Flesh out the readme and add more tests

This commit is contained in:
Sami Samhuri 2025-04-26 13:32:38 -07:00
parent bc05e17c92
commit 7d1e4564ff
No known key found for this signature in database
13 changed files with 289 additions and 68 deletions

121
Readme.md
View file

@ -12,6 +12,87 @@ It uses a Swift `Task` to ensure that all resources are properly cleaned up when
That's it. It's pretty trivial. I just got tired of writing it over and over, mainly for notifications. You still have to map your `Notification`s to something sendable. That's it. It's pretty trivial. I just got tired of writing it over and over, mainly for notifications. You still have to map your `Notification`s to something sendable.
## Usage
The simplest example uses a closure that receives the notification. The closure is async so you can await in there if you need to.
```swift
import AsyncMonitor
class SimplestVersion {
let cancellable = NotificationCenter.default
.notifications(named: .NSCalendarDayChanged)
.map(\.name)
.monitor { _ in
print("The date is now \(Date.now)")
}
}
```
This example uses the context parameter to avoid reference cycles with `self`.
```swift
class WithContext {
var cancellables = Set<AnyAsyncCancellable>()
init() {
NotificationCenter.default
.notifications(named: .NSCalendarDayChanged)
.map(\.name)
.monitor(context: self) { _self, _ in
_self.dayChanged()
}.store(in: &cancellables)
}
func dayChanged() {
print("The date is now \(Date.now)")
}
}
```
### Combine
Working with Combine publishers is trivial thanks to [`AnyPublisher.values`][values].
```swift
import Combine
class CombineExample {
var cancellables = Set<AnyAsyncCancellable>()
init() {
Timer.publish(every: 1.0, on: .main, in: .common)
.autoconnect()
.values
.monitor { date in
print("Timer fired at \(date)")
}
.store(in: &cancellables)
}
}
```
[values]: https://developer.apple.com/documentation/combine/anypublisher/values-3s2uy
### Key-Value Observing (KVO) extension
When you need to observe an object that uses [KVO][] there's an extension method you can use to monitor it:
```swift
class KVOExample {
var cancellables = Set<AnyAsyncCancellable>()
init() {
let progress = Progress(totalUnitCount: 42)
progress.values(for: \.fractionCompleted) { fraction in
print("Progress is \(fraction.formatted(.percent))%")
}.store(in: &cancellables)
}
}
```
[KVO]: https://developer.apple.com/library/archive/documentation/General/Conceptual/DevPedia-CocoaCore/KVO.html
## Installation ## Installation
The only way to install this package is with Swift Package Manager (SPM). Please [file a new issue][] or submit a pull-request if you want to use something else. The only way to install this package is with Swift Package Manager (SPM). Please [file a new issue][] or submit a pull-request if you want to use something else.
@ -36,46 +117,6 @@ When you're integrating this using SPM on its own then add this to the list of d
and then add `"AsyncMonitor"` to the list of dependencies in your target as well. and then add `"AsyncMonitor"` to the list of dependencies in your target as well.
## Usage
The simplest example uses a closure that receives the notification:
```swift
import AsyncMonitor
class SimplestVersion {
let cancellable = NotificationCenter.default
.notifications(named: .NSCalendarDayChanged).map(\.name)
.monitor { _ in
print("The date is now \(Date.now)")
}
}
```
This example uses the context parameter to avoid reference cycles with `self`:
```swift
import AsyncMonitor
class WithContext {
var cancellables = Set<AnyAsyncCancellable>()
init() {
NotificationCenter.default
.notifications(named: .NSCalendarDayChanged).map(\.name)
.monitor(context: self) { _self, _ in
_self.dayChanged()
}.store(in: &cancellables)
}
func dayChanged() {
print("The date is now \(Date.now)")
}
}
```
The closure is async so you can await in there if you need to.
## License ## License
Copyright © 2025 [Sami Samhuri](https://samhuri.net) <sami@samhuri.net>. Released under the terms of the [MIT License][MIT]. Copyright © 2025 [Sami Samhuri](https://samhuri.net) <sami@samhuri.net>. Released under the terms of the [MIT License][MIT].

View file

@ -1,6 +1,8 @@
/// Type-erasing wrapper for ``AsyncCancellable`` that ties its instance lifetime to cancellation. In other words, when you release /// Type-erasing wrapper for ``AsyncCancellable`` that ties its instance lifetime to cancellation. In other words, when you release
/// an instance of ``AnyAsyncCancellable`` and it's deallocated then it automatically cancels its given ``AsyncCancellable``. /// an instance of ``AnyAsyncCancellable`` and it's deallocated then it automatically cancels its given ``AsyncCancellable``.
public class AnyAsyncCancellable: AsyncCancellable { public class AnyAsyncCancellable: AsyncCancellable {
lazy var id = ObjectIdentifier(self)
let canceller: () -> Void let canceller: () -> Void
public init<AC: AsyncCancellable>(cancellable: AC) { public init<AC: AsyncCancellable>(cancellable: AC) {
@ -16,4 +18,14 @@ public class AnyAsyncCancellable: AsyncCancellable {
public func cancel() { public func cancel() {
canceller() canceller()
} }
// MARK: Hashable conformance
public static func == (lhs: AnyAsyncCancellable, rhs: AnyAsyncCancellable) -> Bool {
lhs.id == rhs.id
}
public func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
} }

View file

@ -15,15 +15,3 @@ public extension AsyncCancellable {
set.insert(AnyAsyncCancellable(cancellable: self)) set.insert(AnyAsyncCancellable(cancellable: self))
} }
} }
// MARK: Hashable conformance
public extension AsyncCancellable {
static func == (lhs: Self, rhs: Self) -> Bool {
ObjectIdentifier(lhs) == ObjectIdentifier(rhs)
}
func hash(into hasher: inout Hasher) {
hasher.combine(ObjectIdentifier(self))
}
}

View file

@ -5,7 +5,8 @@
/// ///
/// ``` /// ```
/// NotificationCenter.default /// NotificationCenter.default
/// .notifications(named: .NSCalendarDayChanged).map(\.name) /// .notifications(named: .NSCalendarDayChanged)
/// .map(\.name)
/// .monitor { _ in whatever() } /// .monitor { _ in whatever() }
/// .store(in: &cancellables) /// .store(in: &cancellables)
/// ``` /// ```
@ -43,4 +44,14 @@ public final class AsyncMonitor: Hashable, AsyncCancellable {
public func cancel() { public func cancel() {
task.cancel() task.cancel()
} }
// MARK: Hashable conformance
public static func == (lhs: AsyncMonitor, rhs: AsyncMonitor) -> Bool {
lhs.task == rhs.task
}
public func hash(into hasher: inout Hasher) {
hasher.combine(task)
}
} }

