@mentra/bluetooth-sdk 3.2.1-dev.311 → 3.2.1
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 +4 -4
- 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/BluetoothSdkDefaults.kt +3 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +6 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +2 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +129 -25
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +2 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +4 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +13 -2
- 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/G1.kt +2 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +72 -21
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +101 -22
- 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 +245 -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/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/VersionInfoResponseAccumulatorTest.kt +71 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1PairingTest.kt +105 -0
- 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/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/MentraLivePairingTargetTest.kt +65 -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 +220 -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 +13 -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 +3 -0
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/generated/changelogCatalog.d.ts +2 -2
- package/build/generated/changelogCatalog.js +2 -2
- package/build/generated/changelogCatalog.js.map +1 -1
- package/build/generated/releaseMetadata.js +5 -5
- 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/ios/BluetoothSdkModule.swift +9 -0
- package/ios/MentraBluetoothSDK.podspec +3 -2
- package/ios/Source/BluetoothBackgroundMode.swift +32 -0
- package/ios/Source/BluetoothSdkDefaults.swift +6 -1
- package/ios/Source/Bridge.swift +15 -2
- package/ios/Source/DeviceManager.swift +96 -63
- package/ios/Source/DeviceStore.swift +26 -7
- package/ios/Source/GeneratedChangelogCatalog.swift +2 -2
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +18 -0
- package/ios/Source/audio/GlassesMicWatchdog.swift +33 -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 +201 -156
- 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 +269 -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 +24 -0
- 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/DemandDrivenAudioWorker.swift +150 -0
- package/ios/Source/utils/G2Text.swift +11 -0
- package/ios/Tests/BluetoothBackgroundModeTests.swift +32 -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/G2ConnectionSessionTests.swift +172 -0
- package/ios/Tests/G2TextTests.swift +10 -0
- package/ios/Tests/MentraLiveFileTransferSessionTests.swift +47 -0
- package/ios/Tests/MentraLiveReconnectPolicyTests.swift +58 -0
- package/ios/Tests/NimoCanvasCodecTests.swift +204 -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 +53 -0
- package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
- package/ios/Tests/VideoRecordingCommandTests.swift +81 -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 +14 -0
- package/src/_private/BluetoothSdkModule.ts +8 -0
- package/src/generated/changelogCatalog.ts +2 -2
- package/src/generated/releaseMetadata.ts +5 -5
- package/src/index.ts +1 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
@testable import MentraBluetoothSDK
|
|
2
|
+
import XCTest
|
|
3
|
+
|
|
4
|
+
final class G2ConnectionSessionTests: XCTestCase {
|
|
5
|
+
func testEitherArmAloneCannotStartAuthenticationOrBecomeReady() {
|
|
6
|
+
for side in G2ConnectionSession.Side.allCases {
|
|
7
|
+
var session = G2ConnectionSession()
|
|
8
|
+
session.initialize(side)
|
|
9
|
+
XCTAssertNil(session.beginAuthentication())
|
|
10
|
+
session.authenticated(side, success: true)
|
|
11
|
+
session.completeSetup(generation: session.generation)
|
|
12
|
+
XCTAssertFalse(session.isReady)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
func testReadinessWaitsForBothAuthenticationResponsesAndCompletedSetup() throws {
|
|
17
|
+
for completeSetupFirst in [false, true] {
|
|
18
|
+
var session = G2ConnectionSession()
|
|
19
|
+
session.initialize(.right)
|
|
20
|
+
session.initialize(.left)
|
|
21
|
+
let generation = try XCTUnwrap(session.beginAuthentication())
|
|
22
|
+
XCTAssertNil(session.beginAuthentication(), "Duplicate service callbacks must not restart setup")
|
|
23
|
+
if completeSetupFirst { session.completeSetup(generation: generation) }
|
|
24
|
+
session.authenticated(.left, success: true)
|
|
25
|
+
session.authenticated(.right, success: false)
|
|
26
|
+
XCTAssertFalse(session.isReady)
|
|
27
|
+
session.authenticated(.right, success: true)
|
|
28
|
+
XCTAssertEqual(session.isReady, completeSetupFirst)
|
|
29
|
+
session.completeSetup(generation: generation)
|
|
30
|
+
XCTAssertTrue(session.isReady)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func testDisconnectRetiresAuthenticationAndSuspendedSetup() throws {
|
|
35
|
+
var session = G2ConnectionSession()
|
|
36
|
+
session.initialize(.left)
|
|
37
|
+
session.initialize(.right)
|
|
38
|
+
let oldGeneration = try XCTUnwrap(session.beginAuthentication())
|
|
39
|
+
session.authenticated(.left, success: true)
|
|
40
|
+
session.reset()
|
|
41
|
+
session.authenticated(.right, success: true)
|
|
42
|
+
session.completeSetup(generation: oldGeneration)
|
|
43
|
+
XCTAssertFalse(session.isReady)
|
|
44
|
+
|
|
45
|
+
session.initialize(.left)
|
|
46
|
+
session.initialize(.right)
|
|
47
|
+
let newGeneration = try XCTUnwrap(session.beginAuthentication())
|
|
48
|
+
session.authenticated(.left, success: true)
|
|
49
|
+
session.authenticated(.right, success: true)
|
|
50
|
+
session.completeSetup(generation: oldGeneration)
|
|
51
|
+
XCTAssertFalse(session.isReady, "Old setup cannot complete the replacement attempt")
|
|
52
|
+
session.completeSetup(generation: newGeneration)
|
|
53
|
+
XCTAssertTrue(session.isReady)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func testAttemptDeadlineCoversBothPartialPairsAndIncompleteAuthentication() throws {
|
|
57
|
+
for sides: [G2ConnectionSession.Side] in [[], [.left], [.right], [.left, .right]] {
|
|
58
|
+
var session = G2ConnectionSession()
|
|
59
|
+
for side in sides {
|
|
60
|
+
session.initialize(side)
|
|
61
|
+
}
|
|
62
|
+
_ = session.beginAuthentication()
|
|
63
|
+
let attempt = session.generation
|
|
64
|
+
XCTAssertTrue(session.needsRecovery(attempt: attempt))
|
|
65
|
+
session.reset()
|
|
66
|
+
XCTAssertFalse(session.needsRecovery(attempt: attempt), "A retired deadline cannot reset a new attempt")
|
|
67
|
+
}
|
|
68
|
+
var ready = G2ConnectionSession()
|
|
69
|
+
ready.initialize(.left)
|
|
70
|
+
ready.initialize(.right)
|
|
71
|
+
let attempt = try XCTUnwrap(ready.beginAuthentication())
|
|
72
|
+
ready.authenticated(.left, success: true)
|
|
73
|
+
ready.authenticated(.right, success: true)
|
|
74
|
+
ready.completeSetup(generation: attempt)
|
|
75
|
+
XCTAssertFalse(ready.needsRecovery(attempt: attempt))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
func testCachedArmsCanReconnectIndependentlyWithoutAdvertisements() {
|
|
79
|
+
let left = UUID(), right = UUID()
|
|
80
|
+
XCTAssertEqual(target(left, left: left), .left)
|
|
81
|
+
XCTAssertEqual(target(right, right: right), .right)
|
|
82
|
+
XCTAssertNil(target(UUID(), left: left, right: right))
|
|
83
|
+
XCTAssertNil(target(right, left: right, right: right), "Ambiguous cache must not bind one arm twice")
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func testOnlySelectedSerialCanFillTheMissingArm() {
|
|
87
|
+
let id = UUID()
|
|
88
|
+
XCTAssertEqual(target(id, name: "Even G2_32_L_123456", serial: "S211SELECTED"), .left)
|
|
89
|
+
XCTAssertNil(target(id, name: "Even G2_32_L_123456", serial: "S211OTHER"))
|
|
90
|
+
XCTAssertNil(target(id, name: "Even G2_32_R_123456"))
|
|
91
|
+
XCTAssertNil(target(id, right: id, searchID: "NOT_SET"))
|
|
92
|
+
XCTAssertNil(target(id, right: id, searchID: ""))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private func target(
|
|
96
|
+
_ id: UUID, name: String? = nil, serial: String? = nil,
|
|
97
|
+
left: UUID? = nil, right: UUID? = nil, searchID: String = "S211SELECTED"
|
|
98
|
+
) -> G2ConnectionSession.Side? {
|
|
99
|
+
G2ConnectionTarget.side(identifier: id, name: name, advertisedSerial: serial,
|
|
100
|
+
searchID: searchID, leftUUID: left, rightUUID: right)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
@MainActor
|
|
105
|
+
final class G2PrematureReadinessTests: XCTestCase {
|
|
106
|
+
func testSystemExitAndTouchBeforePairInitializationDoNotPublishReady() {
|
|
107
|
+
let store = DeviceStore.shared.store
|
|
108
|
+
let connected = store.get("glasses", "connected")
|
|
109
|
+
let booted = store.get("glasses", "fullyBooted")
|
|
110
|
+
defer {
|
|
111
|
+
if let connected { store.set("glasses", "connected", connected) }
|
|
112
|
+
if let booted { store.set("glasses", "fullyBooted", booted) }
|
|
113
|
+
}
|
|
114
|
+
store.set("glasses", "connected", false)
|
|
115
|
+
store.set("glasses", "fullyBooted", false)
|
|
116
|
+
let g2 = G2()
|
|
117
|
+
// SendDeviceEvent.SysEvent { eventType: systemExit }, as observed on the right arm.
|
|
118
|
+
g2.handleTouchEvent(Data([0x1A, 0x02, 0x08, 0x07]))
|
|
119
|
+
XCTAssertEqual(store.get("glasses", "connected") as? Bool, false)
|
|
120
|
+
XCTAssertEqual(store.get("glasses", "fullyBooted") as? Bool, false)
|
|
121
|
+
g2.handleTouchEvent(Data([0x1A, 0x02, 0x08, 0x00]))
|
|
122
|
+
XCTAssertEqual(store.get("glasses", "connected") as? Bool, false)
|
|
123
|
+
XCTAssertEqual(store.get("glasses", "fullyBooted") as? Bool, false)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
final class G2PartialConnectionTests: XCTestCase {
|
|
128
|
+
func testEitherPartialLinkStaysQuietForThreeSeconds() {
|
|
129
|
+
for leftConnected in [false, true] {
|
|
130
|
+
var progress = G2PartialConnection()
|
|
131
|
+
progress.update(leftConnected: leftConnected, rightConnected: !leftConnected, now: 10)
|
|
132
|
+
XCTAssertNil(progress.visibleMissingSide(now: 10))
|
|
133
|
+
XCTAssertNil(progress.visibleMissingSide(now: 12.999))
|
|
134
|
+
// Duplicate observations must not restart the grace period.
|
|
135
|
+
progress.update(leftConnected: leftConnected, rightConnected: !leftConnected, now: 12)
|
|
136
|
+
XCTAssertEqual(progress.visibleMissingSide(now: 13), leftConnected ? .right : .left)
|
|
137
|
+
XCTAssertEqual(progress.timeoutError, leftConnected ? "errors:g2RightArmUnavailable" : "errors:g2LeftArmUnavailable")
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
func testNormalSequentialPairingAndDisconnectedStateNeverShowMissingArm() {
|
|
142
|
+
var progress = G2PartialConnection()
|
|
143
|
+
progress.update(leftConnected: false, rightConnected: true, now: 10)
|
|
144
|
+
progress.update(leftConnected: true, rightConnected: true, now: 12)
|
|
145
|
+
XCTAssertNil(progress.visibleMissingSide(now: 15))
|
|
146
|
+
progress.update(leftConnected: false, rightConnected: false, now: 16)
|
|
147
|
+
XCTAssertNil(progress.visibleMissingSide(now: 20))
|
|
148
|
+
XCTAssertEqual(progress.timeoutError, "errors:g2ConnectionTimedOut")
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
func testPeerArrivalClearsVisibleNoticeAndNewPartialLinkGetsOwnGracePeriod() {
|
|
152
|
+
var progress = G2PartialConnection()
|
|
153
|
+
progress.update(leftConnected: true, rightConnected: false, now: 10)
|
|
154
|
+
XCTAssertEqual(progress.visibleMissingSide(now: 13), .right)
|
|
155
|
+
progress.update(leftConnected: true, rightConnected: true, now: 14)
|
|
156
|
+
XCTAssertNil(progress.visibleMissingSide(now: 14))
|
|
157
|
+
progress.update(leftConnected: false, rightConnected: true, now: 15)
|
|
158
|
+
XCTAssertNil(progress.visibleMissingSide(now: 17))
|
|
159
|
+
XCTAssertEqual(progress.visibleMissingSide(now: 18), .left)
|
|
160
|
+
progress = G2PartialConnection()
|
|
161
|
+
XCTAssertNil(progress.visibleMissingSide(now: 100))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
func testStatusSnapshotAndUpdateCarryNoticeAndDisconnectClearsIt() throws {
|
|
165
|
+
let status = GlassesStatus(values: ["g2MissingArm": "left"])
|
|
166
|
+
XCTAssertEqual(status.dictionary["g2MissingArm"] as? String, "left")
|
|
167
|
+
XCTAssertEqual(GlassesStatusUpdate(values: ["g2MissingArm": "right"]).dictionary["g2MissingArm"] as? String, "right")
|
|
168
|
+
let disconnected = status.disconnected().dictionary
|
|
169
|
+
XCTAssertTrue(disconnected["g2MissingArm"] is NSNull)
|
|
170
|
+
XCTAssertNoThrow(try JSONSerialization.data(withJSONObject: disconnected))
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
@testable import MentraBluetoothSDK
|
|
2
|
+
import XCTest
|
|
3
|
+
|
|
4
|
+
final class G2TextTests: XCTestCase {
|
|
5
|
+
func testBlankRowsAreEncodedOnlyAtTheDeviceBoundary() {
|
|
6
|
+
XCTAssertEqual(G2Text.containerContent("hello\n\nworld\n"), "hello\n\u{200B}\nworld\n\u{200B}")
|
|
7
|
+
XCTAssertEqual(G2Text.containerContent(""), " ")
|
|
8
|
+
XCTAssertEqual(G2Text.containerContent("hello\nworld"), "hello\nworld")
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
@testable import MentraBluetoothSDK
|
|
3
|
+
import XCTest
|
|
4
|
+
|
|
5
|
+
final class MentraLiveFileTransferSessionTests: XCTestCase {
|
|
6
|
+
func testDynamicPayloadUsesTrueSizeEvenWhenDivisibleBy400() {
|
|
7
|
+
for pack in [244, 474, 800] {
|
|
8
|
+
var session = MentraLiveFileTransferSession(fileName: "photo", fileSize: 80000, flags: 2)
|
|
9
|
+
session.recalculateTotalPackets(actualPackSize: pack)
|
|
10
|
+
XCTAssertEqual(session.totalPackets, (80000 + pack - 1) / pack)
|
|
11
|
+
for index in 0 ..< session.totalPackets {
|
|
12
|
+
XCTAssertTrue(session.addPacket(index, data: Data(repeating: 7, count: min(pack, 80000 - index * pack))))
|
|
13
|
+
}
|
|
14
|
+
XCTAssertTrue(session.isComplete)
|
|
15
|
+
XCTAssertEqual(session.assembleFile()?.count, 80000)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
func testLegacyInflatedSizeAndLargeUnflaggedPayloads() {
|
|
20
|
+
// Old senders inflate the header length for small packs, including
|
|
21
|
+
// the short final pack. Flag 1 only enables batched ACKs.
|
|
22
|
+
for (pack, headerSize, actualSize, packets) in [(221, 80000, 43982, 200), (400, 80000, 79777, 200), (800, 80000, 80000, 100)] {
|
|
23
|
+
var session = MentraLiveFileTransferSession(fileName: "photo", fileSize: headerSize, flags: 1)
|
|
24
|
+
session.recalculateTotalPackets(actualPackSize: pack)
|
|
25
|
+
XCTAssertEqual(session.totalPackets, packets)
|
|
26
|
+
let source = Data((0 ..< actualSize).map { UInt8($0 % 251) })
|
|
27
|
+
for index in (0 ..< packets).reversed() {
|
|
28
|
+
let start = index * pack
|
|
29
|
+
XCTAssertTrue(session.addPacket(index, data: source.subdata(in: start ..< min(start + pack, actualSize))))
|
|
30
|
+
}
|
|
31
|
+
XCTAssertTrue(session.isComplete)
|
|
32
|
+
XCTAssertEqual(session.assembleFile(), source)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func testMalformedOrDuplicatePacketsCannotCompleteFile() {
|
|
37
|
+
var session = MentraLiveFileTransferSession(fileName: "photo", fileSize: 1000, flags: 2)
|
|
38
|
+
session.recalculateTotalPackets(actualPackSize: 800)
|
|
39
|
+
XCTAssertFalse(session.addPacket(0, data: Data(count: 799)))
|
|
40
|
+
XCTAssertFalse(session.addPacket(2, data: Data(count: 200)))
|
|
41
|
+
XCTAssertTrue(session.addPacket(1, data: Data(count: 200)))
|
|
42
|
+
XCTAssertFalse(session.isComplete)
|
|
43
|
+
XCTAssertFalse(session.addPacket(1, data: Data(count: 200)))
|
|
44
|
+
XCTAssertTrue(session.addPacket(0, data: Data(count: 800)))
|
|
45
|
+
XCTAssertEqual(session.assembleFile()?.count, 1000)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
@testable import MentraBluetoothSDK
|
|
2
|
+
import XCTest
|
|
3
|
+
|
|
4
|
+
final class MentraLiveReconnectPolicyTests: XCTestCase {
|
|
5
|
+
private func decide(
|
|
6
|
+
isKilled: Bool = false,
|
|
7
|
+
pairingYieldActive: Bool = false,
|
|
8
|
+
bluetoothPoweredOn: Bool = true,
|
|
9
|
+
peripheralName: String? = "Mentra_Live_D910",
|
|
10
|
+
savedDeviceName: String? = "Mentra_Live_D910"
|
|
11
|
+
) -> Bool {
|
|
12
|
+
MentraLiveConnectionAttemptPolicy.shouldReconnectDirectly(
|
|
13
|
+
isKilled: isKilled,
|
|
14
|
+
pairingYieldActive: pairingYieldActive,
|
|
15
|
+
bluetoothPoweredOn: bluetoothPoweredOn,
|
|
16
|
+
peripheralName: peripheralName,
|
|
17
|
+
savedDeviceName: savedDeviceName
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func testRemembersLostGlassesWithPendingConnect() {
|
|
22
|
+
XCTAssertTrue(decide())
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
func testDoesNotReconnectAfterUserDisconnectOrKill() {
|
|
26
|
+
XCTAssertFalse(decide(isKilled: true))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func testYieldsToAnotherPhonePairing() {
|
|
30
|
+
XCTAssertFalse(decide(pairingYieldActive: true))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func testFallsBackWhenBluetoothIsOff() {
|
|
34
|
+
XCTAssertFalse(decide(bluetoothPoweredOn: false))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func testDoesNotReconnectForgottenOrDifferentGlasses() {
|
|
38
|
+
XCTAssertFalse(decide(savedDeviceName: nil))
|
|
39
|
+
XCTAssertFalse(decide(savedDeviceName: "Mentra_Live_023B"))
|
|
40
|
+
XCTAssertFalse(decide(peripheralName: nil))
|
|
41
|
+
XCTAssertFalse(decide(peripheralName: "", savedDeviceName: ""))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func testFailureThenFallbackLeavesTheStaleSettleTimerPowerless() {
|
|
45
|
+
var pending = MentraLivePendingReconnect()
|
|
46
|
+
let first = pending.arm()
|
|
47
|
+
XCTAssertTrue(pending.owns(first))
|
|
48
|
+
|
|
49
|
+
// didFailToConnect or a fallback connectToDevice clears tracking.
|
|
50
|
+
pending.clear()
|
|
51
|
+
XCTAssertFalse(pending.owns(first))
|
|
52
|
+
|
|
53
|
+
// A later re-arm does not revive the earlier attempt's timer.
|
|
54
|
+
let second = pending.arm()
|
|
55
|
+
XCTAssertFalse(pending.owns(first))
|
|
56
|
+
XCTAssertTrue(pending.owns(second))
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import CoreGraphics
|
|
2
|
+
import ImageIO
|
|
3
|
+
@testable import MentraBluetoothSDK
|
|
4
|
+
import XCTest
|
|
5
|
+
import zlib
|
|
6
|
+
|
|
7
|
+
final class NimoCanvasCodecTests: XCTestCase {
|
|
8
|
+
private func hex(_ data: Data) -> String {
|
|
9
|
+
data.map { String(format: "%02X", $0) }.joined()
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
private func packet(_ hex: String) -> Data {
|
|
13
|
+
let chars = Array(hex)
|
|
14
|
+
return Data(stride(from: 0, to: chars.count, by: 2).map { UInt8(String(chars[$0 ... $0 + 1]), radix: 16)! })
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
func testVendorGoldenPacketsMatchAndroid() throws {
|
|
18
|
+
let frame = try NimoCanvasCodec.replace([
|
|
19
|
+
NimoCanvasCodec.rectangle(20, 20, 120, 50, stroke: 2, radius: 8),
|
|
20
|
+
NimoCanvasCodec.label("Hello Glass", 160, 30, 300, 40, font: 2),
|
|
21
|
+
])
|
|
22
|
+
XCTAssertEqual(try hex(NimoCanvasCodec.frames(key: 4, content: frame, writeCapacity: 512)[0]),
|
|
23
|
+
"BF003600F72D000007043200FD000000020001010C0014001400780032000208FF00031900A0001E002C012800000002FF0B0048656C6C6F20476C617373")
|
|
24
|
+
XCTAssertEqual(try hex(NimoCanvasCodec.frames(key: 1, writeCapacity: 512)[0]), "BF000600F5E4000007010200FD00")
|
|
25
|
+
XCTAssertEqual(try hex(NimoCanvasCodec.frames(key: 3, writeCapacity: 512)[0]), "BF00050086E4000007030100FD")
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func testPolarityOddPaddingAndImageHeader() throws {
|
|
29
|
+
let gray = Data([0, 85, 170, 255, 255])
|
|
30
|
+
XCTAssertEqual(try NimoCanvasCodec.pack2(gray), Data([0xE4, 0x3F]))
|
|
31
|
+
let bitmap = try NimoCanvasCodec.bitmap(0, 0, 5, 1, gray: gray)
|
|
32
|
+
XCTAssertEqual(bitmap.payload[10], 1)
|
|
33
|
+
XCTAssertEqual(bitmap.payload[19], 2)
|
|
34
|
+
XCTAssertEqual(bitmap.payload[20], 3)
|
|
35
|
+
XCTAssertEqual(bitmap.payload[21], 2)
|
|
36
|
+
XCTAssertEqual(bitmap.payload.dropFirst(29), Data([0x82, 0xE4, 0x3F]))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func testRleRunAndLiteralBoundariesRoundTrip() throws {
|
|
40
|
+
for size in [1, 2, 3, 126, 127, 128, 254, 27500] {
|
|
41
|
+
for source in [Data(repeating: 33, count: size), Data((0 ..< size).map { UInt8($0 % 251) })] {
|
|
42
|
+
let encoded = try [UInt8](NimoCanvasCodec.rle(source))
|
|
43
|
+
var decoded = Data(), i = 0
|
|
44
|
+
while i < encoded.count {
|
|
45
|
+
let tag = encoded[i]; i += 1
|
|
46
|
+
let count = Int(tag & 127)
|
|
47
|
+
XCTAssertGreaterThan(count, 0)
|
|
48
|
+
if tag & 128 != 0 { decoded.append(contentsOf: encoded[i ..< i + count]); i += count }
|
|
49
|
+
else { decoded.append(Data(repeating: encoded[i], count: count)); i += 1 }
|
|
50
|
+
}
|
|
51
|
+
XCTAssertEqual(decoded, source)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func testZlibImageInflatesToPackedPixels() throws {
|
|
57
|
+
let pixels: [UInt8] = (0 ..< 32768).map { index in
|
|
58
|
+
let tone = ((index / 17) ^ (index / 43)) % 4
|
|
59
|
+
return UInt8(tone * 85)
|
|
60
|
+
}
|
|
61
|
+
let gray = Data(pixels)
|
|
62
|
+
let bitmap = try NimoCanvasCodec.bitmap(0, 0, 256, 128, gray: gray)
|
|
63
|
+
XCTAssertEqual(bitmap.payload[20], 7)
|
|
64
|
+
let compressed = Data(bitmap.payload.dropFirst(29))
|
|
65
|
+
var count: uLong = 8192
|
|
66
|
+
var output = Data(count: Int(count))
|
|
67
|
+
let status = output.withUnsafeMutableBytes { dst in
|
|
68
|
+
compressed.withUnsafeBytes { src in
|
|
69
|
+
uncompress(dst.bindMemory(to: UInt8.self).baseAddress!, &count,
|
|
70
|
+
src.bindMemory(to: UInt8.self).baseAddress!, uLong(compressed.count))
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
XCTAssertEqual(status, Z_OK)
|
|
74
|
+
XCTAssertEqual(output, try NimoCanvasCodec.pack2(gray))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
func testFragmentationRespectsCoreBluetoothCapacityAndCrc() throws {
|
|
78
|
+
let content = try NimoCanvasCodec.replace([NimoCanvasCodec.label(String(repeating: "X", count: 2048), 0, 0, 500, 220)])
|
|
79
|
+
var baseline: Data?
|
|
80
|
+
for capacity in [20, 64, 185, 244, 512] {
|
|
81
|
+
let frames = try NimoCanvasCodec.frames(key: 4, content: content, writeCapacity: capacity)
|
|
82
|
+
var stream = Data()
|
|
83
|
+
for (index, frame) in frames.enumerated() {
|
|
84
|
+
XCTAssertLessThanOrEqual(frame.count, capacity)
|
|
85
|
+
XCTAssertEqual(frame[1], 0)
|
|
86
|
+
XCTAssertEqual(Int(frame[6]) | Int(frame[7]) << 8, index == frames.count - 1 ? 0 : index + 1)
|
|
87
|
+
XCTAssertEqual(UInt16(frame[4]) | UInt16(frame[5]) << 8, nimoCrc16(Data(frame.dropFirst(8))))
|
|
88
|
+
stream += frame.dropFirst(8)
|
|
89
|
+
}
|
|
90
|
+
if let baseline { XCTAssertEqual(stream, baseline) } else { baseline = stream }
|
|
91
|
+
}
|
|
92
|
+
XCTAssertThrowsError(try NimoCanvasCodec.frames(key: 4, content: content, writeCapacity: 19))
|
|
93
|
+
XCTAssertThrowsError(try NimoCanvasCodec.frames(key: 4, content: Data(count: 12281), writeCapacity: 512))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
func testStrictCanvasResponseParsing() {
|
|
97
|
+
let good = packet("BF000900D87900000704050000FD000000")
|
|
98
|
+
XCTAssertEqual(NimoCanvasCodec.response(good)?.payload, Data([0, 0xFD, 0, 0, 0]))
|
|
99
|
+
XCTAssertNil(NimoCanvasCodec.response(good + Data([0])))
|
|
100
|
+
for index in [0, 1, 2, 4, 6, 8, 10] {
|
|
101
|
+
var bad = good; bad[index] ^= 1
|
|
102
|
+
XCTAssertNil(NimoCanvasCodec.response(bad))
|
|
103
|
+
}
|
|
104
|
+
let body = Data([7, 4, 1, 0, 7])
|
|
105
|
+
let statusOnly = NimoFrameCodec.transportHeader(body, needsAck: false) + body
|
|
106
|
+
XCTAssertEqual(NimoCanvasCodec.response(statusOnly)?.payload, Data([7]))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
func testUtf8RowsAndGeometryValidation() throws {
|
|
110
|
+
XCTAssertEqual(try NimoCanvasCodec.label("😀é", 0, 0, 100, 20).textBytes, 6)
|
|
111
|
+
XCTAssertThrowsError(try NimoCanvasCodec.label("a\0b", 0, 0, 100, 20))
|
|
112
|
+
XCTAssertThrowsError(try NimoCanvasCodec.label(String(repeating: "é", count: 1025), 0, 0, 100, 20))
|
|
113
|
+
let rows = try NimoCanvasCodec.textRows("first\n\nthird", 12, 15, 400, 100)
|
|
114
|
+
XCTAssertEqual(rows.count, 2)
|
|
115
|
+
XCTAssertEqual(rows.map { $0.payload[2] }, [15, 55])
|
|
116
|
+
XCTAssertEqual(rows[1].payload[6], 20)
|
|
117
|
+
XCTAssertThrowsError(try NimoCanvasCodec.rectangle(Int.max, 0, 10, 1))
|
|
118
|
+
XCTAssertThrowsError(try NimoCanvasCodec.line(0, 0, 500, 219))
|
|
119
|
+
XCTAssertThrowsError(try NimoCanvasCodec.circle(0, 10, radius: 10))
|
|
120
|
+
XCTAssertEqual(try NimoCanvasCodec.circle(10, 10, radius: 10).payload.count, 9)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
func testFrameBudgetsBeforeRasterDecode() throws {
|
|
124
|
+
XCTAssertEqual(try NimoCanvasCodec.replace([]), Data([0, 0, 1]))
|
|
125
|
+
let line = try NimoCanvasCodec.line(0, 0, 1, 1)
|
|
126
|
+
XCTAssertThrowsError(try NimoCanvasCodec.replace(Array(repeating: line, count: 65)))
|
|
127
|
+
let text = try NimoCanvasCodec.label(String(repeating: "a", count: 2048), 0, 0, 500, 220)
|
|
128
|
+
XCTAssertThrowsError(try NimoCanvasCodec.replace(Array(repeating: text, count: 5)))
|
|
129
|
+
let image = SceneElement(id: "image", type: "image", x: 0, y: 0, w: 500, h: 220, text: nil,
|
|
130
|
+
data: "unused", border: 0, radius: 0, change: "unchanged", contentHash: "")
|
|
131
|
+
var decoded = 0
|
|
132
|
+
XCTAssertThrowsError(try NimoCanvasCodec.scene([image, image]) { _, _, _ in decoded += 1; return Data() })
|
|
133
|
+
XCTAssertEqual(decoded, 0)
|
|
134
|
+
XCTAssertThrowsError(try NimoCanvasCodec.replace([.init(type: 4, payload: Data(count: 12278))]))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
func testHostFrameReservationBoundsNativeEncoding() throws {
|
|
138
|
+
var seed: UInt32 = 42
|
|
139
|
+
func noise(_ count: Int) -> Data {
|
|
140
|
+
Data((0 ..< count).map { _ in
|
|
141
|
+
seed = seed &* 1_664_525 &+ 1_013_904_223
|
|
142
|
+
return UInt8(truncatingIfNeeded: seed >> 24)
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
for size in [1, 5, 80, 160, 200] {
|
|
146
|
+
let image = try NimoCanvasCodec.bitmap(0, 0, size, size, gray: noise(size * size))
|
|
147
|
+
let packed = (size * size + 3) / 4
|
|
148
|
+
let reserved = 32 + packed + (packed + 126) / 127
|
|
149
|
+
XCTAssertLessThanOrEqual(3 + image.payload.count, reserved)
|
|
150
|
+
}
|
|
151
|
+
// Retained prefixes of the host's over-budget regression scenes.
|
|
152
|
+
let rows = try NimoCanvasCodec.textRows(Array(repeating: "row", count: 11).joined(separator: "\n"), 0, 0, 500, 220)
|
|
153
|
+
XCTAssertLessThanOrEqual(try NimoCanvasCodec.replace(Array(repeating: rows, count: 5).flatMap { $0 }).count, 12280)
|
|
154
|
+
let images = try [200, 80].map { try NimoCanvasCodec.bitmap(0, 0, $0, $0, gray: noise($0 * $0)) }
|
|
155
|
+
XCTAssertLessThanOrEqual(try NimoCanvasCodec.replace(images).count, 12280)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
func testWholeScenePreservesOrderAndIgnoresDiffAnnotations() throws {
|
|
159
|
+
let text = SceneElement(id: "caption", type: "text", x: 10, y: 20, w: 100, h: 40, text: "hello",
|
|
160
|
+
data: nil, border: 2, radius: 3, change: "unchanged", contentHash: "")
|
|
161
|
+
let expected = try NimoCanvasCodec.replace([
|
|
162
|
+
NimoCanvasCodec.rectangle(10, 20, 100, 40, stroke: 2, radius: 3),
|
|
163
|
+
NimoCanvasCodec.label("hello", 10, 20, 100, 20),
|
|
164
|
+
])
|
|
165
|
+
XCTAssertEqual(try NimoCanvasCodec.scene([text]) { _, _, _ in XCTFail("Not an image"); return Data() }, expected)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
func testImageKindAndDimensionsAreValidatedBeforeDecode() throws {
|
|
169
|
+
let png = Data([0x89, 0x50, 0x4E, 0x47, 13, 10, 26, 10]) + Data(count: 24)
|
|
170
|
+
XCTAssertEqual(try NimoCanvasCodec.imageBytes(png.base64EncodedString()), png)
|
|
171
|
+
XCTAssertThrowsError(try NimoCanvasCodec.imageBytes("data:image/bmp;base64," + png.base64EncodedString()))
|
|
172
|
+
XCTAssertThrowsError(try NimoCanvasCodec.imageBytes("data:image/jpeg;base64,AAAA"))
|
|
173
|
+
XCTAssertThrowsError(try NimoCanvasCodec.imageBytes("Qk0"))
|
|
174
|
+
XCTAssertThrowsError(try NimoCanvasImage.source(png.base64EncodedString()))
|
|
175
|
+
XCTAssertThrowsError(try NimoCanvasCodec.imageBytes(String(repeating: "a", count: 2_800_001)))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
func testJpegPixelsAreDecodedForRawAndDataUriInputs() throws {
|
|
179
|
+
let rgba = Data(repeating: 255, count: 16)
|
|
180
|
+
let provider = CGDataProvider(data: rgba as CFData)!
|
|
181
|
+
let image = try XCTUnwrap(CGImage(width: 2, height: 2, bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: 8,
|
|
182
|
+
space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.noneSkipLast.rawValue),
|
|
183
|
+
provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent))
|
|
184
|
+
let bytes = NSMutableData()
|
|
185
|
+
let destination = try XCTUnwrap(CGImageDestinationCreateWithData(bytes, "public.jpeg" as CFString, 1, nil))
|
|
186
|
+
CGImageDestinationAddImage(destination, image, nil)
|
|
187
|
+
XCTAssertTrue(CGImageDestinationFinalize(destination))
|
|
188
|
+
let raw = (bytes as Data).base64EncodedString()
|
|
189
|
+
for input in [raw, "data:image/jpeg;base64," + raw] {
|
|
190
|
+
XCTAssertEqual(try NimoCanvasImage.decode(input, width: 2, height: 2), Data(repeating: 255, count: 4))
|
|
191
|
+
}
|
|
192
|
+
XCTAssertThrowsError(try NimoCanvasImage.source("data:image/png;base64," + raw))
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
func testRasterKeepsTopRowAndLuminanceWithoutInversion() throws {
|
|
196
|
+
// Top row red/green; bottom row blue/white. Match the Android integer luminance formula.
|
|
197
|
+
let rgba = Data([255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255])
|
|
198
|
+
let provider = CGDataProvider(data: rgba as CFData)!
|
|
199
|
+
let image = try XCTUnwrap(CGImage(width: 2, height: 2, bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: 8,
|
|
200
|
+
space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.last.rawValue),
|
|
201
|
+
provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent))
|
|
202
|
+
XCTAssertEqual(try NimoCanvasImage.grayscale(image, width: 2, height: 2), Data([77, 149, 29, 255]))
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -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
|
+
}
|