@mentra/bluetooth-sdk 3.2.1-beta.319 → 3.2.1-beta.354

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.
Files changed (83) hide show
  1. package/README.md +1 -1
  2. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +368 -0
  3. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocol.kt +156 -0
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +3 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +4 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +2 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +67 -12
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
  9. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +1 -1
  10. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  11. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +4 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +13 -2
  13. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +33 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/services/G2ConnectionRecovery.kt +47 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +64 -14
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +73 -3
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +257 -395
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +245 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +225 -0
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +168 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
  24. package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
  25. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +557 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
  27. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +71 -0
  28. package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
  29. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2DisplayThreadTest.kt +120 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
  31. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +220 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +308 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +270 -0
  34. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
  35. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
  36. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +194 -0
  37. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +199 -0
  38. package/build/BluetoothSdk.types.d.ts +11 -0
  39. package/build/BluetoothSdk.types.d.ts.map +1 -1
  40. package/build/BluetoothSdk.types.js.map +1 -1
  41. package/build/_private/BluetoothSdkModule.d.ts +2 -0
  42. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  43. package/build/_private/BluetoothSdkModule.js +3 -0
  44. package/build/_private/BluetoothSdkModule.js.map +1 -1
  45. package/build/generated/changelogCatalog.d.ts +1 -1
  46. package/build/generated/changelogCatalog.js +1 -1
  47. package/build/generated/changelogCatalog.js.map +1 -1
  48. package/build/generated/releaseMetadata.js +5 -5
  49. package/build/generated/releaseMetadata.js.map +1 -1
  50. package/build/index.d.ts.map +1 -1
  51. package/build/index.js +1 -0
  52. package/build/index.js.map +1 -1
  53. package/ios/BluetoothSdkModule.swift +5 -0
  54. package/ios/MentraBluetoothSDK.podspec +3 -2
  55. package/ios/Source/BluetoothSdkDefaults.swift +6 -1
  56. package/ios/Source/Bridge.swift +15 -2
  57. package/ios/Source/DeviceManager.swift +23 -10
  58. package/ios/Source/DeviceStore.swift +26 -7
  59. package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
  60. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  61. package/ios/Source/MentraBluetoothSDK.swift +4 -0
  62. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +11 -1
  63. package/ios/Source/sgcs/G2.swift +2 -2
  64. package/ios/Source/sgcs/MentraLive.swift +66 -3
  65. package/ios/Source/sgcs/Nimo.swift +182 -371
  66. package/ios/Source/sgcs/NimoCanvasCodec.swift +269 -0
  67. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +219 -0
  68. package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
  69. package/ios/Source/sgcs/NimoCanvasSession.swift +150 -0
  70. package/ios/Source/sgcs/SGCManager.swift +24 -0
  71. package/ios/Source/utils/G2Text.swift +11 -0
  72. package/ios/Tests/DashboardRenderingTests.swift +31 -0
  73. package/ios/Tests/G2TextTests.swift +10 -0
  74. package/ios/Tests/NimoCanvasCodecTests.swift +204 -0
  75. package/ios/Tests/NimoCanvasSessionTests.swift +112 -0
  76. package/ios/Tests/NimoCanvasTransportTests.swift +161 -0
  77. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
  78. package/package.json +2 -2
  79. package/src/BluetoothSdk.types.ts +11 -0
  80. package/src/_private/BluetoothSdkModule.ts +6 -0
  81. package/src/generated/changelogCatalog.ts +1 -1
  82. package/src/generated/releaseMetadata.ts +5 -5
  83. package/src/index.ts +1 -0
