@mentra/bluetooth-sdk 3.2.1-dev.314 → 3.3.0-beta.942
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -10
- package/android/build.gradle +15 -0
- package/android/lc3Lib/build.gradle +6 -1
- package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +368 -0
- package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocol.kt +156 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +63 -14
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTracker.kt +55 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +4 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkInstallId.kt +23 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +8 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheck.kt +184 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTracker.kt +388 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +2 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +133 -26
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +3 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +6 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +227 -36
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +24 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/NetworkDiagnostics.kt +25 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +13 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +3 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/requests/DisplayRequests.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +33 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/services/G2ConnectionRecovery.kt +47 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +4 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscovery.kt +70 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +11 -32
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +72 -21
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/LatinTextSanitizer.kt +39 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +194 -40
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +12 -8
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +314 -395
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +247 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +238 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +180 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddress.kt +17 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +5 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
- package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTrackerTest.kt +58 -3
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheckTest.kt +124 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTrackerTest.kt +263 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/DashboardContentFormatterTest.kt +2 -2
- package/android/src/test/java/com/mentra/bluetoothsdk/DeviceIdentityPairingTest.kt +59 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +666 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/NetworkDiagnosticsTest.kt +55 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/ScanCompletionTest.kt +5 -1
- package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +71 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/debug/BleTraceLoggerTest.kt +69 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscoveryTest.kt +124 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1PairingTest.kt +105 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +29 -4
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2DisplayThreadTest.kt +120 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2PairingTargetTest.kt +90 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveClassicDisconnectTest.kt +133 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveFileTransferSessionTest.kt +55 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +33 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveIncidentDispatchTest.kt +126 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingTargetTest.kt +65 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveWifiCredentialLogTest.kt +55 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraNexPairingTest.kt +151 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +233 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +348 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +339 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoMicReconnectTest.kt +93 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +11 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddressTest.kt +27 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +288 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +205 -0
- package/build/BluetoothSdk.types.d.ts +16 -0
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js +1 -0
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +4 -0
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +5 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/MentraLocalNetworkModule.d.ts +23 -0
- package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
- package/build/_private/MentraLocalNetworkModule.js.map +1 -1
- package/build/_private/nativeLogConsole.d.ts +19 -0
- package/build/_private/nativeLogConsole.d.ts.map +1 -1
- package/build/_private/nativeLogConsole.js +12 -0
- package/build/_private/nativeLogConsole.js.map +1 -1
- package/build/generated/changelogCatalog.d.ts +5 -2
- package/build/generated/changelogCatalog.d.ts.map +1 -1
- package/build/generated/changelogCatalog.js +6 -2
- package/build/generated/changelogCatalog.js.map +1 -1
- package/build/generated/releaseMetadata.js +6 -6
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/build/ota-transport/index.d.ts +4 -0
- package/build/ota-transport/index.d.ts.map +1 -1
- package/build/ota-transport/index.js +2 -0
- package/build/ota-transport/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +17 -4
- package/ios/MentraBluetoothSDK.podspec +3 -2
- package/ios/Source/BluetoothBackgroundMode.swift +32 -0
- package/ios/Source/BluetoothSdkDefaults.swift +7 -2
- package/ios/Source/Bridge.swift +15 -2
- package/ios/Source/DeviceManager.swift +102 -66
- package/ios/Source/DeviceStore.swift +26 -7
- package/ios/Source/GeneratedChangelogCatalog.swift +3 -2
- package/ios/Source/GeneratedReleaseMetadata.swift +6 -6
- package/ios/Source/MentraBluetoothSDK.swift +313 -54
- package/ios/Source/audio/GlassesMicWatchdog.swift +33 -0
- package/ios/Source/internal/BluetoothAvailability.swift +6 -0
- package/ios/Source/internal/BluetoothSdkAnalytics.swift +87 -13
- package/ios/Source/internal/BluetoothSdkAnalyticsTracker.swift +54 -1
- package/ios/Source/internal/BluetoothSdkInstallId.swift +21 -0
- package/ios/Source/internal/BluetoothSdkUpdateCheck.swift +223 -0
- package/ios/Source/internal/BluetoothSdkUsageTracker.swift +428 -0
- package/ios/Source/requests/DisplayRequests.swift +1 -1
- package/ios/Source/requests/VersionInfoResponseAccumulator.swift +11 -1
- package/ios/Source/sgcs/Ar99.swift +1 -2
- package/ios/Source/sgcs/G1.swift +68 -38
- package/ios/Source/sgcs/G1ConnectionTarget.swift +13 -0
- package/ios/Source/sgcs/G2.swift +261 -247
- package/ios/Source/sgcs/G2ConnectionSession.swift +101 -0
- package/ios/Source/sgcs/Mach1.swift +19 -7
- package/ios/Source/sgcs/MentraLive.swift +272 -197
- package/ios/Source/sgcs/MentraLiveFileTransferSession.swift +137 -0
- package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +47 -0
- package/ios/Source/sgcs/MentraNex.swift +8 -31
- package/ios/Source/sgcs/NexConnectionTarget.swift +15 -0
- package/ios/Source/sgcs/Nimo.swift +280 -408
- package/ios/Source/sgcs/NimoCanvasCodec.swift +272 -0
- package/ios/Source/sgcs/NimoCanvasCoordinator.swift +233 -0
- package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
- package/ios/Source/sgcs/NimoCanvasSession.swift +166 -0
- package/ios/Source/sgcs/NimoDiscovery.swift +49 -0
- package/ios/Source/sgcs/SGCManager.swift +25 -1
- package/ios/Source/sgcs/VuzixConnectionTarget.swift +15 -0
- package/ios/Source/status/DeviceStatus.swift +1 -0
- package/ios/Source/stt/SherpaOnnxTranscriber.swift +49 -189
- package/ios/Source/utils/AudioSessionMonitor.swift +44 -8
- package/ios/Source/utils/DemandDrivenAudioWorker.swift +150 -0
- package/ios/Source/utils/G1Text.swift +2 -24
- package/ios/Source/utils/G1TextSanitizer.swift +27 -0
- package/ios/Source/utils/G2Text.swift +11 -0
- package/ios/Source/utils/LatinTextSanitizer.swift +48 -0
- package/ios/Tests/BluetoothAudioRouteTests.swift +26 -0
- package/ios/Tests/BluetoothBackgroundModeTests.swift +32 -0
- package/ios/Tests/BluetoothSdkAnalyticsTrackerTests.swift +54 -3
- package/ios/Tests/BluetoothSdkUpdateCheckTests.swift +153 -0
- package/ios/Tests/BluetoothSdkUsageTrackerTests.swift +296 -0
- package/ios/Tests/DashboardContentFormatterTests.swift +2 -2
- package/ios/Tests/DashboardRenderingTests.swift +31 -0
- package/ios/Tests/DemandDrivenAudioWorkerTests.swift +157 -0
- package/ios/Tests/DeviceIdentityPairingTests.swift +49 -0
- package/ios/Tests/DeviceManagerReconnectTests.swift +362 -0
- package/ios/Tests/G1PairingTests.swift +162 -0
- package/ios/Tests/G1TextSanitizerTests.swift +32 -0
- package/ios/Tests/G2ConnectionSessionTests.swift +172 -0
- package/ios/Tests/G2TextTests.swift +10 -0
- package/ios/Tests/IncidentLogDispatchTests.swift +111 -0
- package/ios/Tests/MentraLiveFileTransferSessionTests.swift +47 -0
- package/ios/Tests/MentraLiveReconnectPolicyTests.swift +58 -0
- package/ios/Tests/MentraLiveSessionReadinessTests.swift +163 -0
- package/ios/Tests/NimoCanvasCodecTests.swift +217 -0
- package/ios/Tests/NimoCanvasSessionTests.swift +181 -0
- package/ios/Tests/NimoCanvasTransportTests.swift +186 -0
- package/ios/Tests/NimoDeviceCommandsTests.swift +16 -0
- package/ios/Tests/NimoDiscoveryTests.swift +79 -0
- package/ios/Tests/SGCConnectionTargetTests.swift +48 -0
- package/ios/Tests/VersionInfoCommandTests.swift +194 -0
- package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
- package/ios/Tests/VideoRecordingCommandTests.swift +81 -0
- package/ios/Tests/WifiCredentialLoggingTests.swift +42 -0
- package/package.json +2 -2
- package/plugin/build/withIos.d.ts +2 -0
- package/plugin/build/withIos.js +12 -1
- package/src/BluetoothSdk.types.ts +15 -0
- package/src/_private/BluetoothSdkModule.ts +10 -1
- package/src/_private/MentraLocalNetworkModule.ts +28 -0
- package/src/_private/nativeLogConsole.ts +19 -0
- package/src/generated/changelogCatalog.ts +6 -2
- package/src/generated/releaseMetadata.ts +6 -6
- package/src/index.ts +1 -0
- package/src/ota-transport/index.ts +10 -1
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import CoreGraphics
|
|
2
|
+
import ImageIO
|
|
3
|
+
@testable import MentraBluetoothSDK
|
|
4
|
+
import XCTest
|
|
5
|
+
import zlib
|
|
6
|
+
|
|
7
|
+
final class NimoCanvasCodecTests: XCTestCase {
|
|
8
|
+
func testLatinFallbackPreservesOtherScriptsAndTheirMarks() throws {
|
|
9
|
+
let otherScripts = "ありがとう が パ カ\u{3099} 한국어 한\u{302E} Α\u{0301} مُرَحَّبًا शि ❤️"
|
|
10
|
+
let label = try NimoCanvasCodec.label("\(otherScripts) café—Æ\u{0301}", 0, 0, 500, 220)
|
|
11
|
+
let expected = Data("\(otherScripts) cafe-AE".utf8)
|
|
12
|
+
XCTAssertEqual(Data(label.payload.dropFirst(14)), expected)
|
|
13
|
+
XCTAssertEqual(label.textBytes, expected.count)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
private func hex(_ data: Data) -> String {
|
|
17
|
+
data.map { String(format: "%02X", $0) }.joined()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
private func packet(_ hex: String) -> Data {
|
|
21
|
+
let chars = Array(hex)
|
|
22
|
+
return Data(stride(from: 0, to: chars.count, by: 2).map { UInt8(String(chars[$0 ... $0 + 1]), radix: 16)! })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
func testVendorGoldenPacketsMatchAndroid() throws {
|
|
26
|
+
let frame = try NimoCanvasCodec.replace([
|
|
27
|
+
NimoCanvasCodec.rectangle(20, 20, 120, 50, stroke: 2, radius: 8),
|
|
28
|
+
NimoCanvasCodec.label("Hello Glass", 160, 30, 300, 40, font: 2),
|
|
29
|
+
])
|
|
30
|
+
XCTAssertEqual(try hex(NimoCanvasCodec.frames(key: 4, content: frame, writeCapacity: 512)[0]),
|
|
31
|
+
"BF003600F72D000007043200FD000000020001010C0014001400780032000208FF00031900A0001E002C012800000002FF0B0048656C6C6F20476C617373")
|
|
32
|
+
XCTAssertEqual(try hex(NimoCanvasCodec.frames(key: 1, writeCapacity: 512)[0]), "BF000600F5E4000007010200FD00")
|
|
33
|
+
XCTAssertEqual(try hex(NimoCanvasCodec.frames(key: 3, writeCapacity: 512)[0]), "BF00050086E4000007030100FD")
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func testPolarityOddPaddingAndImageHeader() throws {
|
|
37
|
+
let gray = Data([0, 85, 170, 255, 255])
|
|
38
|
+
XCTAssertEqual(try NimoCanvasCodec.pack2(gray), Data([0xE4, 0x3F]))
|
|
39
|
+
let bitmap = try NimoCanvasCodec.bitmap(0, 0, 5, 1, gray: gray)
|
|
40
|
+
XCTAssertEqual(bitmap.payload[10], 1)
|
|
41
|
+
XCTAssertEqual(bitmap.payload[19], 2)
|
|
42
|
+
XCTAssertEqual(bitmap.payload[20], 3)
|
|
43
|
+
XCTAssertEqual(bitmap.payload[21], 2)
|
|
44
|
+
XCTAssertEqual(bitmap.payload.dropFirst(29), Data([0x82, 0xE4, 0x3F]))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func testRleRunAndLiteralBoundariesRoundTrip() throws {
|
|
48
|
+
for size in [1, 2, 3, 126, 127, 128, 254, 27500] {
|
|
49
|
+
for source in [Data(repeating: 33, count: size), Data((0 ..< size).map { UInt8($0 % 251) })] {
|
|
50
|
+
let encoded = try [UInt8](NimoCanvasCodec.rle(source))
|
|
51
|
+
var decoded = Data(), i = 0
|
|
52
|
+
while i < encoded.count {
|
|
53
|
+
let tag = encoded[i]; i += 1
|
|
54
|
+
let count = Int(tag & 127)
|
|
55
|
+
XCTAssertGreaterThan(count, 0)
|
|
56
|
+
if tag & 128 != 0 { decoded.append(contentsOf: encoded[i ..< i + count]); i += count }
|
|
57
|
+
else { decoded.append(Data(repeating: encoded[i], count: count)); i += 1 }
|
|
58
|
+
}
|
|
59
|
+
XCTAssertEqual(decoded, source)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
func testZlibImageInflatesToPackedPixels() throws {
|
|
65
|
+
let pixels: [UInt8] = (0 ..< 32768).map { index in
|
|
66
|
+
let tone = ((index / 17) ^ (index / 43)) % 4
|
|
67
|
+
return UInt8(tone * 85)
|
|
68
|
+
}
|
|
69
|
+
let gray = Data(pixels)
|
|
70
|
+
let bitmap = try NimoCanvasCodec.bitmap(0, 0, 256, 128, gray: gray)
|
|
71
|
+
XCTAssertEqual(bitmap.payload[20], 7)
|
|
72
|
+
let compressed = Data(bitmap.payload.dropFirst(29))
|
|
73
|
+
var count: uLong = 8192
|
|
74
|
+
var output = Data(count: Int(count))
|
|
75
|
+
let status = output.withUnsafeMutableBytes { dst in
|
|
76
|
+
compressed.withUnsafeBytes { src in
|
|
77
|
+
uncompress(dst.bindMemory(to: UInt8.self).baseAddress!, &count,
|
|
78
|
+
src.bindMemory(to: UInt8.self).baseAddress!, uLong(compressed.count))
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
XCTAssertEqual(status, Z_OK)
|
|
82
|
+
XCTAssertEqual(output, try NimoCanvasCodec.pack2(gray))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
func testFragmentationRespectsCoreBluetoothCapacityAndCrc() throws {
|
|
86
|
+
let content = try NimoCanvasCodec.replace([NimoCanvasCodec.label(String(repeating: "X", count: 2048), 0, 0, 500, 220)])
|
|
87
|
+
var baseline: Data?
|
|
88
|
+
for capacity in [20, 64, 185, 244, 512] {
|
|
89
|
+
let frames = try NimoCanvasCodec.frames(key: 4, content: content, writeCapacity: capacity)
|
|
90
|
+
var stream = Data()
|
|
91
|
+
for (index, frame) in frames.enumerated() {
|
|
92
|
+
XCTAssertLessThanOrEqual(frame.count, capacity)
|
|
93
|
+
XCTAssertEqual(frame[1], 0)
|
|
94
|
+
XCTAssertEqual(Int(frame[6]) | Int(frame[7]) << 8, index == frames.count - 1 ? 0 : index + 1)
|
|
95
|
+
XCTAssertEqual(UInt16(frame[4]) | UInt16(frame[5]) << 8, nimoCrc16(Data(frame.dropFirst(8))))
|
|
96
|
+
stream += frame.dropFirst(8)
|
|
97
|
+
}
|
|
98
|
+
if let baseline { XCTAssertEqual(stream, baseline) } else { baseline = stream }
|
|
99
|
+
}
|
|
100
|
+
XCTAssertThrowsError(try NimoCanvasCodec.frames(key: 4, content: content, writeCapacity: 19))
|
|
101
|
+
XCTAssertThrowsError(try NimoCanvasCodec.frames(key: 4, content: Data(count: 12281), writeCapacity: 512))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
func testStrictCanvasResponseParsing() {
|
|
105
|
+
let good = packet("BF000900D87900000704050000FD000000")
|
|
106
|
+
XCTAssertEqual(NimoCanvasCodec.response(good)?.payload, Data([0, 0xFD, 0, 0, 0]))
|
|
107
|
+
XCTAssertNil(NimoCanvasCodec.response(good + Data([0])))
|
|
108
|
+
for index in [0, 1, 2, 4, 6, 8, 10] {
|
|
109
|
+
var bad = good; bad[index] ^= 1
|
|
110
|
+
XCTAssertNil(NimoCanvasCodec.response(bad))
|
|
111
|
+
}
|
|
112
|
+
let body = Data([7, 4, 1, 0, 7])
|
|
113
|
+
let statusOnly = NimoFrameCodec.transportHeader(body, needsAck: false) + body
|
|
114
|
+
XCTAssertEqual(NimoCanvasCodec.response(statusOnly)?.payload, Data([7]))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
func testUtf8RowsAndGeometryValidation() throws {
|
|
118
|
+
let dashes = try NimoCanvasCodec.label("—éÆß— - –", 0, 0, 100, 20)
|
|
119
|
+
let expectedText = Data("-eAEss- - –".utf8)
|
|
120
|
+
XCTAssertEqual(Data(dashes.payload.dropFirst(14)), expectedText)
|
|
121
|
+
XCTAssertEqual(dashes.textBytes, expectedText.count)
|
|
122
|
+
XCTAssertEqual(Int(dashes.payload[12]), expectedText.count)
|
|
123
|
+
XCTAssertEqual(try NimoCanvasCodec.label("😀界", 0, 0, 100, 20).textBytes, 7)
|
|
124
|
+
XCTAssertThrowsError(try NimoCanvasCodec.label("a\0b", 0, 0, 100, 20))
|
|
125
|
+
XCTAssertThrowsError(try NimoCanvasCodec.label(String(repeating: "界", count: 683), 0, 0, 100, 20))
|
|
126
|
+
let rows = try NimoCanvasCodec.textRows("first\n\nthird", 12, 15, 400, 100)
|
|
127
|
+
XCTAssertEqual(rows.count, 2)
|
|
128
|
+
XCTAssertEqual(rows.map { $0.payload[2] }, [15, 55])
|
|
129
|
+
XCTAssertEqual(rows[1].payload[6], 20)
|
|
130
|
+
XCTAssertThrowsError(try NimoCanvasCodec.rectangle(Int.max, 0, 10, 1))
|
|
131
|
+
XCTAssertThrowsError(try NimoCanvasCodec.line(0, 0, 500, 219))
|
|
132
|
+
XCTAssertThrowsError(try NimoCanvasCodec.circle(0, 10, radius: 10))
|
|
133
|
+
XCTAssertEqual(try NimoCanvasCodec.circle(10, 10, radius: 10).payload.count, 9)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
func testFrameBudgetsBeforeRasterDecode() throws {
|
|
137
|
+
XCTAssertEqual(try NimoCanvasCodec.replace([]), Data([0, 0, 1]))
|
|
138
|
+
let line = try NimoCanvasCodec.line(0, 0, 1, 1)
|
|
139
|
+
XCTAssertThrowsError(try NimoCanvasCodec.replace(Array(repeating: line, count: 65)))
|
|
140
|
+
let text = try NimoCanvasCodec.label(String(repeating: "a", count: 2048), 0, 0, 500, 220)
|
|
141
|
+
XCTAssertThrowsError(try NimoCanvasCodec.replace(Array(repeating: text, count: 5)))
|
|
142
|
+
let image = SceneElement(id: "image", type: "image", x: 0, y: 0, w: 500, h: 220, text: nil,
|
|
143
|
+
data: "unused", border: 0, radius: 0, change: "unchanged", contentHash: "")
|
|
144
|
+
var decoded = 0
|
|
145
|
+
XCTAssertThrowsError(try NimoCanvasCodec.scene([image, image]) { _, _, _ in decoded += 1; return Data() })
|
|
146
|
+
XCTAssertEqual(decoded, 0)
|
|
147
|
+
XCTAssertThrowsError(try NimoCanvasCodec.replace([.init(type: 4, payload: Data(count: 12278))]))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
func testHostFrameReservationBoundsNativeEncoding() throws {
|
|
151
|
+
var seed: UInt32 = 42
|
|
152
|
+
func noise(_ count: Int) -> Data {
|
|
153
|
+
Data((0 ..< count).map { _ in
|
|
154
|
+
seed = seed &* 1_664_525 &+ 1_013_904_223
|
|
155
|
+
return UInt8(truncatingIfNeeded: seed >> 24)
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
for size in [1, 5, 80, 160, 200] {
|
|
159
|
+
let image = try NimoCanvasCodec.bitmap(0, 0, size, size, gray: noise(size * size))
|
|
160
|
+
let packed = (size * size + 3) / 4
|
|
161
|
+
let reserved = 32 + packed + (packed + 126) / 127
|
|
162
|
+
XCTAssertLessThanOrEqual(3 + image.payload.count, reserved)
|
|
163
|
+
}
|
|
164
|
+
// Retained prefixes of the host's over-budget regression scenes.
|
|
165
|
+
let rows = try NimoCanvasCodec.textRows(Array(repeating: "row", count: 11).joined(separator: "\n"), 0, 0, 500, 220)
|
|
166
|
+
XCTAssertLessThanOrEqual(try NimoCanvasCodec.replace(Array(repeating: rows, count: 5).flatMap { $0 }).count, 12280)
|
|
167
|
+
let images = try [200, 80].map { try NimoCanvasCodec.bitmap(0, 0, $0, $0, gray: noise($0 * $0)) }
|
|
168
|
+
XCTAssertLessThanOrEqual(try NimoCanvasCodec.replace(images).count, 12280)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
func testWholeScenePreservesOrderAndIgnoresDiffAnnotations() throws {
|
|
172
|
+
let text = SceneElement(id: "caption", type: "text", x: 10, y: 20, w: 100, h: 40, text: "héllo—Æsir",
|
|
173
|
+
data: nil, border: 2, radius: 3, change: "unchanged", contentHash: "")
|
|
174
|
+
let expected = try NimoCanvasCodec.replace([
|
|
175
|
+
NimoCanvasCodec.rectangle(10, 20, 100, 40, stroke: 2, radius: 3),
|
|
176
|
+
NimoCanvasCodec.label("hello-AEsir", 10, 20, 100, 20),
|
|
177
|
+
])
|
|
178
|
+
XCTAssertEqual(try NimoCanvasCodec.scene([text]) { _, _, _ in XCTFail("Not an image"); return Data() }, expected)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
func testImageKindAndDimensionsAreValidatedBeforeDecode() throws {
|
|
182
|
+
let png = Data([0x89, 0x50, 0x4E, 0x47, 13, 10, 26, 10]) + Data(count: 24)
|
|
183
|
+
XCTAssertEqual(try NimoCanvasCodec.imageBytes(png.base64EncodedString()), png)
|
|
184
|
+
XCTAssertThrowsError(try NimoCanvasCodec.imageBytes("data:image/bmp;base64," + png.base64EncodedString()))
|
|
185
|
+
XCTAssertThrowsError(try NimoCanvasCodec.imageBytes("data:image/jpeg;base64,AAAA"))
|
|
186
|
+
XCTAssertThrowsError(try NimoCanvasCodec.imageBytes("Qk0"))
|
|
187
|
+
XCTAssertThrowsError(try NimoCanvasImage.source(png.base64EncodedString()))
|
|
188
|
+
XCTAssertThrowsError(try NimoCanvasCodec.imageBytes(String(repeating: "a", count: 2_800_001)))
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
func testJpegPixelsAreDecodedForRawAndDataUriInputs() throws {
|
|
192
|
+
let rgba = Data(repeating: 255, count: 16)
|
|
193
|
+
let provider = CGDataProvider(data: rgba as CFData)!
|
|
194
|
+
let image = try XCTUnwrap(CGImage(width: 2, height: 2, bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: 8,
|
|
195
|
+
space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.noneSkipLast.rawValue),
|
|
196
|
+
provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent))
|
|
197
|
+
let bytes = NSMutableData()
|
|
198
|
+
let destination = try XCTUnwrap(CGImageDestinationCreateWithData(bytes, "public.jpeg" as CFString, 1, nil))
|
|
199
|
+
CGImageDestinationAddImage(destination, image, nil)
|
|
200
|
+
XCTAssertTrue(CGImageDestinationFinalize(destination))
|
|
201
|
+
let raw = (bytes as Data).base64EncodedString()
|
|
202
|
+
for input in [raw, "data:image/jpeg;base64," + raw] {
|
|
203
|
+
XCTAssertEqual(try NimoCanvasImage.decode(input, width: 2, height: 2), Data(repeating: 255, count: 4))
|
|
204
|
+
}
|
|
205
|
+
XCTAssertThrowsError(try NimoCanvasImage.source("data:image/png;base64," + raw))
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
func testRasterKeepsTopRowAndLuminanceWithoutInversion() throws {
|
|
209
|
+
// Top row red/green; bottom row blue/white. Match the Android integer luminance formula.
|
|
210
|
+
let rgba = Data([255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255])
|
|
211
|
+
let provider = CGDataProvider(data: rgba as CFData)!
|
|
212
|
+
let image = try XCTUnwrap(CGImage(width: 2, height: 2, bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: 8,
|
|
213
|
+
space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.last.rawValue),
|
|
214
|
+
provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent))
|
|
215
|
+
XCTAssertEqual(try NimoCanvasImage.grayscale(image, width: 2, height: 2), Data([77, 149, 29, 255]))
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
@testable import MentraBluetoothSDK
|
|
2
|
+
import XCTest
|
|
3
|
+
|
|
4
|
+
final class NimoCanvasSessionTests: XCTestCase {
|
|
5
|
+
private let a = Data([0, 0, 1])
|
|
6
|
+
private let b = Data([1, 0, 1])
|
|
7
|
+
private func ack(_ session: NimoCanvasSession, _ key: Int, status: UInt8 = 0) -> [NimoCanvasSession.Action] {
|
|
8
|
+
session.response(key: key, payload: status == 0 ? (key == 4 ? Data([0, 0xFD, 0, 0, 0]) : Data([0, 0xFD])) : Data([status]))
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
private func sentKey(_ actions: [NimoCanvasSession.Action]) -> Int? {
|
|
12
|
+
guard case let .send(key, _, _) = actions.first else { return nil }
|
|
13
|
+
return key
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
private func connected(_ session: NimoCanvasSession) {
|
|
17
|
+
XCTAssertTrue(session.offer(a, scope: "app:1").isEmpty)
|
|
18
|
+
XCTAssertEqual(sentKey(session.readiness(true)), 1)
|
|
19
|
+
XCTAssertEqual(sentKey(ack(session, 1)), 4)
|
|
20
|
+
XCTAssertTrue(ack(session, 4).isEmpty)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func testReadinessLaunchAndLatestSceneWins() {
|
|
24
|
+
let session = NimoCanvasSession()
|
|
25
|
+
XCTAssertTrue(session.offer(a, scope: "app:1").isEmpty)
|
|
26
|
+
XCTAssertEqual(sentKey(session.readiness(true)), 1)
|
|
27
|
+
XCTAssertTrue(session.offer(b, scope: "app:1").isEmpty)
|
|
28
|
+
guard case let .send(key, frame, _) = ack(session, 1).first else { return XCTFail("Missing update") }
|
|
29
|
+
XCTAssertEqual(key, 4); XCTAssertEqual(frame, b)
|
|
30
|
+
XCTAssertTrue(ack(session, 4).isEmpty)
|
|
31
|
+
XCTAssertTrue(session.offer(b, scope: "app:1").isEmpty)
|
|
32
|
+
XCTAssertEqual(sentKey(session.offer(b, scope: "app:2")), 4)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func testForcedReplayAndReconnectDiscardAcceptedState() {
|
|
36
|
+
let session = NimoCanvasSession(); connected(session)
|
|
37
|
+
XCTAssertEqual(sentKey(session.offer(a, scope: "app:1", force: true)), 4)
|
|
38
|
+
_ = ack(session, 4)
|
|
39
|
+
session.disconnected()
|
|
40
|
+
XCTAssertEqual(sentKey(session.readiness(true)), 1)
|
|
41
|
+
XCTAssertEqual(sentKey(ack(session, 1)), 4)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func testStatusSevenRequiresFreshReadinessAndBoundsRetries() {
|
|
45
|
+
let session = NimoCanvasSession()
|
|
46
|
+
_ = session.offer(a, scope: "app:1"); _ = session.readiness(true)
|
|
47
|
+
for _ in 0 ..< 3 {
|
|
48
|
+
XCTAssertEqual(ack(session, 1, status: 7), [.rejected(7)])
|
|
49
|
+
XCTAssertTrue(session.readiness(true).isEmpty)
|
|
50
|
+
XCTAssertEqual(sentKey(session.readiness(true, confirmed: true)), 1)
|
|
51
|
+
}
|
|
52
|
+
_ = ack(session, 1, status: 7)
|
|
53
|
+
XCTAssertTrue(session.readiness(true, confirmed: true).isEmpty)
|
|
54
|
+
XCTAssertTrue(session.readiness(false).isEmpty)
|
|
55
|
+
XCTAssertEqual(sentKey(session.readiness(true)), 1)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func testWrongEchoAndWrongKeyDoNotAdvance() {
|
|
59
|
+
let session = NimoCanvasSession()
|
|
60
|
+
_ = session.offer(a, scope: "app:1"); _ = session.readiness(true)
|
|
61
|
+
XCTAssertTrue(session.response(key: 1, payload: Data([0])).isEmpty)
|
|
62
|
+
XCTAssertTrue(session.response(key: 1, payload: Data([0, 1])).isEmpty)
|
|
63
|
+
XCTAssertTrue(ack(session, 4).isEmpty)
|
|
64
|
+
XCTAssertEqual(sentKey(ack(session, 1)), 4)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func testLockedAndRejectedFramesDoNotSpin() {
|
|
68
|
+
let session = NimoCanvasSession(); connected(session)
|
|
69
|
+
_ = session.offer(b, scope: "app:1")
|
|
70
|
+
XCTAssertEqual(ack(session, 4, status: 5), [.rejected(5)])
|
|
71
|
+
XCTAssertTrue(session.offer(b, scope: "app:1", force: true).isEmpty)
|
|
72
|
+
XCTAssertEqual(sentKey(session.offer(a, scope: "app:2")), 4)
|
|
73
|
+
XCTAssertEqual(ack(session, 4, status: 8), [.rejected(8)])
|
|
74
|
+
XCTAssertTrue(session.readiness(true, confirmed: true).isEmpty)
|
|
75
|
+
XCTAssertTrue(session.offer(b, scope: "new").isEmpty)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
func testTimeoutRequiresNewConnectionAndStaleTicketCannotResetIt() {
|
|
79
|
+
let session = NimoCanvasSession()
|
|
80
|
+
_ = session.offer(a, scope: "app")
|
|
81
|
+
guard case let .send(_, _, ticket) = session.readiness(true).first else { return XCTFail() }
|
|
82
|
+
XCTAssertEqual(session.timeout(ticket), [.reconnect("Canvas ACK timeout; reconnect before reusing a command key")])
|
|
83
|
+
XCTAssertTrue(ack(session, 1).isEmpty)
|
|
84
|
+
session.disconnected()
|
|
85
|
+
XCTAssertEqual(sentKey(session.readiness(true)), 1)
|
|
86
|
+
XCTAssertTrue(session.timeout(ticket).isEmpty)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
func testReadinessLossDuringFlightRetiresTransport() {
|
|
90
|
+
let session = NimoCanvasSession(); connected(session)
|
|
91
|
+
_ = session.offer(b, scope: "app")
|
|
92
|
+
XCTAssertEqual(session.readiness(false), [.reconnect("Readiness lost during canvas command")])
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
func testExitReportPreservesNewHostScene() {
|
|
96
|
+
let session = NimoCanvasSession(); connected(session)
|
|
97
|
+
XCTAssertEqual(sentKey(session.exit()), 3)
|
|
98
|
+
_ = session.offer(b, scope: "new")
|
|
99
|
+
XCTAssertTrue(session.nativeApp(0xFD, entered: false).isEmpty)
|
|
100
|
+
XCTAssertEqual(sentKey(ack(session, 3)), 1)
|
|
101
|
+
guard case let .send(_, frame, _) = ack(session, 1).first else { return XCTFail() }
|
|
102
|
+
XCTAssertEqual(frame, b)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
func testNativeTakeoverReplaysWithoutNewHostFrame() {
|
|
106
|
+
let session = NimoCanvasSession(); connected(session)
|
|
107
|
+
XCTAssertEqual(sentKey(session.nativeApp(0, entered: true)), 1)
|
|
108
|
+
XCTAssertTrue(session.nativeApp(0, entered: true).isEmpty)
|
|
109
|
+
guard case let .send(_, frame, _) = ack(session, 1).first else { return XCTFail() }
|
|
110
|
+
XCTAssertEqual(frame, a)
|
|
111
|
+
XCTAssertTrue(session.nativeApp(0xFD, entered: false).isEmpty)
|
|
112
|
+
XCTAssertEqual(sentKey(ack(session, 4)), 1)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
func testTakeoverDuringUpdateReplaysIdenticalFrameAfterRelaunch() {
|
|
116
|
+
let session = NimoCanvasSession()
|
|
117
|
+
_ = session.offer(a, scope: "app:1"); _ = session.readiness(true)
|
|
118
|
+
XCTAssertEqual(sentKey(ack(session, 1)), 4)
|
|
119
|
+
XCTAssertTrue(session.nativeApp(0, entered: true).isEmpty)
|
|
120
|
+
XCTAssertEqual(sentKey(ack(session, 4)), 1)
|
|
121
|
+
guard case let .send(_, frame, _) = ack(session, 1).first else { return XCTFail("Missing replay Update") }
|
|
122
|
+
XCTAssertEqual(frame, a)
|
|
123
|
+
XCTAssertTrue(ack(session, 4).isEmpty)
|
|
124
|
+
XCTAssertTrue(session.offer(a, scope: "app:1").isEmpty)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
func testStartupStockUiPreservesReconnectScene() {
|
|
128
|
+
let session = NimoCanvasSession(); connected(session)
|
|
129
|
+
session.disconnected()
|
|
130
|
+
XCTAssertTrue(session.nativeApp(0, entered: true).isEmpty)
|
|
131
|
+
XCTAssertTrue(session.activate().isEmpty)
|
|
132
|
+
XCTAssertEqual(sentKey(session.readiness(true)), 1)
|
|
133
|
+
guard case let .send(_, frame, _) = ack(session, 1).first else { return XCTFail() }
|
|
134
|
+
XCTAssertEqual(frame, a)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
func testActivationEntersBlankCanvasBeforeAnyMiniapp() {
|
|
138
|
+
let session = NimoCanvasSession()
|
|
139
|
+
XCTAssertTrue(session.activate().isEmpty)
|
|
140
|
+
XCTAssertEqual(sentKey(session.readiness(true)), 1)
|
|
141
|
+
guard case let .send(_, frame, _) = ack(session, 1).first else { return XCTFail() }
|
|
142
|
+
XCTAssertEqual(frame, Data([0, 0, 1]))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
func testExplicitExitStopsRestorationUntilAnotherConnection() {
|
|
146
|
+
let session = NimoCanvasSession(); connected(session)
|
|
147
|
+
XCTAssertEqual(sentKey(session.exit()), 3)
|
|
148
|
+
XCTAssertTrue(session.nativeApp(0, entered: true).isEmpty)
|
|
149
|
+
XCTAssertTrue(ack(session, 3).isEmpty)
|
|
150
|
+
XCTAssertTrue(session.nativeApp(0xFD, entered: false).isEmpty)
|
|
151
|
+
session.disconnected()
|
|
152
|
+
_ = session.activate()
|
|
153
|
+
XCTAssertEqual(sentKey(session.readiness(true)), 1)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
func testSuccessfulUpdateRestoresProbeBudgetForNextRecovery() {
|
|
157
|
+
let session = NimoCanvasSession()
|
|
158
|
+
_ = session.activate(); _ = session.readiness(true)
|
|
159
|
+
for episode in 0 ..< 2 {
|
|
160
|
+
if episode > 0 { XCTAssertEqual(sentKey(session.nativeApp(0, entered: true)), 1) }
|
|
161
|
+
for _ in 0 ..< 3 {
|
|
162
|
+
_ = ack(session, 1, status: 7)
|
|
163
|
+
XCTAssertEqual(sentKey(session.retryNotReady()), 1)
|
|
164
|
+
}
|
|
165
|
+
XCTAssertEqual(sentKey(ack(session, 1)), 4)
|
|
166
|
+
XCTAssertTrue(ack(session, 4).isEmpty)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
func testProbesAreBoundedAndHeartbeatCanStillRecover() {
|
|
171
|
+
let session = NimoCanvasSession()
|
|
172
|
+
_ = session.activate(); _ = session.readiness(true)
|
|
173
|
+
for _ in 0 ..< 3 {
|
|
174
|
+
_ = ack(session, 1, status: 7)
|
|
175
|
+
XCTAssertEqual(sentKey(session.retryNotReady()), 1)
|
|
176
|
+
}
|
|
177
|
+
_ = ack(session, 1, status: 7)
|
|
178
|
+
XCTAssertTrue(session.retryNotReady().isEmpty)
|
|
179
|
+
XCTAssertEqual(sentKey(session.readiness(true, confirmed: true)), 1)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
@testable import MentraBluetoothSDK
|
|
2
|
+
import XCTest
|
|
3
|
+
|
|
4
|
+
private final class NimoTestClock {
|
|
5
|
+
private final class Job {
|
|
6
|
+
let time: TimeInterval
|
|
7
|
+
let task: () -> Void
|
|
8
|
+
var cancelled = false
|
|
9
|
+
init(_ time: TimeInterval, _ task: @escaping () -> Void) {
|
|
10
|
+
self.time = time; self.task = task
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
private var now: TimeInterval = 0
|
|
15
|
+
private var jobs: [Job] = []
|
|
16
|
+
lazy var schedule: NimoScheduler = { [unowned self] delay, task in
|
|
17
|
+
let job = Job(self.now + delay, task)
|
|
18
|
+
self.jobs.append(job)
|
|
19
|
+
return { job.cancelled = true }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func advance(_ seconds: TimeInterval = 0) {
|
|
23
|
+
let end = now + seconds
|
|
24
|
+
while let job = jobs.filter({ !$0.cancelled && $0.time <= end }).min(by: { $0.time < $1.time }) {
|
|
25
|
+
jobs.removeAll { $0 === job }
|
|
26
|
+
now = job.time
|
|
27
|
+
job.task()
|
|
28
|
+
}
|
|
29
|
+
now = end
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
final class NimoCanvasTransportTests: XCTestCase {
|
|
34
|
+
private let frame = Data([0, 0, 1])
|
|
35
|
+
|
|
36
|
+
func testNotReadyProbesAreTimedBoundedAndCancelled() {
|
|
37
|
+
for stop in ["budget", "disconnect", "exit", "notReady"] {
|
|
38
|
+
let clock = NimoTestClock()
|
|
39
|
+
var keys: [Int] = []
|
|
40
|
+
let canvas = NimoCanvasCoordinator(schedule: clock.schedule, writeCapacity: { 512 }, enqueue: { frames, start, complete in
|
|
41
|
+
keys.append(Int(frames[0][9])); start(); complete(); return true
|
|
42
|
+
}, reconnect: { XCTFail($0) }, rejected: { _ in })
|
|
43
|
+
canvas.activate(); canvas.readiness(true)
|
|
44
|
+
for attempt in 0 ..< 4 {
|
|
45
|
+
canvas.response(key: 1, payload: Data([7]))
|
|
46
|
+
if stop == "disconnect" { canvas.disconnected() }
|
|
47
|
+
if stop == "exit" { canvas.exit() }
|
|
48
|
+
if stop == "notReady" { canvas.readiness(false) }
|
|
49
|
+
let before = keys.count
|
|
50
|
+
clock.advance(0.999)
|
|
51
|
+
XCTAssertEqual(keys.count, before)
|
|
52
|
+
clock.advance(0.001)
|
|
53
|
+
XCTAssertEqual(keys.count, before + (stop == "budget" && attempt < 3 ? 1 : 0))
|
|
54
|
+
if stop != "budget" { break }
|
|
55
|
+
}
|
|
56
|
+
clock.advance(60)
|
|
57
|
+
XCTAssertEqual(keys.count, stop == "budget" ? 4 : 1)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
func testEarlyAckOnlyAdvancesAfterFinalWriteCompletes() {
|
|
62
|
+
let clock = NimoTestClock()
|
|
63
|
+
var chains: [[Data]] = [], starts: [() -> Void] = [], completes: [() -> Void] = []
|
|
64
|
+
let canvas = NimoCanvasCoordinator(schedule: clock.schedule, writeCapacity: { 20 }, enqueue: { frames, start, complete in
|
|
65
|
+
chains.append(frames); starts.append(start); completes.append(complete); return true
|
|
66
|
+
}, reconnect: { XCTFail($0) }, rejected: { XCTFail("Unexpected rejection \($0)") })
|
|
67
|
+
canvas.offer(frame, scope: "app")
|
|
68
|
+
canvas.readiness(true)
|
|
69
|
+
canvas.response(key: 1, payload: Data([0, 0xFD])) // Before final fragment: ignore.
|
|
70
|
+
starts[0]()
|
|
71
|
+
XCTAssertEqual(chains.count, 1)
|
|
72
|
+
canvas.response(key: 1, payload: Data([0, 0xFD])) // Before GATT callback: buffer.
|
|
73
|
+
XCTAssertEqual(chains.count, 1)
|
|
74
|
+
completes[0]()
|
|
75
|
+
XCTAssertEqual(chains.count, 2)
|
|
76
|
+
XCTAssertEqual(chains[1][0][9], 4)
|
|
77
|
+
starts[1](); completes[1]()
|
|
78
|
+
canvas.response(key: 4, payload: Data([0, 0xFD, 0, 0, 0]))
|
|
79
|
+
clock.advance(60)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
func testTimeoutAndOldCompletionCannotAdvanceNewGeneration() {
|
|
83
|
+
let clock = NimoTestClock()
|
|
84
|
+
var completions: [() -> Void] = [], reasons: [String] = [], count = 0
|
|
85
|
+
let canvas = NimoCanvasCoordinator(schedule: clock.schedule, writeCapacity: { 512 }, enqueue: { _, start, complete in
|
|
86
|
+
count += 1; start(); completions.append(complete); return true
|
|
87
|
+
}, reconnect: { reasons.append($0) }, rejected: { _ in })
|
|
88
|
+
canvas.offer(frame, scope: "app"); canvas.readiness(true)
|
|
89
|
+
clock.advance(45)
|
|
90
|
+
XCTAssertEqual(reasons.count, 1)
|
|
91
|
+
canvas.readiness(true)
|
|
92
|
+
completions[0]()
|
|
93
|
+
canvas.response(key: 1, payload: Data([0, 0xFD]))
|
|
94
|
+
XCTAssertEqual(count, 2) // ACK buffered for the new flight until its own write completes.
|
|
95
|
+
completions[1]()
|
|
96
|
+
XCTAssertEqual(count, 3)
|
|
97
|
+
canvas.disconnected()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
func testQueueWatchdogRetiresRatherThanSkippingFragment() {
|
|
101
|
+
let clock = NimoTestClock(), connection = NSObject(), characteristic = NSObject()
|
|
102
|
+
var bytes: [Data] = [], failures: [String] = []
|
|
103
|
+
let queue = NimoWriteQueue<NSObject>(schedule: clock.schedule, write: { _, data in bytes.append(data); return true }, failure: { failures.append($0) })
|
|
104
|
+
queue.connected(connection)
|
|
105
|
+
queue.enqueue(connection, characteristic: characteristic, frames: [Data([1]), Data([2])])
|
|
106
|
+
clock.advance(3)
|
|
107
|
+
XCTAssertEqual(bytes, [Data([1])]); XCTAssertEqual(failures, ["GATT write timed out"])
|
|
108
|
+
queue.written(connection, characteristic: characteristic, success: true)
|
|
109
|
+
clock.advance(1)
|
|
110
|
+
XCTAssertEqual(bytes.count, 1)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
func testQueueRejectsOldPeripheralAndCharacteristicAndPacesWrites() {
|
|
114
|
+
let clock = NimoTestClock(), old = NSObject(), current = NSObject(), characteristic = NSObject()
|
|
115
|
+
var count = 0, completed = 0
|
|
116
|
+
let queue = NimoWriteQueue<NSObject>(schedule: clock.schedule, write: { _, _ in count += 1; return true }, failure: { XCTFail($0) })
|
|
117
|
+
queue.connected(old)
|
|
118
|
+
queue.enqueue(old, characteristic: characteristic, frames: [Data([0])])
|
|
119
|
+
queue.connected(current)
|
|
120
|
+
queue.enqueue(current, characteristic: characteristic, frames: [Data([1]), Data([2])], completed: { completed += 1 })
|
|
121
|
+
queue.written(old, characteristic: characteristic, success: true)
|
|
122
|
+
queue.written(current, characteristic: NSObject(), success: true)
|
|
123
|
+
clock.advance(0.01)
|
|
124
|
+
XCTAssertEqual(count, 2)
|
|
125
|
+
queue.written(current, characteristic: characteristic, success: true)
|
|
126
|
+
clock.advance(0.004)
|
|
127
|
+
XCTAssertEqual(count, 2)
|
|
128
|
+
clock.advance(0.001)
|
|
129
|
+
XCTAssertEqual(count, 3)
|
|
130
|
+
queue.written(current, characteristic: characteristic, success: true)
|
|
131
|
+
clock.advance(0.005)
|
|
132
|
+
XCTAssertEqual(completed, 1)
|
|
133
|
+
clock.advance(10)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
func testGattErrorAbortsRemainingChain() {
|
|
137
|
+
let clock = NimoTestClock(), connection = NSObject(), characteristic = NSObject()
|
|
138
|
+
var count = 0, failures = 0
|
|
139
|
+
let queue = NimoWriteQueue<NSObject>(schedule: clock.schedule, write: { _, _ in count += 1; return true }, failure: { _ in failures += 1 })
|
|
140
|
+
queue.connected(connection)
|
|
141
|
+
queue.enqueue(connection, characteristic: characteristic, frames: [Data([1]), Data([2])])
|
|
142
|
+
queue.written(connection, characteristic: characteristic, success: false)
|
|
143
|
+
clock.advance(4)
|
|
144
|
+
XCTAssertEqual(count, 1); XCTAssertEqual(failures, 1)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
func testLatestOnlyEncoderAndExitFenceRunningResults() {
|
|
148
|
+
let main = NimoTestClock(), worker = NimoTestClock()
|
|
149
|
+
var delivered: [Data] = [], encoded = 0
|
|
150
|
+
let encoder = NimoCanvasEncoder(main: main.schedule, worker: worker.schedule, failure: { XCTFail("\($0)") })
|
|
151
|
+
encoder.submit(encode: { encoded += 1; return Data([1]) }, deliver: { delivered.append($0) })
|
|
152
|
+
encoder.submit(encode: { encoded += 1; return Data([2]) }, deliver: { delivered.append($0) })
|
|
153
|
+
worker.advance()
|
|
154
|
+
XCTAssertEqual(encoded, 1)
|
|
155
|
+
encoder.invalidate() // Exit/disconnect while the finished encode awaits Main.
|
|
156
|
+
main.advance()
|
|
157
|
+
XCTAssertTrue(delivered.isEmpty)
|
|
158
|
+
encoder.submit(encode: { Data([3]) }, deliver: { delivered.append($0) })
|
|
159
|
+
worker.advance(); main.advance()
|
|
160
|
+
XCTAssertEqual(delivered, [Data([3])])
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
func testCompleteSceneThroughWriteQueueAndBusinessAcks() throws {
|
|
164
|
+
let clock = NimoTestClock(), connection = NSObject(), characteristic = NSObject()
|
|
165
|
+
var written: [Data] = []
|
|
166
|
+
let queue = NimoWriteQueue<NSObject>(schedule: clock.schedule, write: { _, data in written.append(data); return true }, failure: { XCTFail($0) })
|
|
167
|
+
queue.connected(connection)
|
|
168
|
+
let canvas = NimoCanvasCoordinator(schedule: clock.schedule, writeCapacity: { 20 }, enqueue: { frames, started, completed in
|
|
169
|
+
queue.enqueue(connection, characteristic: characteristic, frames: frames, finalStarted: started, completed: completed)
|
|
170
|
+
}, reconnect: { XCTFail($0) }, rejected: { XCTFail("Rejected \($0)") })
|
|
171
|
+
let content = try NimoCanvasCodec.replace([NimoCanvasCodec.label("Captions", 0, 0, 500, 20)])
|
|
172
|
+
canvas.offer(content, scope: "captions:1"); canvas.readiness(true)
|
|
173
|
+
queue.written(connection, characteristic: characteristic, success: true); clock.advance(0.005)
|
|
174
|
+
let launchAck = Data([0, 0xFD])
|
|
175
|
+
canvas.response(key: 1, payload: launchAck)
|
|
176
|
+
let expected = try NimoCanvasCodec.frames(key: 4, content: content, writeCapacity: 20)
|
|
177
|
+
for _ in expected {
|
|
178
|
+
queue.written(connection, characteristic: characteristic, success: true); clock.advance(0.005)
|
|
179
|
+
}
|
|
180
|
+
XCTAssertEqual(Array(written.dropFirst()), expected)
|
|
181
|
+
canvas.response(key: 4, payload: Data([0, 0xFD, 0, 0, 0]))
|
|
182
|
+
canvas.offer(content, scope: "captions:1")
|
|
183
|
+
XCTAssertEqual(written.count, expected.count + 1)
|
|
184
|
+
canvas.disconnected(); queue.reset()
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
@testable import MentraBluetoothSDK
|
|
3
|
+
import XCTest
|
|
4
|
+
|
|
5
|
+
final class NimoDeviceCommandsTests: XCTestCase {
|
|
6
|
+
func testSystemEnglishAndFactoryResetMatchFirmwareWireCommands() {
|
|
7
|
+
func hex(_ bytes: Data) -> String {
|
|
8
|
+
bytes.map { String(format: "%02X", $0) }.joined()
|
|
9
|
+
}
|
|
10
|
+
XCTAssertEqual(hex(NimoFrameCodec.encodeFrame(cmd: NimoProtocol.CMD_SET_PARAMETER,
|
|
11
|
+
key: NimoProtocol.SET_SYSTEM_LANGUAGE, payload: Data([NimoProtocol.LANGUAGE_ENGLISH]))),
|
|
12
|
+
"BF020500702500000324010001")
|
|
13
|
+
XCTAssertEqual(hex(NimoFrameCodec.encodeFrame(cmd: NimoProtocol.CMD_CONTROL_FACTORY,
|
|
14
|
+
key: NimoProtocol.FACTORY_RECOVER, payload: Data())), "BF0204005358000008030000")
|
|
15
|
+
}
|
|
16
|
+
}
|