feat(gamepad): controller discovery + client-negotiated pad type + rich DualSense end to end
The Apple client grows full gamepad support and punktfunk/1 learns to negotiate the virtual pad type: - Protocol: Hello carries a GamepadPref byte (offset 21, the same trailing-byte back-compat pattern as the compositor; echoed resolved in Welcome at 54). Host precedence: explicit client choice > PUNKTFUNK_GAMEPAD env > Xbox 360, DualSense (UHID) only where available. ABI: punktfunk_connect_ex2 + punktfunk_connection_gamepad (connect_ex delegates; ABI_VERSION stays 2 — the trailing byte IS the compat mechanism). punktfunk-client-rs gets --gamepad. - Swift client: GamepadManager (app-lifetime discovery + selection — Settings lists every controller with capabilities/battery/"In use"; exactly ONE pad forwards as pad 0, auto = most recently connected, or pinned), GamepadCapture (snapshot-diff button/axis events, DualSense touchpad + ~250 Hz motion on the rich-input plane, held state released on switch/deactivate/stop), GamepadFeedback (rumble → CoreHaptics per-handle engines; lightbar → GCDeviceLight; player LEDs → playerIndex; adaptive-trigger blocks → the table-driven DualSenseTriggerEffect parser → GCDualSenseAdaptiveTrigger, exact for the 10-zone positional modes). The pad type auto-resolves from the physical controller at connect time, user-overridable in Settings. - Host DualSense fixes surfaced by adversarial review against hid-playstation / SDL / Nielk1 ground truth: input-report sensor/touch offsets were off by one (the kernel read garbage motion + phantom touches), the L2/R2 trigger blocks were swapped (the report is right-trigger-first), feedback now gates on the report's valid-flags (a plain rumble write no longer blanks lightbar/ triggers), and the touchpad rescale clamps to the advertised ABS_MT extents. - Tests: Hello/Welcome trailing-byte back-compat, pick_gamepad precedence, byte-exact input-report layout, valid-flag gating, per-mode trigger-parser table (incl. packed 3-bit zones), wire conversions, and a scripted loopback feedback burst (PUNKTFUNK_TEST_FEEDBACK=1) asserted through the xcframework on the rumble + HID-output planes. Validated: cargo test/clippy/fmt green on macOS + Linux (61 host tests), swift build/test green, test-loopback.sh green, tvOS/iOS targets compile. DualSense motion sign/scale is derived from the calibration blob, not yet live-verified (constants isolated in GamepadWire). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
// Table-driven coverage of the DualSense trigger-effect parser: every supported mode
|
||||
// byte, the packed 10-zone decoding, and the it-must-never-trap guarantee for garbage.
|
||||
// Pure data → data, no controller needed.
|
||||
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class DualSenseTriggerEffectTests: XCTestCase {
|
||||
/// Build an 11-byte block: mode + up to 10 params (zero-padded).
|
||||
private func block(_ mode: UInt8, _ params: [UInt8] = []) -> [UInt8] {
|
||||
var b = [mode] + params
|
||||
while b.count < 11 { b.append(0) }
|
||||
return b
|
||||
}
|
||||
|
||||
/// Pack a 10-zone effect: active-zone bitmask (p0/p1) + 3-bit values (p2...p5).
|
||||
private func zones(_ mode: UInt8, values: [UInt8], extra: [UInt8] = []) -> [UInt8] {
|
||||
precondition(values.count == 10)
|
||||
var mask: UInt16 = 0
|
||||
var packed: UInt32 = 0
|
||||
for (i, v) in values.enumerated() where v > 0 {
|
||||
mask |= 1 << UInt16(i)
|
||||
packed |= UInt32(v & 0x07) << (3 * UInt32(i))
|
||||
}
|
||||
var p: [UInt8] = [
|
||||
UInt8(mask & 0xFF), UInt8(mask >> 8),
|
||||
UInt8(packed & 0xFF), UInt8((packed >> 8) & 0xFF),
|
||||
UInt8((packed >> 16) & 0xFF), UInt8((packed >> 24) & 0xFF),
|
||||
]
|
||||
p += extra
|
||||
return block(mode, p)
|
||||
}
|
||||
|
||||
func testOffModes() {
|
||||
XCTAssertEqual(DualSenseTriggerEffect.parse(block(0x00)), .off)
|
||||
XCTAssertEqual(DualSenseTriggerEffect.parse(block(0x05, [9, 9, 9])), .off)
|
||||
XCTAssertEqual(DualSenseTriggerEffect.parse(block(0xFC)), .off)
|
||||
}
|
||||
|
||||
func testUnknownAndGarbageNeverTrap() {
|
||||
XCTAssertEqual(DualSenseTriggerEffect.parse([]), .off)
|
||||
XCTAssertEqual(DualSenseTriggerEffect.parse([0x99]), .off)
|
||||
XCTAssertEqual(DualSenseTriggerEffect.parse([0x42, 0xFF]), .off)
|
||||
// Short blocks of known modes parse with zero-padded params.
|
||||
XCTAssertEqual(
|
||||
DualSenseTriggerEffect.parse([0x01, 128]),
|
||||
.feedback(start: 128.0 / 255, strength: 1.0 / 8))
|
||||
// Every possible mode byte with random-ish params returns *something*.
|
||||
for mode in UInt8.min...UInt8.max {
|
||||
_ = DualSenseTriggerEffect.parse(block(mode, [255, 255, 255, 255, 255, 255, 255, 255, 255, 255]))
|
||||
}
|
||||
}
|
||||
|
||||
func testLegacySimpleModes() {
|
||||
// 0x01: continuous resistance (start, force).
|
||||
XCTAssertEqual(
|
||||
DualSenseTriggerEffect.parse(block(0x01, [51, 255])),
|
||||
.feedback(start: 51.0 / 255, strength: 1))
|
||||
// Zero force still resists faintly (an active effect is never strength 0).
|
||||
XCTAssertEqual(
|
||||
DualSenseTriggerEffect.parse(block(0x01, [0, 0])),
|
||||
.feedback(start: 0, strength: 1.0 / 8))
|
||||
// 0x02: section between start and end.
|
||||
guard case let .weapon(start, end, strength) =
|
||||
DualSenseTriggerEffect.parse(block(0x02, [51, 204])) else {
|
||||
return XCTFail("0x02 should parse as weapon")
|
||||
}
|
||||
XCTAssertEqual(start, 51.0 / 255, accuracy: 0.001)
|
||||
XCTAssertEqual(end, 204.0 / 255, accuracy: 0.001)
|
||||
XCTAssertEqual(strength, 1)
|
||||
// 0x06: vibration — Nielk1's Simple_Vibration order is (frequency, amplitude, position).
|
||||
XCTAssertEqual(
|
||||
DualSenseTriggerEffect.parse(block(0x06, [30, 128, 51])),
|
||||
.vibration(start: 51.0 / 255, amplitude: 128.0 / 255, frequency: 30.0 / 255))
|
||||
}
|
||||
|
||||
func testFeedback0x21UniformSuffixSimplifies() {
|
||||
// Zones 3...9 all at strength 4 → one simple feedback call from zone 3.
|
||||
let b = zones(0x21, values: [0, 0, 0, 4, 4, 4, 4, 4, 4, 4])
|
||||
XCTAssertEqual(
|
||||
DualSenseTriggerEffect.parse(b),
|
||||
.feedback(start: 3.0 / 9, strength: 5.0 / 8))
|
||||
}
|
||||
|
||||
func testFeedback0x21MixedGoesPositional() {
|
||||
let b = zones(0x21, values: [0, 0, 2, 0, 0, 7, 0, 0, 0, 1])
|
||||
guard case let .positionalFeedback(strengths) = DualSenseTriggerEffect.parse(b) else {
|
||||
return XCTFail("mixed zones should parse positional")
|
||||
}
|
||||
XCTAssertEqual(strengths.count, 10)
|
||||
XCTAssertEqual(strengths[2], 3.0 / 8) // 3-bit value 2 → (2+1)/8
|
||||
XCTAssertEqual(strengths[5], 8.0 / 8)
|
||||
XCTAssertEqual(strengths[9], 2.0 / 8)
|
||||
XCTAssertEqual(strengths[0], 0) // inactive zone
|
||||
XCTAssertEqual(strengths[3], 0)
|
||||
}
|
||||
|
||||
func testFeedback0x21EmptyMaskIsOff() {
|
||||
XCTAssertEqual(
|
||||
DualSenseTriggerEffect.parse(zones(0x21, values: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0])),
|
||||
.off)
|
||||
}
|
||||
|
||||
func testWeapon0x25() {
|
||||
// Nielk1 Weapon = mode 0x25: start zone 2, end zone 7 from the mask; p2 = strength.
|
||||
var b = zones(0x25, values: [0, 0, 1, 0, 0, 0, 0, 1, 0, 0])
|
||||
b[3] = 6 // p2 (block[3] = params[2]): strength, stored minus one
|
||||
guard case let .weapon(start, end, strength) = DualSenseTriggerEffect.parse(b) else {
|
||||
return XCTFail("0x25 should parse as weapon")
|
||||
}
|
||||
XCTAssertEqual(start, 2.0 / 9, accuracy: 0.001)
|
||||
XCTAssertEqual(end, 7.0 / 9, accuracy: 0.001)
|
||||
XCTAssertEqual(strength, 7.0 / 8)
|
||||
// A single active zone can't form a section.
|
||||
XCTAssertEqual(
|
||||
DualSenseTriggerEffect.parse(zones(0x25, values: [0, 0, 1, 0, 0, 0, 0, 0, 0, 0])),
|
||||
.off)
|
||||
}
|
||||
|
||||
func testBow0x22BecomesSlope() {
|
||||
// Nielk1 Bow = mode 0x22, draw + snap packed as a 3-bit pair in p2 (p3 always 0).
|
||||
var b = zones(0x22, values: [0, 1, 0, 0, 0, 0, 0, 0, 1, 0])
|
||||
b[3] = (7 & 0x07) | ((3 & 0x07) << 3) // p2: draw (low) | snap (bits 3-5)
|
||||
guard case let .slope(start, end, from, to) = DualSenseTriggerEffect.parse(b) else {
|
||||
return XCTFail("0x22 should parse as slope")
|
||||
}
|
||||
XCTAssertEqual(start, 1.0 / 9, accuracy: 0.001)
|
||||
XCTAssertEqual(end, 8.0 / 9, accuracy: 0.001)
|
||||
XCTAssertEqual(from, 8.0 / 8)
|
||||
XCTAssertEqual(to, 4.0 / 8)
|
||||
}
|
||||
|
||||
func testVibration0x26() {
|
||||
var b = zones(0x26, values: [0, 0, 0, 0, 0, 5, 5, 5, 5, 5])
|
||||
b[9] = 40 // p8 (block[9]): frequency Hz
|
||||
guard case let .positionalVibration(amps, freq) = DualSenseTriggerEffect.parse(b) else {
|
||||
return XCTFail("0x26 should parse as positional vibration")
|
||||
}
|
||||
XCTAssertEqual(amps[4], 0)
|
||||
XCTAssertEqual(amps[5], 6.0 / 8)
|
||||
XCTAssertEqual(amps[9], 6.0 / 8)
|
||||
XCTAssertEqual(freq, 40.0 / 255, accuracy: 0.001)
|
||||
XCTAssertEqual(
|
||||
DualSenseTriggerEffect.parse(zones(0x26, values: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0])),
|
||||
.off)
|
||||
}
|
||||
|
||||
func testGalloping0x23() {
|
||||
// Nielk1 Galloping = mode 0x23: zone range + p3 frequency (p2 is foot timing).
|
||||
var b = zones(0x23, values: [0, 0, 1, 0, 0, 0, 1, 0, 0, 0])
|
||||
b[4] = 20 // p3 (block[4]): frequency
|
||||
guard case let .positionalVibration(amps, freq) = DualSenseTriggerEffect.parse(b) else {
|
||||
return XCTFail("0x23 should parse as positional vibration")
|
||||
}
|
||||
XCTAssertEqual(amps[1], 0)
|
||||
XCTAssertEqual(amps[2], 0.5)
|
||||
XCTAssertEqual(amps[6], 0.5)
|
||||
XCTAssertEqual(amps[7], 0)
|
||||
XCTAssertEqual(freq, 20.0 / 255, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testMachine0x27() {
|
||||
// Nielk1 Machine = mode 0x27: zone range, p2 = two raw 3-bit amplitudes, p3 = frequency.
|
||||
var b = zones(0x27, values: [0, 0, 0, 1, 0, 0, 0, 0, 1, 0])
|
||||
b[3] = (2 & 0x07) | ((6 & 0x07) << 3) // p2: amplitude A = 2, B = 6
|
||||
b[4] = 90 // p3: frequency
|
||||
guard case let .positionalVibration(amps, freq) = DualSenseTriggerEffect.parse(b) else {
|
||||
return XCTFail("0x27 should parse as positional vibration")
|
||||
}
|
||||
XCTAssertEqual(amps[2], 0)
|
||||
XCTAssertEqual(amps[3], 6.0 / 7, accuracy: 0.001) // the stronger leg
|
||||
XCTAssertEqual(amps[8], 6.0 / 7, accuracy: 0.001)
|
||||
XCTAssertEqual(amps[9], 0)
|
||||
XCTAssertEqual(freq, 90.0 / 255, accuracy: 0.001)
|
||||
// Zero amplitudes render as off, whatever the mask says.
|
||||
var z = zones(0x27, values: [0, 0, 0, 1, 0, 0, 0, 0, 1, 0])
|
||||
z[3] = 0
|
||||
XCTAssertEqual(DualSenseTriggerEffect.parse(z), .off)
|
||||
}
|
||||
|
||||
/// The host's PUNKTFUNK_TEST_FEEDBACK burst sends [0x21, 1, 2, 3, ...] — make sure the
|
||||
/// loopback test's expected shape stays a real parse result.
|
||||
func testScriptedLoopbackBlockParses() {
|
||||
let scripted: [UInt8] = [0x21, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
if case .off = DualSenseTriggerEffect.parse(scripted) {
|
||||
XCTFail("the scripted test block should parse as a feedback effect")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// The gamepad wire contract: button bit positions (must match
|
||||
// punktfunk_core::input::gamepad), GC→DualSense touchpad/motion conversions, and the
|
||||
// player-LED-bits → GCControllerPlayerIndex map. All pure functions.
|
||||
|
||||
import GameController
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class GamepadWireTests: XCTestCase {
|
||||
func testButtonBitsMatchTheRustWireContract() {
|
||||
// punktfunk_core::input::gamepad constants, spot-checked bit for bit.
|
||||
XCTAssertEqual(GamepadWire.dpadUp, 0x0001)
|
||||
XCTAssertEqual(GamepadWire.dpadDown, 0x0002)
|
||||
XCTAssertEqual(GamepadWire.dpadLeft, 0x0004)
|
||||
XCTAssertEqual(GamepadWire.dpadRight, 0x0008)
|
||||
XCTAssertEqual(GamepadWire.start, 0x0010)
|
||||
XCTAssertEqual(GamepadWire.back, 0x0020)
|
||||
XCTAssertEqual(GamepadWire.leftStickClick, 0x0040)
|
||||
XCTAssertEqual(GamepadWire.rightStickClick, 0x0080)
|
||||
XCTAssertEqual(GamepadWire.leftShoulder, 0x0100)
|
||||
XCTAssertEqual(GamepadWire.rightShoulder, 0x0200)
|
||||
XCTAssertEqual(GamepadWire.guide, 0x0400)
|
||||
XCTAssertEqual(GamepadWire.a, 0x1000)
|
||||
XCTAssertEqual(GamepadWire.b, 0x2000)
|
||||
XCTAssertEqual(GamepadWire.x, 0x4000)
|
||||
XCTAssertEqual(GamepadWire.y, 0x8000)
|
||||
XCTAssertEqual(GamepadWire.touchpadClick, 0x10_0000)
|
||||
// Every button is enumerated exactly once (releaseAll walks this list).
|
||||
let combined: UInt32 = GamepadWire.allButtons.reduce(0) { $0 | $1 }
|
||||
XCTAssertEqual(combined, 0x0010_F7FF)
|
||||
XCTAssertEqual(GamepadWire.allButtons.count, 16)
|
||||
XCTAssertEqual(GamepadWire.allButtons.count, Set(GamepadWire.allButtons).count)
|
||||
// Axis ids.
|
||||
XCTAssertEqual(GamepadWire.axisLSX, 0)
|
||||
XCTAssertEqual(GamepadWire.axisLSY, 1)
|
||||
XCTAssertEqual(GamepadWire.axisRSX, 2)
|
||||
XCTAssertEqual(GamepadWire.axisRSY, 3)
|
||||
XCTAssertEqual(GamepadWire.axisLT, 4)
|
||||
XCTAssertEqual(GamepadWire.axisRT, 5)
|
||||
}
|
||||
|
||||
func testTouchpadConversionCorners() {
|
||||
// GC ±1 with +y up → wire 0...65535 with origin top-left, +y down.
|
||||
let topLeft = GamepadWire.touchpad(x: -1, y: 1)
|
||||
XCTAssertEqual(topLeft.x, 0)
|
||||
XCTAssertEqual(topLeft.y, 0)
|
||||
let bottomRight = GamepadWire.touchpad(x: 1, y: -1)
|
||||
XCTAssertEqual(bottomRight.x, 65535)
|
||||
XCTAssertEqual(bottomRight.y, 65535)
|
||||
let center = GamepadWire.touchpad(x: 0, y: 0)
|
||||
XCTAssertEqual(Int(center.x), 32768, accuracy: 1)
|
||||
XCTAssertEqual(Int(center.y), 32768, accuracy: 1)
|
||||
// Out-of-range input clamps instead of wrapping.
|
||||
let wild = GamepadWire.touchpad(x: 5, y: -7)
|
||||
XCTAssertEqual(wild.x, 65535)
|
||||
XCTAssertEqual(wild.y, 65535)
|
||||
}
|
||||
|
||||
func testMotionScalingAndClamping() {
|
||||
// 20 raw LSB per deg/s — one full revolution per second (360 deg/s = 2π rad/s).
|
||||
XCTAssertEqual(GamepadWire.motionRaw(2 * .pi, scale: GamepadWire.gyroLSBPerRadS), 7200)
|
||||
// 1 g → 10000 raw.
|
||||
XCTAssertEqual(GamepadWire.motionRaw(1, scale: GamepadWire.accelLSBPerG), 10000)
|
||||
XCTAssertEqual(GamepadWire.motionRaw(-1, scale: GamepadWire.accelLSBPerG), -10000)
|
||||
// Saturation, not overflow.
|
||||
XCTAssertEqual(GamepadWire.motionRaw(100, scale: GamepadWire.accelLSBPerG), Int16.max)
|
||||
XCTAssertEqual(GamepadWire.motionRaw(-100, scale: GamepadWire.accelLSBPerG), Int16.min)
|
||||
XCTAssertEqual(GamepadWire.motionRaw(0, scale: GamepadWire.gyroLSBPerRadS), 0)
|
||||
}
|
||||
|
||||
func testPlayerIndexMap() {
|
||||
// hid-playstation's DualSense player-LED patterns.
|
||||
XCTAssertEqual(GamepadFeedback.playerIndex(forBits: 0), .indexUnset)
|
||||
XCTAssertEqual(GamepadFeedback.playerIndex(forBits: 0b00100), .index1)
|
||||
XCTAssertEqual(GamepadFeedback.playerIndex(forBits: 0b01010), .index2)
|
||||
XCTAssertEqual(GamepadFeedback.playerIndex(forBits: 0b10101), .index3)
|
||||
XCTAssertEqual(GamepadFeedback.playerIndex(forBits: 0b11011), .index4)
|
||||
// Unknown patterns: lit-count fallback, clamped to GC's four indices.
|
||||
XCTAssertEqual(GamepadFeedback.playerIndex(forBits: 0b00001), .index1)
|
||||
XCTAssertEqual(GamepadFeedback.playerIndex(forBits: 0b00011), .index2)
|
||||
XCTAssertEqual(GamepadFeedback.playerIndex(forBits: 0b11111), .index4)
|
||||
// Bits above the 5 LEDs are ignored.
|
||||
XCTAssertEqual(GamepadFeedback.playerIndex(forBits: 0b1110_0000), .indexUnset)
|
||||
}
|
||||
}
|
||||
@@ -43,17 +43,51 @@ final class LoopbackIntegrationTests: XCTestCase {
|
||||
XCTAssertGreaterThanOrEqual(lastIndex, 24)
|
||||
|
||||
// Input goes the other way (enqueue-only; the host logs the count on close) —
|
||||
// including the touch kinds and the mic uplink plane (the synthetic host counts
|
||||
// the datagrams; injection/decoding are Linux-side concerns).
|
||||
// including the touch kinds, gamepad events, the rich-input plane (DualSense
|
||||
// touchpad/motion), and the mic uplink plane (the synthetic host counts the
|
||||
// datagrams; injection/decoding are Linux-side concerns).
|
||||
conn.send(.mouseMove(dx: 1, dy: 2))
|
||||
conn.send(.key(0x41, down: true))
|
||||
conn.send(.key(0x41, down: false))
|
||||
conn.send(.touchDown(id: 0, x: 100, y: 200, surfaceWidth: 1280, surfaceHeight: 720))
|
||||
conn.send(.touchMove(id: 0, x: 110, y: 210, surfaceWidth: 1280, surfaceHeight: 720))
|
||||
conn.send(.touchUp(id: 0))
|
||||
conn.send(.gamepadButton(GamepadWire.a, down: true, pad: 0))
|
||||
conn.send(.gamepadButton(GamepadWire.a, down: false, pad: 0))
|
||||
conn.send(.gamepadAxis(GamepadWire.axisLSX, value: 12345, pad: 0))
|
||||
conn.send(.gamepadAxis(GamepadWire.axisRT, value: 200, pad: 0))
|
||||
conn.sendTouchpad(finger: 0, active: true, x: 32768, y: 16384)
|
||||
conn.sendTouchpad(finger: 0, active: false, x: 0, y: 0)
|
||||
conn.sendMotion(gyro: (100, -100, 0), accel: (0, 0, 10000))
|
||||
conn.sendMic(Data([0xFC, 0xFF, 0xFE]), seq: 0, ptsNs: 1) // tiny opus-ish frame
|
||||
conn.sendMic(Data(), seq: 1, ptsNs: 2) // DTX silence frame
|
||||
|
||||
// The synthetic host (PUNKTFUNK_TEST_FEEDBACK=1, set by test-loopback.sh) scripts
|
||||
// one feedback burst on the host→client planes — drain both and verify, end to
|
||||
// end through the xcframework: rumble (0xCA) + the three hidout kinds (0xCD).
|
||||
if ProcessInfo.processInfo.environment["PUNKTFUNK_TEST_FEEDBACK"] == "1" {
|
||||
var rumble: (pad: UInt16, low: UInt16, high: UInt16)?
|
||||
var hidout: [PunktfunkConnection.HidOutputEvent] = []
|
||||
let feedbackDeadline = Date().addingTimeInterval(10)
|
||||
while (rumble == nil || hidout.count < 3), Date() < feedbackDeadline {
|
||||
if rumble == nil, let r = try conn.nextRumble(timeoutMs: 100) { rumble = r }
|
||||
if let ev = try conn.nextHidOutput(timeoutMs: 100) { hidout.append(ev) }
|
||||
}
|
||||
XCTAssertEqual(rumble?.pad, 0)
|
||||
XCTAssertEqual(rumble?.low, 0x4000)
|
||||
XCTAssertEqual(rumble?.high, 0x8000)
|
||||
XCTAssertTrue(
|
||||
hidout.contains(.led(pad: 0, r: 10, g: 20, b: 30)),
|
||||
"missing the scripted lightbar event: \(hidout)")
|
||||
XCTAssertTrue(
|
||||
hidout.contains(.playerLEDs(pad: 0, bits: 0b00100)),
|
||||
"missing the scripted player-LED event: \(hidout)")
|
||||
XCTAssertTrue(
|
||||
hidout.contains(.triggerEffect(
|
||||
pad: 0, which: 1, effect: [0x21, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])),
|
||||
"missing the scripted trigger event: \(hidout)")
|
||||
}
|
||||
|
||||
conn.close()
|
||||
XCTAssertThrowsError(try conn.nextAU(timeoutMs: 10)) { error in
|
||||
guard case PunktfunkClientError.closed = error else {
|
||||
|
||||
Reference in New Issue
Block a user