View file

@ -18,8 +18,12 @@ public extension NSObjectProtocol where Self: NSObject {
let token = self.observe(keyPath, options: options) { object, _ in let token = self.observe(keyPath, options: options) { object, _ in
continuation.yield(object[keyPath: keyPath]) continuation.yield(object[keyPath: keyPath])
} }
let locker = TokenLocker(token: token)
continuation.onTermination = { _ in
locker.clear()
}
return stream.monitor { value in return stream.monitor { value in
_ = token // keep this alive _ = locker // keep this alive
changeHandler(value) changeHandler(value)
} }
} }

View file

@ -0,0 +1,16 @@
import Foundation
final class TokenLocker: @unchecked Sendable {
private let lock = NSLock()
private var unsafeToken: NSKeyValueObservation?
init(token: NSKeyValueObservation) {
unsafeToken = token
}
func clear() {
lock.withLock {
unsafeToken = nil
}
}
}

View file

@ -0,0 +1,22 @@
@testable import AsyncMonitor
import Testing
@MainActor class AnyAsyncCancellableTests {
var subject: AnyAsyncCancellable!
@Test func cancelsWhenReleased() {
let cancellable = TestCancellable()
subject = AnyAsyncCancellable(cancellable: cancellable)
#expect(!cancellable.isCancelled)
subject = nil
#expect(cancellable.isCancelled)
}
@Test func cancelsWhenCancelled() {
let cancellable = TestCancellable()
subject = AnyAsyncCancellable(cancellable: cancellable)
#expect(!cancellable.isCancelled)
subject.cancel()
#expect(cancellable.isCancelled)
}
}

