diff --git a/Package.swift b/Package.swift index a1c4da1..7e54b96 100644 --- a/Package.swift +++ b/Package.swift @@ -13,6 +13,12 @@ var targets: [Target] = [ swiftSettings: [.swiftLanguageMode(.v6)]), .testTarget(name: "Switch2KitCTests", dependencies: ["Switch2Kit", "Switch2KitC", "Switch2KitCABI"], swiftSettings: [.swiftLanguageMode(.v6)]), + // Compile the actual native SDL fixture during swift test, not just its separate CMake build. + .testTarget(name: "Switch2KitSDLFixtureTests", dependencies: ["Switch2Kit", "Switch2KitC", "Switch2KitCABI"], + path: "tests/sdl-inprocess", + exclude: ["CMakeLists.txt", "Clock.cpp", "Clock.hpp", "main.cpp", "motion.cpp", "verify.sh"], + sources: ["Fixture.swift", "FixtureTests.swift"], + swiftSettings: [.swiftLanguageMode(.v6)]), .target(name: "Switch2Kit", dependencies: radioDependencies, path: "Sources/Switch2Kit", swiftSettings: [.swiftLanguageMode(.v6)]), .testTarget(name: "Switch2KitTests", dependencies: ["Switch2Kit"], path: "Tests/Switch2KitTests", swiftSettings: [.swiftLanguageMode(.v6)]) diff --git a/Sources/Switch2KitC/Context.swift b/Sources/Switch2KitC/Context.swift index db6a536..9755148 100644 --- a/Sources/Switch2KitC/Context.swift +++ b/Sources/Switch2KitC/Context.swift @@ -9,6 +9,7 @@ package protocol ControllerSource: Sendable { func start() func stop(completion: @escaping @Sendable () -> Void) func discover(seconds: Double) + func setAutomaticDiscovery(_ enabled: Bool) func disconnect(id: Switch2ControllerID, connection: UUID, forget: Bool) func rumble(id: Switch2ControllerID, connection: UUID, strong: Double, weak: Double, duration: Double?, feedback: Bool) func player(id: Switch2ControllerID, connection: UUID, number: Int) @@ -17,7 +18,12 @@ package protocol ControllerSource: Sendable { // All mutable ownership/lifecycle state is mutex protected. Read has a single // logical host reader; start/stop/control calls can arrive from other threads. package final class CContext: Sendable { - private struct Lifecycle: Sendable { var running = false; var stopping = false; var closed = false } + private struct Lifecycle: Sendable { + var running = false + var stopping = false + var closed = false + var automaticDiscovery = false + } private let lifecycle = Mutex(Lifecycle()) package let source: any ControllerSource private let reader: ControllerEventReader @@ -47,6 +53,18 @@ package final class CContext: Sendable { reader.cancel() } deinit { reader.cancel() } + package func setAutomaticDiscovery(_ enabled: Bool) -> Int32 { + lifecycle.withLock { value in + guard !value.closed else { return 1 } + guard !value.stopping else { return 5 } + guard value.automaticDiscovery != enabled else { return 0 } + // Configuration only: never start a stopped source, consume input, + // or disturb ready sessions. The source enqueues on its radio queue. + value.automaticDiscovery = enabled + source.setAutomaticDiscovery(enabled) + return 0 + } + } package func discover(_ seconds: Double) -> Int32 { guard seconds.isFinite, (0.1...300).contains(seconds) else { return 1 } return lifecycle.withLock { value in diff --git a/Sources/Switch2KitC/Exports.swift b/Sources/Switch2KitC/Exports.swift index 203c855..f42b7ed 100644 --- a/Sources/Switch2KitC/Exports.swift +++ b/Sources/Switch2KitC/Exports.swift @@ -52,6 +52,13 @@ public func stopContext(_ handle: OpaquePointer?) -> Int32 { context(handle)?.st @_cdecl("s2k_discover") public func discoverContext(_ handle: OpaquePointer?, _ seconds: Double) -> Int32 { context(handle)?.discover(seconds) ?? 1 } +/// C ABI entry point; ownership, threading and argument rules are specified in Switch2KitC.h. +@_cdecl("s2k_set_automatic_discovery") +public func setAutomaticDiscoveryContext(_ handle: OpaquePointer?, _ enabled: UInt32) -> Int32 { + guard enabled <= 1 else { return 1 } + return context(handle)?.setAutomaticDiscovery(enabled == 1) ?? 1 +} + /// C ABI entry point; ownership, threading and argument rules are specified in Switch2KitC.h. @_cdecl("s2k_read") public func readContext(_ handle: OpaquePointer?, _ events: UnsafeMutablePointer?, @@ -111,4 +118,4 @@ public func pulseRumble(_ handle: OpaquePointer?, _ id: UnsafePointer?, _ public func setPlayer(_ handle: OpaquePointer?, _ id: UnsafePointer?, _ connection: UnsafePointer?, _ number: UInt32) -> Int32 { guard (1...8).contains(number) else { return 1 } return control(handle, id, connection) { c, source in source.player(id: c.id, connection: c.connectionID, number: Int(number)); return 0 } -} +} \ No newline at end of file diff --git a/Sources/Switch2KitC/ManagerSource.swift b/Sources/Switch2KitC/ManagerSource.swift index 04c5348..c1b3a13 100644 --- a/Sources/Switch2KitC/ManagerSource.swift +++ b/Sources/Switch2KitC/ManagerSource.swift @@ -11,6 +11,9 @@ package final class ManagerSource: ControllerSource { package func start() { manager.start() } package func stop(completion: @escaping @Sendable () -> Void) { manager.stop(completion: completion) } package func discover(seconds: Double) { try? manager.discover(for: seconds) } + package func setAutomaticDiscovery(_ enabled: Bool) { + manager.configureDiscovery(enabled ? .automatic : .onDemand) + } package func disconnect(id: Switch2ControllerID, connection: UUID, forget: Bool) { manager.transport.disconnect(id, forget: forget, expectedConnection: connection) } diff --git a/Sources/Switch2KitCABI/include/Switch2KitC.h b/Sources/Switch2KitCABI/include/Switch2KitC.h index 3e95b4b..552be20 100644 --- a/Sources/Switch2KitCABI/include/Switch2KitC.h +++ b/Sources/Switch2KitCABI/include/Switch2KitC.h @@ -117,7 +117,7 @@ enum { S2K_LINK_LOST=1, S2K_REQUESTED, S2K_FORGOTTEN, S2K_STOPPED, * not overwrite ordinary events before they have been processed. */ enum { S2K_READ_RESYNC=1u, S2K_READ_MORE=2u }; /** Creation configuration; initialize every field. Event capacity is 1..256, controllers 1..64. - * The facade uses on-demand discovery and does not persist identities or preferences. */ + * The facade defaults to on-demand discovery and does not persist identities or preferences. */ typedef struct S2KConfig { uint32_t abi_version, struct_size, maximum_controllers, event_capacity; } S2KConfig; @@ -142,6 +142,19 @@ S2KResult s2k_start(S2KContext *context); S2KResult s2k_stop(S2KContext *context); /** Open/replace a 0.1..300 second scan window after start; ready controllers are unaffected. */ S2KResult s2k_discover(S2KContext *context, double seconds); +/** Opt in to continuous discovery of available supported controllers (enabled=1), + * including reconnect after link loss; enabled=0 restores on-demand discovery. + * Default is 0. Any other value is INVALID_ARGUMENT. Idempotent and thread-safe. + * Configuration is queued; it does not start Bluetooth or revive stopped support. + * Call start separately. BUSY while asynchronous stop is finishing. + * Switching to 0 cancels automatic scanning, not ready connections. An already + * admitted handshake may finish. Use stop to cancel attempts and disconnect all. + * Automatic mode has no discovery-window deadline and resumes scanning when the + * radio/capacity permits. The host need not periodically renew discover calls. + * The choice survives stop/start on this context, not destroy/create. Hosts own + * user consent and persistence. New additive ABI-v1 symbol; link a matching SDK. + */ +S2KResult s2k_set_automatic_discovery(S2KContext *context, uint32_t enabled); /** Read without blocking or invoking callbacks. One logical reader per handle. * events may be NULL only when capacity is zero. event_stride must equal sizeof(S2KEvent); * snapshot_size must equal sizeof(S2KSnapshot). count/snapshot/flags must be non-NULL. @@ -171,4 +184,4 @@ S2KResult s2k_set_player(S2KContext *context, const S2KID *id, const S2KID *conn #ifdef __cplusplus } #endif -#endif +#endif \ No newline at end of file diff --git a/Tests/Switch2KitCTests/AutomaticDiscoveryTests.swift b/Tests/Switch2KitCTests/AutomaticDiscoveryTests.swift new file mode 100644 index 0000000..a38b5f9 --- /dev/null +++ b/Tests/Switch2KitCTests/AutomaticDiscoveryTests.swift @@ -0,0 +1,83 @@ +import Foundation +import XCTest +import Switch2Kit +import Switch2KitCABI +@testable import Switch2KitC + +final class AutomaticDiscoveryTests: XCTestCase { + func testValidationDefaultAndRadioFreeConfiguration() throws { + let source = TestSource(), context = try CContext(source: source, capacity: 16) + let handle = retainedHandle(context) + defer { destroyContext(handle) } + XCTAssertEqual(s2k_set_automatic_discovery(nil, 1), 1) + XCTAssertEqual(s2k_set_automatic_discovery(handle, 2), 1) + XCTAssertEqual(s2k_set_automatic_discovery(handle, UInt32.max), 1) + XCTAssertFalse(source.state.withLock { $0.automaticDiscovery }) + XCTAssertTrue(source.state.withLock { $0.calls.isEmpty }) + XCTAssertEqual(s2k_set_automatic_discovery(handle, 0), 0) + XCTAssertEqual(s2k_set_automatic_discovery(handle, 1), 0) + XCTAssertEqual(s2k_set_automatic_discovery(handle, 1), 0) + XCTAssertEqual(source.state.withLock { $0.calls }, ["automatic"]) + XCTAssertFalse(source.state.withLock { $0.running }) + XCTAssertFalse(context.read(maximum: 0).snapshot.isRunning) + } + + func testTogglePreservesReadySessionAndInputReader() throws { + let source = TestSource(), context = try CContext(source: source, capacity: 16) + let handle = retainedHandle(context) + defer { destroyContext(handle) } + XCTAssertEqual(startContext(handle), 0) + source.emit(pressed: true) + let before = context.read(maximum: 16).snapshot.controllers[0] + source.emit(sequence: 2, pressed: false) + XCTAssertEqual(s2k_set_automatic_discovery(handle, 1), 0) + XCTAssertEqual(s2k_set_automatic_discovery(handle, 0), 0) + let batch = context.read(maximum: 16) + XCTAssertFalse(batch.resync) + XCTAssertEqual(batch.events.count, 1) + XCTAssertEqual(batch.snapshot.controllers[0].connectionID, before.connectionID) + XCTAssertEqual(batch.snapshot.controllers[0].state.sequence, 2) + XCTAssertEqual(source.state.withLock { $0.calls }, ["automatic", "on-demand"]) + } + + func testStopIsAuthoritativeAndChoiceSurvivesRestart() throws { + let source = TestSource(), context = try CContext(source: source, capacity: 16) + let handle = retainedHandle(context) + defer { destroyContext(handle) } + XCTAssertEqual(s2k_set_automatic_discovery(handle, 1), 0) + XCTAssertEqual(startContext(handle), 0) + XCTAssertEqual(stopContext(handle), 0) + XCTAssertEqual(s2k_set_automatic_discovery(handle, 0), 5) + XCTAssertEqual(startContext(handle), 5) + XCTAssertTrue(source.state.withLock { $0.automaticDiscovery }) + source.finishStop() + XCTAssertEqual(s2k_set_automatic_discovery(handle, 1), 0) + XCTAssertFalse(context.read(maximum: 0).snapshot.isRunning) + XCTAssertEqual(startContext(handle), 0) + XCTAssertTrue(source.state.withLock { $0.automaticDiscovery }) + XCTAssertEqual(stopContext(handle), 0) + source.finishStop() + XCTAssertEqual(s2k_set_automatic_discovery(handle, 0), 0) + XCTAssertFalse(source.state.withLock { $0.running }) + context.close() + XCTAssertEqual(s2k_set_automatic_discovery(handle, 1), 1) + } + + func testConcurrentConfigurationCannotRestartStoppedSource() throws { + let source = TestSource(), context = try CContext(source: source, capacity: 16) + XCTAssertEqual(context.start(), 0) + DispatchQueue.concurrentPerform(iterations: 200) { index in + if index == 100 { + XCTAssertEqual(context.stop(), 0) + } else { + let result = context.setAutomaticDiscovery(index % 2 == 0) + XCTAssertTrue(result == 0 || result == 5) + } + } + XCTAssertFalse(source.state.withLock { $0.running }) + XCTAssertTrue(context.read(maximum: 0).stopping) + source.finishStop() + XCTAssertEqual(context.setAutomaticDiscovery(true), 0) + XCTAssertFalse(source.state.withLock { $0.running }) + } +} diff --git a/Tests/Switch2KitCTests/TestSource.swift b/Tests/Switch2KitCTests/TestSource.swift index 5422581..84eaa5b 100644 --- a/Tests/Switch2KitCTests/TestSource.swift +++ b/Tests/Switch2KitCTests/TestSource.swift @@ -8,6 +8,7 @@ final class TestSource: ControllerSource { let hub = ControllerEventHub() struct State: Sendable { var running = false + var automaticDiscovery = false var controllers: [Int: Switch2Controller] = [:] var lifetimes: [Int: SessionLifetime] = [:] var calls: [String] = [] @@ -33,6 +34,12 @@ final class TestSource: ControllerSource { completion?() } func discover(seconds: Double) { state.withLock { $0.calls.append("discover") } } + func setAutomaticDiscovery(_ enabled: Bool) { + state.withLock { + $0.automaticDiscovery = enabled + $0.calls.append(enabled ? "automatic" : "on-demand") + } + } func disconnect(id: Switch2ControllerID, connection: UUID, forget: Bool) { state.withLock { $0.calls.append("disconnect") } } func rumble(id: Switch2ControllerID, connection: UUID, strong: Double, weak: Double, duration: Double?, feedback: Bool) { state.withLock { $0.calls.append(feedback ? "feedback" : "rumble") } diff --git a/Tests/Switch2KitTests/AutomaticDiscoveryPolicyTests.swift b/Tests/Switch2KitTests/AutomaticDiscoveryPolicyTests.swift new file mode 100644 index 0000000..1715afa --- /dev/null +++ b/Tests/Switch2KitTests/AutomaticDiscoveryPolicyTests.swift @@ -0,0 +1,28 @@ +import Foundation +import XCTest +@testable import Switch2Kit + +final class AutomaticDiscoveryPolicyTests: XCTestCase { + func testAutomaticDiscoverySurvivesLongAbsenceWithoutRenewal() { + let queue = DispatchQueue(label: "test.automatic-discovery") + queue.sync { + let policy = ControllerDiscoveryPolicy(queue: queue, mode: .onDemand) {} + let id = UUID() + XCTAssertFalse(policy.shouldScan(readyIDs: [], now: 0)) + policy.configure(mode: .automatic, remembered: []) + XCTAssertTrue(policy.shouldScan(readyIDs: [], now: 0)) + XCTAssertTrue(policy.shouldScan(readyIDs: [id], now: 60)) + // Controller powers off while the emulator is paused for a day. + XCTAssertTrue(policy.shouldScan(readyIDs: [], now: 86_400)) + XCTAssertNil(policy.deadline) + XCTAssertTrue(policy.shouldScan(readyIDs: [id], now: 86_401)) + policy.cancelWindow() + XCTAssertTrue(policy.shouldScan(readyIDs: [], now: 172_800)) + policy.configure(mode: .onDemand, remembered: []) + XCTAssertFalse(policy.shouldScan(readyIDs: [id], now: 172_801)) + XCTAssertTrue(policy.openWindow(seconds: 60, now: 172_801)) + XCTAssertTrue(policy.shouldScan(readyIDs: [], now: 172_860)) + XCTAssertFalse(policy.shouldScan(readyIDs: [], now: 172_861)) + } + } +} diff --git a/docs/switch2kit/automatic-discovery.md b/docs/switch2kit/automatic-discovery.md new file mode 100644 index 0000000..fe0a15e --- /dev/null +++ b/docs/switch2kit/automatic-discovery.md @@ -0,0 +1,44 @@ +# Automatic discovery for native hosts + +The C facade defaults to a finite, explicitly requested discovery window. That +is appropriate for manual pairing, but a controller that powers off and returns +after the window expires cannot be rediscovered until the host requests another +window. Pausing emulation does not extend that window. + +A native host can now opt in with `s2k_set_automatic_discovery(context, 1)` and +then call `s2k_start(context)`. This selects the existing Swift `.automatic` +policy: supported advertising controllers can connect whenever the transport +has capacity, including after a long absence. It is not a known-device allowlist. +It does not pair arbitrary Bluetooth devices or change the host's port mappings. + +Discovery is event-driven on the private Bluetooth queue. The host does not need +a timer that repeatedly calls `s2k_discover`, or to reopen its settings window. +The transport still serializes handshakes, uses duplicate-filtered scans, bounds +retry work and pauses discovery while connecting or at capacity. Continuous +scanning still uses radio resources; hosts should make this an explicit option. + +The setter is idempotent, accepts only 0/1, and is serialized with lifecycle +commands. It does not start a stopped manager or consume input. Configuration +while asynchronous stop is finishing returns `S2K_BUSY`. The mode survives +stop/start on the same context. Destroy/create restores the off default; the +host owns saved preferences and main-thread creation. + +Setting 0 returns to on-demand discovery without disconnecting ready sessions. +An already admitted handshake can finish. For an explicit Disconnect action, +call `s2k_stop` and keep the host's stopped-state fence: do not automatically +restart from an input poll, settings refresh or resume event. Only a deliberate +start, or a later application launch with prior user consent, should restart it. + +The function is an additive ABI-v1 symbol; all existing structures and defaults +are unchanged. Hosts using it must pin/link a library revision that supplies it. +Existing C/C++ consumers that do not call it retain manual discovery behavior. + +## Validation boundary + +The regression suite checks argument validation through the C declaration, +radio-free configuration, live-session/input-reader preservation, stop/restart, +concurrent configuration and simulated long-absence policy behavior. These are +not physical Bluetooth tests. On a Mac, verify controller power-off/wake after +more than 60 seconds, repeated cycles, a paused game, Bluetooth off/on, two +controllers reconnecting in reverse order, and explicit Disconnect followed by +wake. Initial pairing, permissions and actual hardware availability still apply. diff --git a/tests/sdl-inprocess/Fixture.swift b/tests/sdl-inprocess/Fixture.swift index e1576d3..8dab338 100644 --- a/tests/sdl-inprocess/Fixture.swift +++ b/tests/sdl-inprocess/Fixture.swift @@ -5,13 +5,15 @@ import Switch2KitC import Switch2KitCABI // This module is a test fixture, not part of the SDK or its distributions. -private final class SDLTestSource: ControllerSource { +final class SDLTestSource: ControllerSource { let hub = ControllerEventHub() struct State: Sendable { var controllers: [Int32: Switch2Controller] = [:] var lifetimes: [Int32: SessionLifetime] = [:] var calls: [Switch2ControllerID: (count: UInt32, strong: Double, weak: Double)] = [:] var running = false + var automaticDiscovery = false + var discoveryConfigurationCount: UInt32 = 0 var stopCompletion: (@Sendable () -> Void)? } let state = Mutex(State()) @@ -29,6 +31,13 @@ private final class SDLTestSource: ControllerSource { } } func discover(seconds: Double) {} + func setAutomaticDiscovery(_ enabled: Bool) { + // Record policy intent without simulating Bluetooth or changing ready sessions. + state.withLock { + $0.automaticDiscovery = enabled + $0.discoveryConfigurationCount += 1 + } + } func disconnect(id: Switch2ControllerID, connection: UUID, forget: Bool) {} func rumble(id: Switch2ControllerID, connection: UUID, strong: Double, weak: Double, duration: Double?, feedback: Bool) { state.withLock { value in diff --git a/tests/sdl-inprocess/FixtureTests.swift b/tests/sdl-inprocess/FixtureTests.swift new file mode 100644 index 0000000..934fa91 --- /dev/null +++ b/tests/sdl-inprocess/FixtureTests.swift @@ -0,0 +1,90 @@ +import Foundation +import XCTest +import Switch2Kit +import Switch2KitC +import Switch2KitCABI + +// Exercise the SAME source compiled into libS2KSDLFixture by the native CMake tests. +// No physical radio, SDL implementation, or emulator behavior is simulated here. +final class SDLFixtureTests: XCTestCase { + private func make() throws -> (SDLTestSource, CContext, OpaquePointer) { + let handle = try XCTUnwrap(fixtureCreate()) + let context = Unmanaged.fromOpaque(UnsafeRawPointer(handle)).takeUnretainedValue() + return (context.source as! SDLTestSource, context, handle) + } + + private func finishStop(_ source: SDLTestSource) throws { + let completion = source.state.withLock { value in + let completion = value.stopCompletion + value.stopCompletion = nil + return completion + } + // Complete the fixture's deferred stop outside both source and context locks. + try XCTUnwrap(completion)() + } + + func testAutomaticDiscoveryConfigurationIsValidatedIdempotentAndRadioFree() throws { + let (source, context, handle) = try make() + defer { destroyContext(handle) } + XCTAssertFalse(source.state.withLock { $0.automaticDiscovery }) + XCTAssertEqual(s2k_set_automatic_discovery(nil, 1), 1) + XCTAssertEqual(s2k_set_automatic_discovery(handle, 2), 1) + XCTAssertEqual(s2k_set_automatic_discovery(handle, 0), 0) + XCTAssertEqual(source.state.withLock { $0.discoveryConfigurationCount }, 0) + XCTAssertEqual(s2k_set_automatic_discovery(handle, 1), 0) + XCTAssertEqual(s2k_set_automatic_discovery(handle, 1), 0) + XCTAssertTrue(source.state.withLock { $0.automaticDiscovery }) + XCTAssertEqual(source.state.withLock { $0.discoveryConfigurationCount }, 1) + XCTAssertFalse(source.state.withLock { $0.running }) + XCTAssertFalse(context.read(maximum: 0).snapshot.isRunning) + XCTAssertEqual(s2k_set_automatic_discovery(handle, 0), 0) + XCTAssertFalse(source.state.withLock { $0.automaticDiscovery }) + XCTAssertEqual(source.state.withLock { $0.discoveryConfigurationCount }, 2) + } + + func testPolicyChangesPreserveNativeFixtureSessionAndQueuedInput() throws { + let (_, context, handle) = try make() + defer { destroyContext(handle) } + XCTAssertEqual(startContext(handle), 0) + var report = S2KState() + report.sequence = 1 + report.buttons = UInt32(S2K_BUTTON_A) + fixtureReport(handle, 0, UInt32(S2K_GAMECUBE), &report) + let before = try XCTUnwrap(context.read(maximum: 16).snapshot.controllers.first) + report.sequence = 2 + report.buttons = 0 + fixtureReport(handle, 0, UInt32(S2K_GAMECUBE), &report) + XCTAssertEqual(s2k_set_automatic_discovery(handle, 1), 0) + XCTAssertEqual(s2k_set_automatic_discovery(handle, 0), 0) + let batch = context.read(maximum: 16) + XCTAssertFalse(batch.resync) + XCTAssertEqual(batch.events.count, 1) + let after = try XCTUnwrap(batch.snapshot.controllers.first) + XCTAssertEqual(after.connectionID, before.connectionID) + XCTAssertEqual(after.state.sequence, 2) + XCTAssertTrue(after.state.buttons.isEmpty) + } + + func testExplicitStopStaysStoppedAndPolicySurvivesRestart() throws { + let (source, context, handle) = try make() + defer { destroyContext(handle) } + XCTAssertEqual(s2k_set_automatic_discovery(handle, 1), 0) + XCTAssertEqual(startContext(handle), 0) + XCTAssertEqual(stopContext(handle), 0) + XCTAssertEqual(s2k_set_automatic_discovery(handle, 0), 5) + XCTAssertEqual(startContext(handle), 5) + XCTAssertFalse(source.state.withLock { $0.running }) + XCTAssertTrue(source.state.withLock { $0.automaticDiscovery }) + try finishStop(source) + XCTAssertEqual(s2k_set_automatic_discovery(handle, 1), 0) + XCTAssertFalse(context.read(maximum: 0).snapshot.isRunning) + XCTAssertEqual(source.state.withLock { $0.discoveryConfigurationCount }, 1) + XCTAssertEqual(startContext(handle), 0) + XCTAssertTrue(source.state.withLock { $0.automaticDiscovery }) + XCTAssertEqual(stopContext(handle), 0) + try finishStop(source) + XCTAssertEqual(s2k_set_automatic_discovery(handle, 0), 0) + XCTAssertFalse(source.state.withLock { $0.running }) + XCTAssertFalse(source.state.withLock { $0.automaticDiscovery }) + } +}