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
58 changes: 57 additions & 1 deletion Sources/Atoms/Context/AtomTestContext.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import Combine
import Foundation

/// A context structure that to read, watch, and otherwise interacting with atoms in testing.
///
/// This context has an internal Store that manages atoms, so it can be used to test individual
Expand All @@ -19,6 +22,58 @@ public struct AtomTestContext: AtomWatchableContext {
nonmutating set { container.onUpdate = newValue }
}

/// Waits until any of atoms watched through this context is updated for up to
/// the specified timeout, and then return a boolean value indicating whether an update is done.
///
/// - Parameter interval: The maximum timeout interval that this function can wait until
/// the next update. The default timeout interval is `60`.
/// - Returns: A boolean value indicating whether an update is done.
@discardableResult
public func waitUntilNextUpdate(timeout interval: TimeInterval = 60) async -> Bool {
let updates = AsyncStream<Void> { continuation in
let cancellable = container.notifier.sink(
receiveCompletion: { completion in
continuation.finish()
},
receiveValue: {
continuation.yield()
}
)

let box = UnsafeUncheckedSendableBox(cancellable)
continuation.onTermination = { termination in
switch termination {
case .cancelled:
box.unboxed.cancel()

case .finished:
break

@unknown default:
break
}
}
}

return await withTaskGroup(of: Bool.self) { group in
group.addTask {
var iterator = updates.makeAsyncIterator()
await iterator.next()
return true
}

group.addTask {
try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
return false
}

let didUpdate = await group.next() ?? false
group.cancelAll()

return didUpdate
}
}

/// Accesses the value associated with the given atom without watching to it.
///
/// This method returns a value for the given atom. Even if you access to a value with this method,
Expand Down Expand Up @@ -199,7 +254,7 @@ private extension AtomTestContext {
final class Container {
private let storeContainer = StoreContainer()
private var relationshipContainer = RelationshipContainer()

let notifier = PassthroughSubject<Void, Never>()
var overrides: AtomOverrides
var observers = [AtomObserver]()
var onUpdate: (() -> Void)?
Expand Down Expand Up @@ -227,6 +282,7 @@ private extension AtomTestContext {
shouldNotifyAfterUpdates: shouldNotifyAfterUpdates
) { [weak self] in
self?.onUpdate?()
self?.notifier.send()
}
}
}
Expand Down
6 changes: 2 additions & 4 deletions Tests/AtomsTests/Atom/ObservableObjectAtomTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,19 @@ final class ObservableObjectAtomTests: XCTestCase {
}
}

func test() {
func test() async {
let atom = TestAtom(value: 100)
let context = AtomTestContext()
let object = context.watch(atom)
let expectation = expectation(description: "test")
var updatedValue: Int?

context.onUpdate = {
updatedValue = object.value
expectation.fulfill()
}

object.value = 200
await context.waitUntilNextUpdate()

wait(for: [expectation], timeout: 1)
XCTAssertEqual(updatedValue, 200)
}
}
19 changes: 19 additions & 0 deletions Tests/AtomsTests/Context/AtomTestContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,25 @@ final class AtomTestContextTests: XCTestCase {
XCTAssertTrue(isCalled)
}

func testWaitUntilNextUpdate() async {
let atom = TestStateAtom(defaultValue: 0)
let context = AtomTestContext()

context.watch(atom)

Task {
context[atom] = 1
}

let didUpdate0 = await context.waitUntilNextUpdate()

XCTAssertTrue(didUpdate0)

let didUpdate1 = await context.waitUntilNextUpdate(timeout: 1)

XCTAssertFalse(didUpdate1)
}