View file

@ -0,0 +1,17 @@
@testable import AsyncMonitor
import Testing
@MainActor class AsyncCancellableTests {
var cancellables = Set<AnyAsyncCancellable>()
@Test func storeInsertsIntoSetAndKeepsSubjectAlive() throws {
var subject: TestCancellable? = TestCancellable()
weak var weakSubject: TestCancellable? = subject
try #require(subject).store(in: &cancellables)
#expect(cancellables.count == 1)
subject = nil
#expect(weakSubject != nil)
cancellables.removeAll()
#expect(weakSubject == nil)
}
}

View file

@ -10,10 +10,12 @@ class AsyncMonitorTests {
@Test func callsBlockWhenNotificationsArePosted() async throws { @Test func callsBlockWhenNotificationsArePosted() async throws {
await withCheckedContinuation { [center, name] continuation in await withCheckedContinuation { [center, name] continuation in
subject = center.notifications(named: name).map(\.name).monitor { receivedName in subject = center.notifications(named: name)
#expect(name == receivedName) .map(\.name)
continuation.resume() .monitor { receivedName in
} #expect(name == receivedName)
continuation.resume()
}
Task { Task {
center.post(name: name, object: nil) center.post(name: name, object: nil)
} }
@ -21,9 +23,11 @@ class AsyncMonitorTests {
} }
@Test func doesNotCallBlockWhenOtherNotificationsArePosted() async throws { @Test func doesNotCallBlockWhenOtherNotificationsArePosted() async throws {
subject = center.notifications(named: name).map(\.name).monitor { receivedName in subject = center.notifications(named: name)
Issue.record("Called for irrelevant notification \(receivedName)") .map(\.name)
} .monitor { receivedName in
Issue.record("Called for irrelevant notification \(receivedName)")
}
Task { [center] in Task { [center] in
center.post(name: Notification.Name("something else"), object: nil) center.post(name: Notification.Name("something else"), object: nil)
} }
@ -31,9 +35,11 @@ class AsyncMonitorTests {
} }
@Test @MainActor func stopsCallingBlockWhenDeallocated() async throws { @Test @MainActor func stopsCallingBlockWhenDeallocated() async throws {
subject = center.notifications(named: name).map(\.name).monitor { _ in subject = center.notifications(named: name)
Issue.record("Called after deallocation") .map(\.name)
} .monitor { _ in
Issue.record("Called after deallocation")
}
Task { @MainActor in Task { @MainActor in
subject = nil subject = nil
@ -51,7 +57,8 @@ class AsyncMonitorTests {
init(center: NotificationCenter, deinitHook: @escaping () -> Void) { init(center: NotificationCenter, deinitHook: @escaping () -> Void) {
self.deinitHook = deinitHook self.deinitHook = deinitHook
let name = Notification.Name("irrelevant name") let name = Notification.Name("irrelevant name")
cancellable = center.notifications(named: name).map(\.name) cancellable = center.notifications(named: name)
.map(\.name)
.monitor(context: self) { _, _ in } .monitor(context: self) { _, _ in }
} }
@ -73,7 +80,8 @@ class AsyncMonitorTests {
@Test func stopsCallingBlockWhenContextIsDeallocated() async throws { @Test func stopsCallingBlockWhenContextIsDeallocated() async throws {
var context: NSObject? = NSObject() var context: NSObject? = NSObject()
subject = center.notifications(named: name).map(\.name) subject = center.notifications(named: name)
.map(\.name)
.monitor(context: context!) { context, receivedName in .monitor(context: context!) { context, receivedName in
Issue.record("Called after context was deallocated") Issue.record("Called after context was deallocated")
} }
@ -83,4 +91,21 @@ class AsyncMonitorTests {
} }
try await Task.sleep(for: .milliseconds(10)) try await Task.sleep(for: .milliseconds(10))
} }
@Test func equatable() throws {
let subject = AsyncMonitor(sequence: AsyncStream.just(42)) { _ in }
#expect(subject == subject)
#expect(subject != AsyncMonitor(sequence: AsyncStream.just(42)) { _ in })
}
@Test func hashable() throws {
let subjects = (1...100).map { _ in
AsyncMonitor(sequence: AsyncStream.just(42)) { _ in }
}
var hashValues: Set<Int> = []
for subject in subjects {
hashValues.insert(subject.hashValue)
}
#expect(hashValues.count == subjects.count)
}
} }

View file

@ -0,0 +1,9 @@
import Foundation
extension AsyncSequence where Element: Sendable {
static func just(_ value: Element) -> AsyncStream<Element> {
AsyncStream { continuation in
continuation.yield(value)
}
}
}

View file

@ -0,0 +1,21 @@
@testable import AsyncMonitor
import Foundation
import Testing
class AsyncKVOTests {
var subject: Progress? = Progress(totalUnitCount: 42)
var cancellable: (any AsyncCancellable)?
@Test func yieldsChanges() async throws {
let subject = try #require(subject)
var values = [Double]()
cancellable = subject.values(for: \.fractionCompleted) { progress in
values.append(progress)
}
for _ in 1...3 {
subject.completedUnitCount += 1
await Task.yield()
}
#expect(values.count == 3)
}
}

View file

@ -1,9 +1,12 @@
import Foundation import Foundation
@testable import AsyncMonitor @testable import AsyncMonitor
// MARK: Basics
class SimplestVersion { class SimplestVersion {
let cancellable = NotificationCenter.default let cancellable = NotificationCenter.default
.notifications(named: .NSCalendarDayChanged).map(\.name) .notifications(named: .NSCalendarDayChanged)
.map(\.name)
.monitor { _ in .monitor { _ in
print("The date is now \(Date.now)") print("The date is now \(Date.now)")
} }
@ -14,7 +17,8 @@ class WithContext {
init() { init() {
NotificationCenter.default NotificationCenter.default
.notifications(named: .NSCalendarDayChanged).map(\.name) .notifications(named: .NSCalendarDayChanged)
.map(\.name)
.monitor(context: self) { _self, _ in .monitor(context: self) { _self, _ in
_self.dayChanged() _self.dayChanged()
}.store(in: &cancellables) }.store(in: &cancellables)
@ -24,3 +28,34 @@ class WithContext {
print("The date is now \(Date.now)") print("The date is now \(Date.now)")
} }
} }
// MARK: - Combine
import Combine
class CombineExample {
var cancellables = Set<AnyAsyncCancellable>()
init() {
Timer.publish(every: 1.0, on: .main, in: .common)
.autoconnect()
.values
.monitor { date in
print("Timer fired at \(date)")
}
.store(in: &cancellables)
}
}
// MARK: - KVO
class KVOExample {
var cancellables = Set<AnyAsyncCancellable>()
init() {
let progress = Progress(totalUnitCount: 42)
progress.values(for: \.fractionCompleted) { fraction in
print("Progress is \(fraction.formatted(.percent))%")
}.store(in: &cancellables)
}
}

View file

@ -0,0 +1,20 @@
import AsyncMonitor
class TestCancellable: AsyncCancellable {
lazy var id = ObjectIdentifier(self)
var isCancelled = false
func cancel() {
isCancelled = true
}
// MARK: Hashable conformance
public static func == (lhs: TestCancellable, rhs: TestCancellable) -> Bool {
lhs.id == rhs.id
}
public func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}