Skip to content

Commit

Permalink
Support more extensive device state and characteristic simulation
Browse files Browse the repository at this point in the history
  • Loading branch information
Supereg committed Jun 26, 2024
1 parent 1745d29 commit b10ca56
Show file tree
Hide file tree
Showing 5 changed files with 217 additions and 43 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ public final class Characteristic<Value>: @unchecked Sendable {
private let _value: ObservableBox<Value?>
private(set) var injection: CharacteristicPeripheralInjection<Value>?

private let _testInjections = Box(CharacteristicTestInjections<Value>())
private let _testInjections: Box<CharacteristicTestInjections<Value>?> = Box(nil)

var description: CharacteristicDescription {
CharacteristicDescription(id: configuration.id, discoverDescriptors: configuration.discoverDescriptors)
Expand Down
30 changes: 4 additions & 26 deletions Sources/SpeziBluetooth/Model/Properties/DeviceState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,9 @@ import Observation
public final class DeviceState<Value>: @unchecked Sendable {
private let keyPath: KeyPath<BluetoothPeripheral, Value>
private(set) var injection: DeviceStatePeripheralInjection<Value>?

private var _injectedValue = ObservableBox<Value?>(nil)
private let _testInjections: Box<DeviceStateTestInjections<Value>?> = Box(nil)

var objectId: ObjectIdentifier {
ObjectIdentifier(self)
Expand All @@ -111,7 +113,7 @@ public final class DeviceState<Value>: @unchecked Sendable {

/// Retrieve a temporary accessors instance.
public var projectedValue: DeviceStateAccessor<Value> {
DeviceStateAccessor(id: objectId, injection: injection, injectedValue: _injectedValue)
DeviceStateAccessor(id: objectId, keyPath: keyPath, injection: injection, injectedValue: _injectedValue, testInjections: _testInjections)
}


Expand Down Expand Up @@ -150,30 +152,6 @@ extension DeviceState {
return injected
}

let value: Any? = switch keyPath {
case \.id:
nil // we cannot provide a stable id?
case \.name:
Optional<String>.none as Any
case \.state:
PeripheralState.disconnected
case \.advertisementData:
AdvertisementData([:])
case \.rssi:
Int(UInt8.max)
case \.services:
Optional<[GATTService]>.none as Any
default:
nil
}

guard let value else {
return nil
}

guard let value = value as? Value else {
preconditionFailure("Default value \(value) was not the expected type for \(keyPath)")
}
return value
return _testInjections.value?.artificialValue(for: keyPath)

Check warning on line 155 in Sources/SpeziBluetooth/Model/Properties/DeviceState.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/Properties/DeviceState.swift#L155

Added line #L155 was not covered by tests
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,24 @@

import ByteCoding
import CoreBluetooth
import Spezi


struct CharacteristicTestInjections<Value> {
struct CharacteristicTestInjections<Value>: DefaultInitializable {
var writeClosure: ((Value, WriteType) async throws -> Void)?
var readClosure: (() async throws -> Value)?
var requestClosure: ((Value) async throws -> Value)?
var subscriptions: ChangeSubscriptions<Value>?
var simulatePeripheral = false

init() {}

mutating func enableSubscriptions() {
// there is no BluetoothManager, so we need to create a queue on the fly
subscriptions = ChangeSubscriptions<Value>(
queue: DispatchSerialQueue(label: "edu.stanford.spezi.bluetooth.testing-\(Self.self)", qos: .userInitiated)
)
}

Check warning on line 28 in Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift#L23-L28

Added lines #L23 - L28 were not covered by tests
}


Expand Down Expand Up @@ -59,14 +71,14 @@ public struct CharacteristicAccessor<Value> {
/// We keep track of this for testing support.
private let _value: ObservableBox<Value?>
/// Closure that captures write for testing support.
private let _testInjections: Box<CharacteristicTestInjections<Value>>
private let _testInjections: Box<CharacteristicTestInjections<Value>?>


init(
configuration: Characteristic<Value>.Configuration,
injection: CharacteristicPeripheralInjection<Value>?,
value: ObservableBox<Value?>,
testInjections: Box<CharacteristicTestInjections<Value>>
testInjections: Box<CharacteristicTestInjections<Value>?>
) {
self.configuration = configuration
self.injection = injection
Expand Down Expand Up @@ -120,6 +132,10 @@ extension CharacteristicAccessor where Value: ByteDecodable {
///
/// This property creates an AsyncStream that yields all future updates to the characteristic value.
public var subscription: AsyncStream<Value> {
if let subscriptions = _testInjections.value?.subscriptions {
return subscriptions.newSubscription()
}

guard let injection else {
preconditionFailure(
"The `subscription` of a @Characteristic cannot be accessed within the initializer. Defer access to the `configure() method"
Expand Down Expand Up @@ -172,6 +188,16 @@ extension CharacteristicAccessor where Value: ByteDecodable {
/// Otherwise, the action will only run strictly if the value changes.
/// - action: The change handler to register, receiving both the old and new value.
public func onChange(initial: Bool = false, perform action: @escaping (_ oldValue: Value, _ newValue: Value) async -> Void) {
if let subscriptions = _testInjections.value?.subscriptions {
let id = subscriptions.newOnChangeSubscription(perform: action)

if initial, let value = _value.value {
// if there isn't a value already, initial won't work properly with injections
subscriptions.notifySubscriber(id: id, with: value)

Check warning on line 196 in Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift#L192-L196

Added lines #L192 - L196 were not covered by tests
}
return
}

guard let injection else {
preconditionFailure(
"""
Expand Down Expand Up @@ -210,8 +236,17 @@ extension CharacteristicAccessor where Value: ByteDecodable {
/// It might also throw a ``BluetoothError/notPresent(service:characteristic:)`` or ``BluetoothError/incompatibleDataFormat`` error.
@discardableResult
public func read() async throws -> Value {
if let injectedReadClosure = _testInjections.value.readClosure {
return try await injectedReadClosure()
if let testInjection = _testInjections.value {
if let injectedReadClosure = testInjection.readClosure {
return try await injectedReadClosure()
}

if testInjection.simulatePeripheral {
guard let value = _value.value else {
throw BluetoothError.notPresent(characteristic: configuration.id)
}
return value
}

Check warning on line 249 in Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift#L240-L249

Added lines #L240 - L249 were not covered by tests
}

guard let injection else {
Expand All @@ -236,9 +271,16 @@ extension CharacteristicAccessor where Value: ByteEncodable {
/// - Throws: Throws an `CBError` or `CBATTError` if the write fails.
/// It might also throw a ``BluetoothError/notPresent(service:characteristic:)`` error.
public func write(_ value: Value) async throws {
if let injectedWriteClosure = _testInjections.value.writeClosure {
try await injectedWriteClosure(value, .withResponse)
return
if let testInjection = _testInjections.value {
if let injectedWriteClosure = testInjection.writeClosure {
try await injectedWriteClosure(value, .withResponse)
return
}

if testInjection.simulatePeripheral {
inject(value)
return
}

Check warning on line 283 in Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift#L279-L283

Added lines #L279 - L283 were not covered by tests
}

guard let injection else {
Expand All @@ -258,9 +300,16 @@ extension CharacteristicAccessor where Value: ByteEncodable {
/// - Throws: Throws an `CBError` or `CBATTError` if the write fails.
/// It might also throw a ``BluetoothError/notPresent(service:characteristic:)`` error.
public func writeWithoutResponse(_ value: Value) async throws {
if let injectedWriteClosure = _testInjections.value.writeClosure {
try await injectedWriteClosure(value, .withoutResponse)
return
if let testInjection = _testInjections.value {
if let injectedWriteClosure = testInjection.writeClosure {
try await injectedWriteClosure(value, .withoutResponse)
return
}

if testInjection.simulatePeripheral {
inject(value)
return
}

Check warning on line 312 in Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift#L303-L312

Added lines #L303 - L312 were not covered by tests
}

guard let injection else {
Expand Down Expand Up @@ -290,7 +339,7 @@ extension CharacteristicAccessor where Value: ControlPointCharacteristic {
/// ``BluetoothError/controlPointRequiresNotifying(service:characteristic:)`` or
/// ``BluetoothError/controlPointInProgress(service:characteristic:)`` error.
public func sendRequest(_ value: Value, timeout: Duration = .seconds(20)) async throws -> Value {
if let injectedRequestClosure = _testInjections.value.requestClosure {
if let injectedRequestClosure = _testInjections.value?.requestClosure {
return try await injectedRequestClosure(value)
}

Expand All @@ -306,6 +355,22 @@ extension CharacteristicAccessor where Value: ControlPointCharacteristic {

@_spi(TestingSupport)
extension CharacteristicAccessor {
/// Enable testing support for subscriptions and onChange handlers.
///
/// After this method is called, subsequent calls to ``subscription`` and ``onChange(initial:perform:)-6ltwk`` or ``onChange(initial:perform:)-5awby``
/// will be stored and called when injecting new values via `inject(_:)`.
/// - Note: Make sure to inject a initial value if you want to make the `initial` property work properly
public func enableSubscriptions() {
_testInjections.valueOrInitialize.enableSubscriptions()
}

Check warning on line 365 in Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift#L363-L365

Added lines #L363 - L365 were not covered by tests

/// Simulate a peripheral by automatically mocking read and write commands.
///
/// - Note: `onWrite(perform:)` and `onRead(return:)` closures take precedence.
public func enablePeripheralSimulation(_ enabled: Bool = true) {
_testInjections.valueOrInitialize.simulatePeripheral = enabled
}

Check warning on line 372 in Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift#L370-L372

Added lines #L370 - L372 were not covered by tests

/// Inject a custom value for previewing purposes.
///
/// This method can be used to inject a custom characteristic value.
Expand All @@ -317,6 +382,10 @@ extension CharacteristicAccessor {
/// - Parameter value: The value to inject.
public func inject(_ value: Value) {
_value.value = value

if let subscriptions = _testInjections.value?.subscriptions {
subscriptions.notifySubscribers(with: value)

Check warning on line 387 in Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift#L387

Added line #L387 was not covered by tests
}
}

/// Inject a custom action that sinks all write operations for testing purposes.
Expand All @@ -326,7 +395,7 @@ extension CharacteristicAccessor {
///
/// - Parameter action: The action to inject. Called for every write.
public func onWrite(perform action: @escaping (Value, WriteType) async throws -> Void) {
_testInjections.value.writeClosure = action
_testInjections.valueOrInitialize.writeClosure = action
}

/// Inject a custom action that sinks all read operations for testing purposes.
Expand All @@ -336,7 +405,7 @@ extension CharacteristicAccessor {
///
/// - Parameter action: The action to inject. Called for every read.
public func onRead(return action: @escaping () async throws -> Value) {
_testInjections.value.readClosure = action
_testInjections.valueOrInitialize.readClosure = action

Check warning on line 408 in Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/PropertySupport/CharacteristicAccessor.swift#L408

Added line #L408 was not covered by tests
}

/// Inject a custom action that sinks all control point request operations for testing purposes.
Expand All @@ -346,6 +415,6 @@ extension CharacteristicAccessor {
///
/// - Parameter action: The action to inject. Called for every control point request.
public func onRequest(perform action: @escaping (Value) async throws -> Value) {
_testInjections.value.requestClosure = action
_testInjections.valueOrInitialize.requestClosure = action
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,57 @@
// SPDX-License-Identifier: MIT
//

import Foundation
import Spezi


struct DeviceStateTestInjections<Value>: DefaultInitializable {
var subscriptions: ChangeSubscriptions<Value>?

init() {}

Check warning on line 16 in Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift#L16

Added line #L16 was not covered by tests

mutating func enableSubscriptions() {
// there is no BluetoothManager, so we need to create a queue on the fly
subscriptions = ChangeSubscriptions<Value>(
queue: DispatchSerialQueue(label: "edu.stanford.spezi.bluetooth.testing-\(Self.self)", qos: .userInitiated)
)
}

Check warning on line 23 in Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift#L18-L23

Added lines #L18 - L23 were not covered by tests

func artificialValue(for keyPath: KeyPath<BluetoothPeripheral, Value>) -> Value? {
// swiftlint:disable:previous cyclomatic_complexity

let value: Any? = switch keyPath {
case \.id:
nil // we cannot provide a stable id?
case \.name, \.localName:
Optional<String>.none as Any
case \.state:
PeripheralState.disconnected
case \.advertisementData:
AdvertisementData([:])
case \.rssi:
Int(UInt8.max)
case \.nearby:
false
case \.lastActivity:
Date.now
case \.services:
Optional<[GATTService]>.none as Any
default:
nil
}

guard let value else {
return nil
}

guard let value = value as? Value else {
preconditionFailure("Default value \(value) was not the expected type for \(keyPath)")
}
return value
}

Check warning on line 57 in Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift#L25-L57

Added lines #L25 - L57 were not covered by tests
}


/// Interact with a given device state.
///
Expand All @@ -18,15 +69,25 @@
/// - ``onChange(initial:perform:)-9igc9``
public struct DeviceStateAccessor<Value> {
private let id: ObjectIdentifier
private let keyPath: KeyPath<BluetoothPeripheral, Value>
private let injection: DeviceStatePeripheralInjection<Value>?
/// To support testing support.
private let _injectedValue: ObservableBox<Value?>
private let _testInjections: Box<DeviceStateTestInjections<Value>?>


init(id: ObjectIdentifier, injection: DeviceStatePeripheralInjection<Value>?, injectedValue: ObservableBox<Value?>) {
init(
id: ObjectIdentifier,
keyPath: KeyPath<BluetoothPeripheral, Value>,
injection: DeviceStatePeripheralInjection<Value>?,
injectedValue: ObservableBox<Value?>,
testInjections: Box<DeviceStateTestInjections<Value>?>
) {
self.id = id
self.keyPath = keyPath
self.injection = injection
self._injectedValue = injectedValue
self._testInjections = testInjections
}
}

Expand All @@ -36,6 +97,10 @@ extension DeviceStateAccessor {
///
/// This property creates an AsyncStream that yields all future updates to the device state.
public var subscription: AsyncStream<Value> {
if let subscriptions = _testInjections.value?.subscriptions {
return subscriptions.newSubscription()
}

Check warning on line 102 in Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift#L99-L102

Added lines #L99 - L102 were not covered by tests

guard let injection else {
preconditionFailure(
"The `subscription` of a @DeviceState cannot be accessed within the initializer. Defer access to the `configure() method"
Expand Down Expand Up @@ -87,6 +152,17 @@ extension DeviceStateAccessor {
/// strictly if the value changes.
/// - action: The change handler to register, receiving both the old and new value.
public func onChange(initial: Bool = false, perform action: @escaping (_ oldValue: Value, _ newValue: Value) async -> Void) {
if let testInjections = _testInjections.value,
let subscriptions = testInjections.subscriptions {
let id = subscriptions.newOnChangeSubscription(perform: action)

if initial, let value = _injectedValue.value ?? testInjections.artificialValue(for: keyPath) {
// if there isn't a value already, initial won't work properly with injections
subscriptions.notifySubscriber(id: id, with: value)

Check warning on line 161 in Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift#L157-L161

Added lines #L157 - L161 were not covered by tests
}
return
}

guard let injection else {
preconditionFailure(
"""
Expand Down Expand Up @@ -115,6 +191,15 @@ extension DeviceStateAccessor: @unchecked Sendable {}

@_spi(TestingSupport)
extension DeviceStateAccessor {
/// Enable testing support for subscriptions and onChange handlers.
///
/// After this method is called, subsequent calls to ``subscription`` and ``onChange(initial:perform:)-6ltwk`` or ``onChange(initial:perform:)-5awby``
/// will be stored and called when injecting new values via `inject(_:)`.
/// - Note: Make sure to inject a initial value if you want to make the `initial` property work properly
public func enableSubscriptions() {
_testInjections.valueOrInitialize.enableSubscriptions()
}

Check warning on line 201 in Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift#L199-L201

Added lines #L199 - L201 were not covered by tests

/// Inject a custom value for previewing purposes.
///
/// This method can be used to inject a custom device state value.
Expand All @@ -126,5 +211,9 @@ extension DeviceStateAccessor {
/// - Parameter value: The value to inject.
public func inject(_ value: Value) {
_injectedValue.value = value

if let subscriptions = _testInjections.value?.subscriptions {
subscriptions.notifySubscribers(with: value)
}

Check warning on line 217 in Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/PropertySupport/DeviceStateAccessor.swift#L214-L217

Added lines #L214 - L217 were not covered by tests
}
}
Loading

0 comments on commit b10ca56

Please sign in to comment.