func testOverride() {
let atom0 = TestValueAtom(value: 100)
let atom1 = TestValueAtom(value: 200)
Expand Down
125 changes: 63 additions & 62 deletions Tests/AtomsTests/Core/Hook/AsyncSequenceHookTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,73 +51,64 @@ final class AsyncSequenceHookTests: XCTestCase {
XCTAssertEqual(hook.value(context: context).value, 100)
}

func testUpdate() {
func testUpdate() async {
let pipe = AsyncThrowingStreamPipe<Int>()
let hook = AsyncSequenceHook { _ in pipe.stream }
let atom = TestAtom(key: 0, hook: hook)
let context = AtomTestContext()

XCTContext.runActivity(named: "Initially suspending") { _ in
do {
XCTAssertTrue(context.watch(atom).isSuspending)
}

XCTContext.runActivity(named: "Value") { _ in
let expectation = expectation(description: "Update")
context.onUpdate = expectation.fulfill
do {
// Value
pipe.continuation.yield(0)
await context.waitUntilNextUpdate()

wait(for: [expectation], timeout: 1)
XCTAssertEqual(context.watch(atom).value, 0)
}

XCTContext.runActivity(named: "Error") { _ in
let expectation = expectation(description: "Update")
context.onUpdate = expectation.fulfill
do {
// Failure
pipe.continuation.finish(throwing: URLError(.badURL))
await context.waitUntilNextUpdate()

wait(for: [expectation], timeout: 1)
XCTAssertEqual(context.watch(atom).error as? URLError, URLError(.badURL))
}

XCTContext.runActivity(named: "Value after finished") { _ in
let expectation = expectation(description: "Update")
expectation.isInverted = true
context.onUpdate = expectation.fulfill
do {
// Yield value after finish
pipe.continuation.yield(1)
let didUpdate = await context.waitUntilNextUpdate(timeout: 1)

wait(for: [expectation], timeout: 1)
XCTAssertEqual(context.watch(atom).error as? URLError, URLError(.badURL))
XCTAssertFalse(didUpdate)
}

XCTContext.runActivity(named: "Value after termination") { _ in
context.unwatch(atom)
do {
// Yield value after termination
pipe.reset()
context.watch(atom)
context.unwatch(atom)

let expectation = expectation(description: "Update")
expectation.isInverted = true
context.onUpdate = expectation.fulfill
pipe.continuation.yield(0)
let didUpdate = await context.waitUntilNextUpdate(timeout: 1)

wait(for: [expectation], timeout: 1)
XCTAssertFalse(didUpdate)
}

XCTContext.runActivity(named: "Error after termination") { _ in
context.unwatch(atom)
do {
// Yield error after termination
pipe.reset()
context.watch(atom)
context.unwatch(atom)

let expectation = expectation(description: "Update")
expectation.isInverted = true
context.onUpdate = expectation.fulfill
pipe.continuation.finish(throwing: URLError(.badURL))
let didUpdate = await context.waitUntilNextUpdate(timeout: 1)

wait(for: [expectation], timeout: 1)
XCTAssertFalse(didUpdate)
}

XCTContext.runActivity(named: "Override") { _ in
do {
// Override
context.override(atom) { _ in .success(100) }

XCTAssertEqual(context.watch(atom).value, 100)
Expand All @@ -129,50 +120,60 @@ final class AsyncSequenceHookTests: XCTestCase {
let hook = AsyncSequenceHook { _ in pipe.stream }
let atom = TestAtom(key: 0, hook: hook)
let context = AtomTestContext()
var updateCount = 0

context.onUpdate = { updateCount += 1 }

// Refresh

XCTAssertTrue(context.watch(atom).isSuspending)

Task {
pipe.continuation.yield(0)
pipe.continuation.finish(throwing: nil)
do {
XCTAssertTrue(context.watch(atom).isSuspending)
}

pipe.reset()
let phase0 = await context.refresh(atom)
do {
// Refresh
var updateCount = 0
context.onUpdate = { updateCount += 1 }
pipe.reset()

XCTAssertEqual(phase0.value, 0)
XCTAssertEqual(updateCount, 1)
Task {
pipe.continuation.yield(0)
pipe.continuation.finish(throwing: nil)
}

// Cancellation
let phase = await context.refresh(atom)

let refreshTask = Task {
await context.refresh(atom)
XCTAssertEqual(phase.value, 0)
XCTAssertEqual(updateCount, 1)
}

Task {
pipe.continuation.yield(1)
refreshTask.cancel()
}
do {
// Cancellation
var updateCount = 0
context.onUpdate = { updateCount += 1 }
pipe.reset()

pipe.reset()
let phase1 = await refreshTask.value
let refreshTask = Task {
await context.refresh(atom)
}

XCTAssertEqual(phase1.value, 1)
XCTAssertEqual(updateCount, 2)
Task {
pipe.continuation.yield(1)
refreshTask.cancel()
}

// Override
let phase = await refreshTask.value

context.override(atom) { _ in .success(200) }
XCTAssertEqual(phase.value, 1)
XCTAssertEqual(updateCount, 1)
}

do {
// Override
var updateCount = 0
context.onUpdate = { updateCount += 1 }
context.override(atom) { _ in .success(200) }
pipe.reset()

pipe.reset()
let phase2 = await context.refresh(atom)
let phase = await context.refresh(atom)

XCTAssertEqual(phase2.value, 200)
XCTAssertEqual(updateCount, 3)
XCTAssertEqual(phase.value, 200)
XCTAssertEqual(updateCount, 1)
}
}
}
Loading