@@ -0,0 +1,308 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import org.junit.Assert.*
4
+ import org.junit.Test
5
+
6
+ class NimoCanvasCoordinatorTest {
7
+ private class Clock : NimoScheduler {
8
+ private data class Job(val time: Long, val task: () -> Unit, var canceled: Boolean = false)
9
+ private var now = 0L
10
+ private val jobs = mutableListOf<Job>()
11
+ override fun post(delayMs: Long, task: () -> Unit): () -> Unit {
12
+ val job = Job(now + delayMs, task)
13
+ jobs += job
14
+ return { job.canceled = true }
15
+ }
16
+ fun advance(ms: Long) {
17
+ val end = now + ms
18
+ while (true) {
19
+ val job = jobs.filter { it.time <= end }.minByOrNull { it.time } ?: break
20
+ jobs.remove(job); now = job.time
21
+ if (!job.canceled) job.task()
22
+ }
23
+ now = end
24
+ }
25
+ }
26
+
27
+ private class Fixture {
28
+ val clock = Clock()
29
+ var connection: Any = Any()
30
+ val characteristic = Any()
31
+ val writes = mutableListOf<ByteArray>()
32
+ val failures = mutableListOf<String>()
33
+ val rejections = mutableListOf<Int>()
34
+ var holdPreemptions = 0
35
+ var rejectWrites = false
36
+ val queue = NimoGattQueue<Any>(clock, { _, bytes -> writes += bytes; !rejectWrites }, { failures += it })
37
+ val canvas = NimoCanvasCoordinator(clock, { 20 },
38
+ { frames, started, completed -> queue.enqueue(connection, characteristic, frames, started, completed) },
39
+ { queue.reset(); failures += it }, { rejections += it }, { holdPreemptions++ })
40
+ init { queue.connected(connection) }
41
+ fun written(success: Boolean = true) {
42
+ queue.written(connection, characteristic, success)
43
+ clock.advance(5)
44
+ }
45
+ fun ack(key: Int, status: Int = 0) = canvas.response(key, if (status != 0) byteArrayOf(status.toByte())
46
+ else if (key == 4) byteArrayOf(0, 0xFD.toByte(), 0, 0, 0) else byteArrayOf(0, 0xFD.toByte()))
47
+ fun launch(frame: ByteArray = byteArrayOf(0, 0, 1)) {
48
+ canvas.offer(frame, "one:1"); canvas.readiness(true)
49
+ written(); ack(1)
50
+ }
51
+ fun finishUpdate(frame: ByteArray) { repeat(NimoCanvasCodec.frames(4, frame, 20).size) { written() }; ack(4) }
52
+ }
53
+
54
+ @Test fun bothNotificationDescriptorsGateCharacteristicWrites() {
55
+ val f = Fixture()
56
+ val rx = Any(); val mic = Any()
57
+ val subscribed = mutableListOf<String>()
58
+ f.queue.subscribe(f.connection, rx, { subscribed += "rx"; true }) {
59
+ f.queue.subscribe(f.connection, mic, { subscribed += "mic"; true }) {}
60
+ }
61
+ f.canvas.offer(byteArrayOf(0, 0, 1), "one"); f.canvas.readiness(true)
62
+ assertTrue(f.writes.isEmpty())
63
+ f.queue.descriptorWritten(f.connection, Any(), true)
64
+ assertTrue(f.writes.isEmpty())
65
+ f.queue.descriptorWritten(f.connection, rx, true)
66
+ assertEquals(listOf("rx", "mic"), subscribed)
67
+ assertTrue(f.writes.isEmpty())
68
+ f.queue.descriptorWritten(f.connection, mic, true)
69
+ assertEquals(1, f.writes.size)
70
+ }
71
+
72
+ @Test fun entireFragmentChainRemainsContiguousAmongOtherCommands() {
73
+ val f = Fixture()
74
+ val frame = ByteArray(85) { it.toByte() }
75
+ f.launch(frame)
76
+ val update = NimoCanvasCodec.frames(4, frame, 20)
77
+ val other = byteArrayOf(42)
78
+ f.queue.enqueue(f.connection, f.characteristic, listOf(other))
79
+ repeat(update.size) { f.written() }
80
+ assertEquals(update.size + 2, f.writes.size)
81
+ update.forEachIndexed { i, bytes -> assertArrayEquals(bytes, f.writes[i + 1]) }
82
+ assertArrayEquals(other, f.writes.last())
83
+ }
84
+
85
+ @Test fun earlyAndDuplicateAcksCannotReleaseAnUnfinishedChain() {
86
+ val f = Fixture()
87
+ val a = ByteArray(60) { it.toByte() }; val b = byteArrayOf(0, 0, 1)
88
+ f.launch(a)
89
+ f.canvas.offer(b, "one:1")
90
+ f.ack(4) // Before the final fragment has started: stale/uncorrelated.
91
+ val fragments = NimoCanvasCodec.frames(4, a, 20).size
92
+ repeat(fragments - 1) { f.written() }
93
+ val before = f.writes.size
94
+ f.ack(4) // Final fragment started, but its callback has not completed.
95
+ f.ack(4, 6) // Duplicate must not replace the first valid early ACK.
96
+ assertEquals(before, f.writes.size)
97
+ f.queue.written(f.connection, f.characteristic, true)
98
+ f.queue.written(f.connection, f.characteristic, true) // Duplicate callback within pacing window.
99
+ assertEquals(before, f.writes.size)
100
+ f.clock.advance(5)
101
+ assertEquals(before + 1, f.writes.size)
102
+ assertTrue(f.rejections.isEmpty())
103
+ f.finishUpdate(b)
104
+ f.ack(4) // Late duplicate with no business command in flight.
105
+ assertTrue(f.failures.isEmpty())
106
+ }
107
+
108
+ @Test fun finalCallbackFailureAndMissingCallbackNeverReleaseNextFragment() {
109
+ for (missing in listOf(false, true)) {
110
+ val f = Fixture(); f.launch(ByteArray(60))
111
+ val before = f.writes.size
112
+ if (missing) f.clock.advance(3_000) else f.written(false)
113
+ assertEquals(before, f.writes.size)
114
+ assertEquals(1, f.failures.size)
115
+ f.queue.written(f.connection, f.characteristic, true)
116
+ f.clock.advance(5)
117
+ assertEquals(before, f.writes.size)
118
+ }
119
+ }
120
+
121
+ @Test fun writeStartRetriesAreBoundedAndDescriptorFailuresReset() {
122
+ val f = Fixture(); f.rejectWrites = true
123
+ f.canvas.offer(byteArrayOf(0, 0, 1), "one"); f.canvas.readiness(true)
124
+ f.clock.advance(200)
125
+ assertEquals(3, f.writes.size)
126
+ assertEquals(1, f.failures.size)
127
+ for (failure in listOf("start", "callback", "missing")) {
128
+ val link = Fixture(); val descriptor = Any()
129
+ link.queue.subscribe(link.connection, descriptor, { failure != "start" }) { fail("Must not become ready") }
130
+ if (failure == "callback") link.queue.descriptorWritten(link.connection, descriptor, false)
131
+ if (failure == "missing") link.clock.advance(5_000)
132
+ assertEquals(1, link.failures.size)
133
+ }
134
+ }
135
+
136
+ @Test fun ackDeadlineResetsAndReplacedConnectionCallbacksAreIgnored() {
137
+ val f = Fixture(); f.launch()
138
+ f.written() // Update is transported, but no business ACK.
139
+ f.clock.advance(45_000)
140
+ assertEquals(1, f.failures.size)
141
+ f.ack(4)
142
+ val old = f.connection
143
+ f.connection = Any(); f.queue.connected(f.connection)
144
+ f.canvas.readiness(true)
145
+ val before = f.writes.size
146
+ f.queue.written(old, f.characteristic, true)
147
+ f.clock.advance(5)
148
+ assertEquals(before, f.writes.size)
149
+ f.written(); f.ack(1); f.finishUpdate(byteArrayOf(0, 0, 1))
150
+ assertEquals(1, f.failures.size)
151
+ }
152
+
153
+ @Test fun rejectedUpdateImmediatelyDrainsDifferentQueuedScene() {
154
+ val f = Fixture(); f.launch()
155
+ f.written()
156
+ val b = byteArrayOf(1, 0, 1)
157
+ f.canvas.offer(b, "one:1")
158
+ val before = f.writes.size
159
+ f.ack(4, 6)
160
+ assertEquals(listOf(6), f.rejections)
161
+ assertEquals(before + 1, f.writes.size)
162
+ assertArrayEquals(NimoCanvasCodec.frames(4, b, 20).first(), f.writes.last())
163
+ }
164
+
165
+ @Test fun confirmedReadinessRelaunchesAfterNotReadyAndPreservesLatestScene() {
166
+ val f = Fixture(); f.launch(); f.written()
167
+ f.ack(4, 7)
168
+ val b = byteArrayOf(1, 0, 1)
169
+ f.canvas.offer(b, "one:1")
170
+ val before = f.writes.size
171
+ f.canvas.readiness(true)
172
+ assertEquals(before, f.writes.size)
173
+ f.canvas.confirmedReadiness(true)
174
+ assertEquals(before + 1, f.writes.size)
175
+ assertArrayEquals(NimoCanvasCodec.frames(1, writeCapacity = 20).single(), f.writes.last())
176
+ f.written(); f.ack(1)
177
+ assertArrayEquals(NimoCanvasCodec.frames(4, b, 20).single(), f.writes.last())
178
+ f.finishUpdate(b)
179
+ assertEquals(listOf(7), f.rejections)
180
+ assertTrue(f.failures.isEmpty())
181
+ }
182
+
183
+ @Test fun newerSceneSurvivesExitReportAndWaitsForExitAck() {
184
+ val f = Fixture(); f.launch(); f.finishUpdate(byteArrayOf(0, 0, 1))
185
+ f.canvas.exit()
186
+ f.canvas.offer(byteArrayOf(1, 0, 1), "two:1")
187
+ assertFalse(f.canvas.nativeApp(0xFD, false))
188
+ f.written()
189
+ val before = f.writes.size
190
+ f.ack(3)
191
+ assertEquals(before + 1, f.writes.size)
192
+ assertArrayEquals(NimoCanvasCodec.frames(1, writeCapacity = 20).single(), f.writes.last())
193
+ }
194
+
195
+ @Test fun actualNativeTakeoverDuringExitInvalidatesEncodingAndDoesNotRelaunch() {
196
+ val f = Fixture(); f.launch(); f.finishUpdate(byteArrayOf(0, 0, 1))
197
+ f.canvas.exit()
198
+ f.canvas.offer(byteArrayOf(1, 0, 1), "two:1")
199
+ assertTrue(f.canvas.nativeApp(0, true))
200
+ f.written()
201
+ val before = f.writes.size
202
+ f.ack(3)
203
+ assertEquals(before, f.writes.size)
204
+ assertTrue(f.failures.isEmpty())
205
+ }
206
+
207
+ @Test fun diagnosticHoldWaitsForCurrentBusinessAckAndThenResumesLatestScene() {
208
+ val f = Fixture()
209
+ val initial = byteArrayOf(0, 0, 1)
210
+ val latest = byteArrayOf(2, 0, 1)
211
+ f.launch(initial)
212
+ var ready = 0
213
+ f.canvas.hold { ready++ }
214
+ f.canvas.offer(byteArrayOf(1, 0, 1), "one:1")
215
+ f.canvas.offer(latest, "one:1")
216
+
217
+ assertEquals(0, ready)
218
+ f.finishUpdate(initial)
219
+ assertEquals(1, ready)
220
+ val beforeRelease = f.writes.size
221
+
222
+ f.canvas.releaseHold()
223
+
224
+ assertEquals(beforeRelease + 1, f.writes.size)
225
+ assertArrayEquals(NimoCanvasCodec.frames(4, latest, 20).first(), f.writes.last())
226
+ }
227
+
228
+ @Test fun scopeTakeoverPreemptsHoldWithoutFlushingHeldScene() {
229
+ val f = Fixture()
230
+ val initial = byteArrayOf(0, 0, 1)
231
+ val held = byteArrayOf(1, 0, 1)
232
+ val takeover = byteArrayOf(2, 0, 1)
233
+ f.launch(initial); f.finishUpdate(initial)
234
+ var ready = 0
235
+ f.canvas.hold { ready++ }
236
+ f.canvas.offer(held, "one:1")
237
+ val beforeTakeover = f.writes.size
238
+
239
+ f.canvas.offer(takeover, "two:1")
240
+
241
+ assertEquals(1, ready)
242
+ assertEquals(1, f.holdPreemptions)
243
+ assertEquals(beforeTakeover + 1, f.writes.size)
244
+ assertArrayEquals(NimoCanvasCodec.frames(4, takeover, 20).first(), f.writes.last())
245
+ }
246
+
247
+ @Test fun firstScopePreemptsHoldAcquiredBeforeAnyCanvasScene() {
248
+ val f = Fixture()
249
+ var ready = 0
250
+ f.canvas.readiness(true)
251
+ f.canvas.hold { ready++ }
252
+ assertEquals(1, ready)
253
+
254
+ f.canvas.offer(byteArrayOf(0, 0, 1), "first:1")
255
+
256
+ assertEquals(1, f.holdPreemptions)
257
+ assertEquals(1, f.writes.size)
258
+ assertArrayEquals(NimoCanvasCodec.frames(1, writeCapacity = 20).single(), f.writes.single())
259
+ }
260
+
261
+ @Test fun reentrantCompletionCannotReleaseNewConnectionWrite() {
262
+ val f = Fixture(); val old = f.connection
263
+ f.queue.enqueue(old, f.characteristic, listOf(byteArrayOf(1)), completed = {
264
+ f.queue.reset()
265
+ f.connection = Any(); f.queue.connected(f.connection)
266
+ f.queue.enqueue(f.connection, f.characteristic, listOf(byteArrayOf(2), byteArrayOf(3)))
267
+ })
268
+ f.written()
269
+ assertEquals(2, f.writes.size)
270
+ f.queue.written(old, f.characteristic, true)
271
+ f.clock.advance(5)
272
+ assertEquals(2, f.writes.size)
273
+ f.written()
274
+ assertEquals(3, f.writes.size)
275
+ assertArrayEquals(byteArrayOf(3), f.writes.last())
276
+ }
277
+
278
+ @Test fun staleEncodeAfterNewSceneExitDisconnectOrDisposalCannotDeliver() {
279
+ for (invalidate in listOf("new", "exit", "disconnect", "dispose")) {
280
+ val main = Clock(); val worker = Clock()
281
+ val delivered = mutableListOf<Int>(); var releases = 0; var stopped = 0
282
+ val encoder = NimoCanvasEncoder(main, worker, { stopped++ }, { throw it })
283
+ encoder.submit({ byteArrayOf(1) }, { releases++ }) { delivered += it[0].toInt() }
284
+ worker.advance(0) // Completed encoding is now pending on Main.
285
+ when (invalidate) {
286
+ "new" -> encoder.submit({ byteArrayOf(2) }, { releases++ }) { delivered += it[0].toInt() }
287
+ "dispose" -> encoder.close()
288
+ else -> encoder.invalidate()
289
+ }
290
+ worker.advance(0); main.advance(0)
291
+ assertEquals(if (invalidate == "new") listOf(2) else emptyList(), delivered)
292
+ encoder.close(); encoder.close()
293
+ assertEquals(1, stopped)
294
+ assertEquals(if (invalidate == "new") 2 else 1, releases)
295
+ }
296
+ }
297
+
298
+ @Test fun canceledAndDisposedBitmapSourcesAreReleasedExactlyOnce() {
299
+ val main = Clock(); val worker = Clock(); var encoded = 0; var released = 0
300
+ val encoder = NimoCanvasEncoder(main, worker, {}, { throw it })
301
+ encoder.submit({ encoded++; byteArrayOf(1) }, { released++ }) { fail("Canceled work delivered") }
302
+ encoder.invalidate(); worker.advance(0); main.advance(0)
303
+ assertEquals(0, encoded); assertEquals(1, released)
304
+ encoder.close()
305
+ assertFalse(encoder.submit({ encoded++; byteArrayOf(2) }, { released++ }) {})
306
+ assertEquals(0, encoded); assertEquals(2, released)
307
+ }
308
+ }
@@ -0,0 +1,270 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import org.junit.Assert.*
4
+ import org.junit.Test
5
+
6
+ class NimoCanvasSessionTest {
7
+ private val frameA = byteArrayOf(0, 0, 1)
8
+ private val frameB = byteArrayOf(1, 0, 1, 1)
9
+ private val frameC = byteArrayOf(2, 0, 1, 2)
10
+ private fun send(actions: List<NimoCanvasSession.Action>): NimoCanvasSession.Action.Send =
11
+ actions.single() as NimoCanvasSession.Action.Send
12
+ private fun success(session: NimoCanvasSession, key: Int) = session.response(key,
13
+ if (key == 4) byteArrayOf(0, 0xFD.toByte(), 0, 0, 0) else byteArrayOf(0, 0xFD.toByte()))
14
+ private fun launched(session: NimoCanvasSession): NimoCanvasSession.Action.Send {
15
+ assertTrue(session.offer(frameA, "one:1").isEmpty())
16
+ assertEquals(1, send(session.readiness(true)).key)
17
+ return send(success(session, 1))
18
+ }
19
+
20
+ @Test fun launchAckPrecedesUpdateAndLatestOfferWins() {
21
+ val session = NimoCanvasSession()
22
+ session.readiness(true)
23
+ assertEquals(1, send(session.offer(frameA, "one:1")).key)
24
+ assertTrue(session.offer(frameB, "one:1").isEmpty())
25
+ assertTrue(session.offer(frameC, "one:1").isEmpty())
26
+ val update = send(success(session, 1))
27
+ assertEquals(4, update.key)
28
+ assertArrayEquals(frameC, update.frame)
29
+ }
30
+
31
+ @Test fun inflightUpdatesCoalesceAndDedupUsesActualBytes() {
32
+ val session = NimoCanvasSession()
33
+ launched(session)
34
+ session.offer(frameB, "one:1")
35
+ session.offer(frameC, "one:1")
36
+ assertArrayEquals(frameC, send(success(session, 4)).frame)
37
+ assertTrue(success(session, 4).isEmpty())
38
+ assertTrue(session.offer(frameC.copyOf(), "one:1").isEmpty())
39
+ assertArrayEquals(frameB, send(session.offer(frameB, "one:1")).frame)
40
+ }
41
+
42
+ @Test fun dedupDoesNotOverrideReversionWhileUpdateIsInFlight() {
43
+ val session = NimoCanvasSession()
44
+ launched(session); success(session, 4)
45
+ session.offer(frameB, "one:1")
46
+ session.offer(frameA, "one:1")
47
+ assertArrayEquals(frameA, send(success(session, 4)).frame)
48
+ }
49
+
50
+ @Test fun replayEpochAndReconnectForceAcceptedFrameAgain() {
51
+ val session = NimoCanvasSession()
52
+ launched(session); success(session, 4)
53
+ assertEquals(4, send(session.offer(frameA, "one:1", force = true)).key)
54
+ success(session, 4)
55
+ assertEquals(4, send(session.offer(frameA, "one:2")).key)
56
+ success(session, 4)
57
+ session.disconnected()
58
+ assertEquals(1, send(session.readiness(true)).key)
59
+ assertEquals(4, send(success(session, 1)).key)
60
+ }
61
+
62
+ @Test fun malformedSuccessAndWrongCommandCannotAcknowledgeLaunch() {
63
+ val session = NimoCanvasSession()
64
+ session.offer(frameA, "one:1"); val launch = send(session.readiness(true))
65
+ assertTrue(session.response(1, byteArrayOf(0)).isEmpty())
66
+ assertTrue(session.response(1, byteArrayOf(0, 0x04)).isEmpty())
67
+ assertTrue(session.response(4, byteArrayOf(0, 0xFD.toByte(), 0, 0, 0)).isEmpty())
68
+ assertTrue(session.timeout(launch.ticket).single() is NimoCanvasSession.Action.Reconnect)
69
+ }
70
+
71
+ @Test fun timeoutRequiresFreshTransportBeforeLateAckCanDoAnything() {
72
+ val session = NimoCanvasSession()
73
+ val update = launched(session)
74
+ assertTrue(session.timeout(update.ticket).single() is NimoCanvasSession.Action.Reconnect)
75
+ assertTrue(success(session, 4).isEmpty())
76
+ assertTrue(session.offer(frameB, "one:1").isEmpty())
77
+ session.disconnected()
78
+ assertEquals(1, send(session.readiness(true)).key)
79
+ assertTrue(session.timeout(update.ticket).isEmpty())
80
+ assertArrayEquals(frameB, send(success(session, 1)).frame)
81
+ }
82
+
83
+ @Test fun unsupportedAndInvalidDoNotRetryUnchangedFrame() {
84
+ for (status in listOf(1, 5, 6)) {
85
+ val session = NimoCanvasSession()
86
+ launched(session)
87
+ assertTrue(session.response(4, byteArrayOf(status.toByte())).single() is NimoCanvasSession.Action.Rejected)
88
+ assertTrue(session.offer(frameA, "one:1").isEmpty())
89
+ assertTrue(session.offer(frameA, "one:2", force = true).isEmpty())
90
+ assertArrayEquals(frameB, send(session.offer(frameB, "one:1")).frame)
91
+ }
92
+ }
93
+
94
+ @Test fun failedLaunchDoesNotRetryIdenticalLaunchForChangingCaptions() {
95
+ for (status in listOf(1, 5, 6)) {
96
+ val session = NimoCanvasSession()
97
+ session.offer(frameA, "one:1"); session.readiness(true)
98
+ assertTrue(session.response(1, byteArrayOf(status.toByte())).single() is NimoCanvasSession.Action.Rejected)
99
+ assertTrue(session.offer(frameB, "one:1").isEmpty())
100
+ assertTrue(session.offer(frameC, "one:2", true).isEmpty())
101
+ session.disconnected()
102
+ assertEquals(1, send(session.readiness(true)).key)
103
+ }
104
+ }
105
+
106
+ @Test fun readinessErrorWaitsForExplicitReadySignal() {
107
+ val session = NimoCanvasSession()
108
+ launched(session)
109
+ assertTrue(session.response(4, byteArrayOf(7)).single() is NimoCanvasSession.Action.Rejected)
110
+ assertTrue(session.offer(frameB, "one:1").isEmpty())
111
+ assertTrue(session.readiness(true).isEmpty())
112
+ assertTrue(session.readiness(false).isEmpty())
113
+ assertEquals(1, send(session.readiness(true)).key)
114
+ assertArrayEquals(frameB, send(success(session, 1)).frame)
115
+ }
116
+
117
+ @Test fun freshConfirmedReadinessReleasesNotReadyWithoutFalseEdge() {
118
+ for (key in listOf(1, 4)) {
119
+ val session = NimoCanvasSession()
120
+ session.offer(frameA, "one:1"); session.readiness(true)
121
+ if (key == 4) success(session, 1)
122
+ assertTrue(session.response(key, byteArrayOf(7)).single() is NimoCanvasSession.Action.Rejected)
123
+ assertTrue(session.offer(frameB, "one:1").isEmpty())
124
+ assertTrue("cached true must not release status 7", session.readiness(true).isEmpty())
125
+ assertEquals(1, send(session.confirmedReadiness(true)).key)
126
+ assertArrayEquals(frameB, send(success(session, 1)).frame)
127
+ }
128
+ }
129
+
130
+ @Test fun confirmedNotReadyStaysBlockedUntilConfirmedReady() {
131
+ val session = NimoCanvasSession()
132
+ launched(session)
133
+ session.response(4, byteArrayOf(7))
134
+ assertTrue(session.confirmedReadiness(false).isEmpty())
135
+ assertTrue(session.offer(frameB, "one:1").isEmpty())
136
+ assertEquals(1, send(session.confirmedReadiness(true)).key)
137
+ assertArrayEquals(frameB, send(success(session, 1)).frame)
138
+ }
139
+
140
+ @Test fun repeatedReadyButRejectedLaunchHasBoundedRecovery() {
141
+ val session = NimoCanvasSession()
142
+ session.offer(frameA, "one:1"); session.readiness(true)
143
+ repeat(3) {
144
+ session.response(1, byteArrayOf(7))
145
+ assertEquals(1, send(session.confirmedReadiness(true)).key)
146
+ assertTrue("duplicate heartbeat cannot overlap Launch", session.confirmedReadiness(true).isEmpty())
147
+ }
148
+ session.response(1, byteArrayOf(7))
149
+ repeat(5) { assertTrue("ready-only recovery must stop after three retries", session.confirmedReadiness(true).isEmpty()) }
150
+ session.offer(frameB, "one:2", true)
151
+ assertTrue(session.confirmedReadiness(true).isEmpty())
152
+ session.readiness(false)
153
+ assertEquals(1, send(session.confirmedReadiness(true)).key)
154
+ assertArrayEquals(frameB, send(success(session, 1)).frame)
155
+ success(session, 4)
156
+ session.offer(frameC, "one:2")
157
+ session.response(4, byteArrayOf(7))
158
+ assertEquals(1, send(session.confirmedReadiness(true)).key)
159
+ }
160
+
161
+ @Test fun confirmedReadyDoesNotUnlockRejectedLaunchOrDeviceLock() {
162
+ for (status in listOf(1, 5, 6, 8)) {
163
+ val session = NimoCanvasSession()
164
+ session.offer(frameA, "one:1"); session.readiness(true)
165
+ session.response(1, byteArrayOf(status.toByte()))
166
+ assertTrue(session.offer(frameB, "one:2", true).isEmpty())
167
+ assertTrue(session.confirmedReadiness(false).isEmpty())
168
+ assertTrue(session.confirmedReadiness(true).isEmpty())
169
+ }
170
+ }
171
+
172
+ @Test fun deviceLockedStopsAllCanvasUntilNewTransport() {
173
+ val session = NimoCanvasSession()
174
+ launched(session)
175
+ session.response(4, byteArrayOf(8))
176
+ assertTrue(session.readiness(true).isEmpty())
177
+ assertTrue(session.confirmedReadiness(true).isEmpty())
178
+ assertTrue(session.offer(frameB, "one:2", true).isEmpty())
179
+ session.disconnected()
180
+ assertEquals(1, send(session.readiness(true)).key)
181
+ }
182
+
183
+ @Test fun statusTwoResetsTransportInsteadOfBlindRetry() {
184
+ val session = NimoCanvasSession()
185
+ launched(session)
186
+ val actions = session.response(4, byteArrayOf(2))
187
+ assertEquals(2, actions.size)
188
+ assertTrue(actions.last() is NimoCanvasSession.Action.Reconnect)
189
+ assertTrue(session.offer(frameB, "one:1").isEmpty())
190
+ }
191
+
192
+ @Test fun explicitExitWaitsForCurrentCommandAndDoesNotReplayOldFrame() {
193
+ val session = NimoCanvasSession()
194
+ launched(session)
195
+ assertTrue(session.exit().isEmpty())
196
+ assertEquals(3, send(success(session, 4)).key)
197
+ assertTrue(session.nativeApp(0xFD, false).isEmpty())
198
+ assertTrue(success(session, 3).isEmpty())
199
+ session.disconnected()
200
+ assertTrue(session.readiness(true).isEmpty())
201
+ }
202
+
203
+ @Test fun newSceneDuringExitLaunchesOnlyAfterExitAck() {
204
+ val session = NimoCanvasSession()
205
+ launched(session); success(session, 4)
206
+ assertEquals(3, send(session.exit()).key)
207
+ assertTrue(session.offer(frameB, "two:1").isEmpty())
208
+ assertTrue(session.nativeApp(0xFD, false).isEmpty())
209
+ assertEquals(1, send(success(session, 3)).key)
210
+ assertArrayEquals(frameB, send(success(session, 1)).frame)
211
+ }
212
+
213
+ @Test fun actualNativeTakeoverDuringExitDiscardsNewerScene() {
214
+ val session = NimoCanvasSession()
215
+ launched(session); success(session, 4)
216
+ assertEquals(3, send(session.exit()).key)
217
+ session.offer(frameB, "two:1")
218
+ assertTrue(session.nativeApp(0, true).isEmpty())
219
+ assertTrue(success(session, 3).isEmpty())
220
+ session.disconnected()
221
+ assertTrue(session.readiness(true).isEmpty())
222
+ }
223
+
224
+ @Test fun nativeTakeoverSuppressesReplayUntilNewHostFrame() {
225
+ val session = NimoCanvasSession()
226
+ launched(session); success(session, 4)
227
+ assertTrue(session.nativeApp(0, true).isEmpty())
228
+ assertTrue(session.readiness(true).isEmpty())
229
+ assertEquals(1, send(session.offer(frameA, "one:1")).key)
230
+ }
231
+
232
+ @Test fun nativeTakeoverOrReadinessLossDuringCommandResets() {
233
+ for (takeover in listOf(true, false)) {
234
+ val session = NimoCanvasSession(); launched(session)
235
+ val actions = if (takeover) session.nativeApp(0, true) else session.readiness(false)
236
+ assertTrue(actions.single() is NimoCanvasSession.Action.Reconnect)
237
+ }
238
+ }
239
+
240
+ @Test fun offersCopyMutableInputAndRejectOversizedFrame() {
241
+ val session = NimoCanvasSession()
242
+ val input = frameA.copyOf(); session.offer(input, "one:1"); input[0] = 99
243
+ session.readiness(true)
244
+ assertArrayEquals(frameA, send(success(session, 1)).frame)
245
+ assertThrows(IllegalArgumentException::class.java) { session.offer(ByteArray(12281), "one:1") }
246
+ }
247
+
248
+ @Test fun diagnosticHoldRetainsOnlyLatestSceneUntilReleased() {
249
+ val session = NimoCanvasSession()
250
+ launched(session); success(session, 4)
251
+
252
+ assertTrue(session.hold(true).isEmpty())
253
+ assertTrue(session.offer(frameB, "two:1").isEmpty())
254
+ assertTrue(session.offer(frameC, "two:2", force = true).isEmpty())
255
+
256
+ val update = send(session.hold(false))
257
+ assertEquals(4, update.key)
258
+ assertArrayEquals(frameC, update.frame)
259
+ }
260
+
261
+ @Test fun preemptedHoldDoesNotFlushStaleSceneBeforeExit() {
262
+ val session = NimoCanvasSession()
263
+ launched(session); success(session, 4)
264
+ session.hold(true)
265
+ session.offer(frameB, "two:1")
266
+
267
+ assertTrue(session.hold(false, resume = false).isEmpty())
268
+ assertEquals(3, send(session.exit()).key)
269
+ }
270
+ }