@mentra/bluetooth-sdk 3.3.0-dev.317 → 3.3.0-dev.320

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 (46) hide show
  1. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +368 -0
  2. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocol.kt +156 -0
  3. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +37 -12
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +30 -13
  5. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  6. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +3 -2
  7. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +257 -395
  8. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +245 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +225 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +168 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +8 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
  14. package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
  15. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +557 -0
  16. package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
  17. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
  18. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +220 -0
  19. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +308 -0
  20. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +270 -0
  21. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
  22. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
  23. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +194 -0
  24. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +199 -0
  25. package/build/generated/releaseMetadata.js +5 -5
  26. package/build/generated/releaseMetadata.js.map +1 -1
  27. package/ios/MentraBluetoothSDK.podspec +3 -2
  28. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  29. package/ios/Source/DeviceManager.swift +23 -10
  30. package/ios/Source/DeviceStore.swift +19 -7
  31. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  32. package/ios/Source/sgcs/G2.swift +2 -2
  33. package/ios/Source/sgcs/Nimo.swift +182 -371
  34. package/ios/Source/sgcs/NimoCanvasCodec.swift +269 -0
  35. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +219 -0
  36. package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
  37. package/ios/Source/sgcs/NimoCanvasSession.swift +150 -0
  38. package/ios/Source/sgcs/SGCManager.swift +16 -0
  39. package/ios/Source/utils/G2Text.swift +11 -0
  40. package/ios/Tests/DashboardRenderingTests.swift +31 -0
  41. package/ios/Tests/G2TextTests.swift +10 -0
  42. package/ios/Tests/NimoCanvasCodecTests.swift +204 -0
  43. package/ios/Tests/NimoCanvasSessionTests.swift +112 -0
  44. package/ios/Tests/NimoCanvasTransportTests.swift +161 -0
  45. package/package.json +2 -2
  46. package/src/generated/releaseMetadata.ts +5 -5
@@ -0,0 +1,220 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import org.junit.Assert.*
4
+ import org.junit.Test
5
+
6
+ class NimoCanvasCodecTest {
7
+ @Test fun imageSourcesAcceptRawAndMatchingDataUris() {
8
+ // ImageIO/BitmapFactory validate the complete raster; this boundary validates file kind and size.
9
+ val png = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 13, 10, 26, 10) + ByteArray(24)
10
+ val bmp = byteArrayOf(0x42, 0x4D) + ByteArray(56)
11
+ val jpeg = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte()) + ByteArray(24)
12
+ for ((mime, bytes) in listOf("png" to png, "bmp" to bmp, "jpeg" to jpeg)) {
13
+ val raw = java.util.Base64.getEncoder().encodeToString(bytes)
14
+ assertArrayEquals(bytes, NimoCanvasCodec.imageBytes(raw))
15
+ assertArrayEquals(bytes, NimoCanvasCodec.imageBytes("data:image/$mime;base64,$raw"))
16
+ val wrongMime = if (mime == "png") "bmp" else "png"
17
+ assertThrows(IllegalArgumentException::class.java) {
18
+ NimoCanvasCodec.imageBytes("data:image/$wrongMime;base64,$raw")
19
+ }
20
+ }
21
+ }
22
+
23
+ @Test fun unsupportedAndOversizedImageSourcesFailBeforeRasterDecoding() {
24
+ val encoder = java.util.Base64.getEncoder()
25
+ assertThrows(IllegalStateException::class.java) { NimoCanvasCodec.imageBytes("data:image/gif;base64,AAAA") }
26
+ assertThrows(IllegalArgumentException::class.java) { NimoCanvasCodec.imageBytes(encoder.encodeToString(ByteArray(54))) }
27
+ assertThrows(IllegalArgumentException::class.java) { NimoCanvasCodec.imageBytes("a".repeat(2_800_001)) }
28
+ assertThrows(IllegalArgumentException::class.java) { NimoCanvasCodec.imageBytes(encoder.encodeToString(ByteArray(2_000_001))) }
29
+ assertThrows(IllegalArgumentException::class.java) { NimoCanvasCodec.imageBytes("not valid base64!") }
30
+ assertThrows(IllegalArgumentException::class.java) { NimoCanvasCodec.imageBytes("Qk0") }
31
+ }
32
+
33
+ @Test fun guideGoldenPacket() {
34
+ val frame = NimoCanvasCodec.replace(listOf(
35
+ NimoCanvasCodec.rectangle(20, 20, 120, 50, 2, 8),
36
+ NimoCanvasCodec.label("Hello Glass", 160, 30, 300, 40, font = 2)
37
+ ))
38
+ val actual = NimoCanvasCodec.frames(4, frame, 512).single()
39
+ val expected = "BF003600F72D000007043200FD000000020001010C0014001400780032000208FF00031900A0001E002C012800000002FF0B0048656C6C6F20476C617373"
40
+ assertEquals(expected, actual.joinToString("") { "%02X".format(it.toInt() and 255) })
41
+ }
42
+
43
+ @Test fun fourTonePolarityAndOddPadding() {
44
+ assertArrayEquals(byteArrayOf(0xE4.toByte(), 0x3F),
45
+ NimoCanvasCodec.pack2(byteArrayOf(0, 85, 170.toByte(), 255.toByte(), 255.toByte())))
46
+ }
47
+
48
+ @Test fun launchAndExitGoldenPackets() {
49
+ fun hex(bytes: ByteArray) = bytes.joinToString("") { "%02X".format(it.toInt() and 255) }
50
+ assertEquals("BF000600F5E4000007010200FD00", hex(NimoCanvasCodec.frames(1, writeCapacity = 512).single()))
51
+ assertEquals("BF00050086E4000007030100FD", hex(NimoCanvasCodec.frames(3, writeCapacity = 512).single()))
52
+ }
53
+
54
+ @Test fun primitivesValidateGeometryAndUseCompactCircle() {
55
+ assertEquals(10, NimoCanvasCodec.line(0, 0, 499, 219).payload.size)
56
+ assertEquals(12, NimoCanvasCodec.rectangle(0, 0, 500, 220).payload.size)
57
+ assertEquals(9, NimoCanvasCodec.circle(10, 10, 10).payload.size)
58
+ assertThrows(IllegalArgumentException::class.java) { NimoCanvasCodec.line(0, 0, 500, 219) }
59
+ assertThrows(IllegalArgumentException::class.java) { NimoCanvasCodec.rectangle(Int.MAX_VALUE, 0, 10, 1) }
60
+ assertThrows(IllegalArgumentException::class.java) { NimoCanvasCodec.rectangle(0, 0, 501, 220) }
61
+ assertThrows(IllegalArgumentException::class.java) { NimoCanvasCodec.circle(0, 10, 10) }
62
+ }
63
+
64
+ @Test fun labelUtf8LengthAndErrors() {
65
+ val label = NimoCanvasCodec.label("😀é", 0, 0, 100, 20)
66
+ assertEquals(6, label.textBytes)
67
+ assertEquals(6, label.payload[12].toInt())
68
+ assertThrows(IllegalArgumentException::class.java) { NimoCanvasCodec.label("a\u0000b", 0, 0, 100, 20) }
69
+ assertThrows(java.nio.charset.CharacterCodingException::class.java) { NimoCanvasCodec.label("\uD800", 0, 0, 100, 20) }
70
+ assertThrows(IllegalArgumentException::class.java) { NimoCanvasCodec.label("é".repeat(1025), 0, 0, 100, 20) }
71
+ assertEquals(2048, NimoCanvasCodec.label("é".repeat(1024), 0, 0, 100, 20).textBytes)
72
+ }
73
+
74
+ @Test fun totalBudgetsAndClear() {
75
+ assertArrayEquals(byteArrayOf(0, 0, 1), NimoCanvasCodec.replace(emptyList()))
76
+ val line = NimoCanvasCodec.line(0, 0, 1, 1)
77
+ NimoCanvasCodec.replace(List(64) { line })
78
+ assertThrows(IllegalArgumentException::class.java) { NimoCanvasCodec.replace(List(65) { line }) }
79
+ val text = NimoCanvasCodec.label("a".repeat(2048), 0, 0, 500, 220)
80
+ NimoCanvasCodec.replace(List(4) { text })
81
+ assertThrows(IllegalArgumentException::class.java) { NimoCanvasCodec.replace(List(5) { text }) }
82
+ val image = NimoCanvasCodec.bitmap(0, 0, 500, 220, ByteArray(110000))
83
+ NimoCanvasCodec.replace(listOf(image))
84
+ assertThrows(IllegalArgumentException::class.java) { NimoCanvasCodec.replace(listOf(image, image)) }
85
+ assertThrows(IllegalArgumentException::class.java) {
86
+ NimoCanvasCodec.replace(listOf(NimoCanvasCodec.Object(4, ByteArray(12278))))
87
+ }
88
+ }
89
+
90
+ @Test fun hostFrameReservationBoundsNativeEncoding() {
91
+ val random = java.util.Random(42)
92
+ for (size in listOf(1, 5, 80, 160, 200)) {
93
+ val gray = ByteArray(size * size).also { random.nextBytes(it) }
94
+ val image = NimoCanvasCodec.bitmap(0, 0, size, size, gray)
95
+ val packed = (gray.size + 3) / 4
96
+ val reserved = 32 + packed + (packed + 126) / 127
97
+ assertTrue(3 + image.payload.size <= reserved)
98
+ }
99
+ // These are the retained prefixes of the host's over-budget regression scenes.
100
+ val rows = NimoCanvasCodec.textRows(List(11) { "row" }.joinToString("\n"), 0, 0, 500, 220)
101
+ assertTrue(NimoCanvasCodec.replace(List(5) { rows }.flatten()).size <= 12280)
102
+ val images = listOf(200, 80).map { size ->
103
+ NimoCanvasCodec.bitmap(0, 0, size, size, ByteArray(size * size).also { random.nextBytes(it) })
104
+ }
105
+ assertTrue(NimoCanvasCodec.replace(images).size <= 12280)
106
+ }
107
+
108
+ @Test fun explicitNewlinesRetainRowsWithoutRewrapping() {
109
+ val rows = NimoCanvasCodec.textRows("first\n\nthird", 12, 15, 400, 100)
110
+ assertEquals(2, rows.size)
111
+ assertEquals(15, rows[0].payload[2].toInt())
112
+ assertEquals(55, rows[1].payload[2].toInt())
113
+ assertEquals(20, rows[1].payload[6].toInt())
114
+ val single = NimoCanvasCodec.textRows("single", 12, 15, 400, 100).single()
115
+ assertEquals(20, single.payload[6].toInt())
116
+ }
117
+
118
+ @Test fun rleBoundaryAndLiteralRoundtrip() {
119
+ fun decode(data: ByteArray): ByteArray {
120
+ val out = java.io.ByteArrayOutputStream(); var i = 0
121
+ while (i < data.size) {
122
+ val tag = data[i++].toInt() and 255
123
+ val count = tag and 127
124
+ assertTrue(count > 0)
125
+ if (tag and 128 != 0) { out.write(data, i, count); i += count }
126
+ else { repeat(count) { out.write(data[i].toInt() and 255) }; i++ }
127
+ }
128
+ return out.toByteArray()
129
+ }
130
+ for (size in listOf(1, 2, 3, 126, 127, 128, 254, 27500)) {
131
+ for (input in listOf(ByteArray(size) { 33 }, ByteArray(size) { (it % 251).toByte() })) {
132
+ assertArrayEquals(input, decode(NimoCanvasCodec.rle(input)))
133
+ }
134
+ }
135
+ assertArrayEquals(byteArrayOf(3, 1, 0x82.toByte(), 2, 3), NimoCanvasCodec.rle(byteArrayOf(1, 1, 1, 2, 3)))
136
+ }
137
+
138
+ @Test fun bitmapIsCompleteCompressed2BppAndUsesExactOddSize() {
139
+ val bitmap = NimoCanvasCodec.bitmap(0, 0, 5, 1, byteArrayOf(0, 85, 170.toByte(), 255.toByte(), 255.toByte()))
140
+ assertEquals(1, bitmap.payload[10].toInt()) // total_chunks
141
+ assertEquals(2, bitmap.payload[19].toInt()) // image bpp
142
+ assertEquals(3, bitmap.payload[20].toInt()) // RLE is smallest here
143
+ assertEquals(2, bitmap.payload[21].toInt()) // original_size ceil(5/4)
144
+ assertArrayEquals(byteArrayOf(0x82.toByte(), 0xE4.toByte(), 0x3F), bitmap.payload.copyOfRange(29, bitmap.payload.size))
145
+ }
146
+
147
+ @Test fun fragmentationAtNegotiatedCapacityReassemblesWholeAppStream() {
148
+ val content = NimoCanvasCodec.replace(listOf(NimoCanvasCodec.label("X".repeat(2048), 0, 0, 500, 220)))
149
+ val baseline = NimoCanvasCodec.frames(4, content, 512).flatMap { it.drop(8) }.toByteArray()
150
+ for (capacity in listOf(20, 64, 185, 244, 512)) {
151
+ val frames = NimoCanvasCodec.frames(4, content, capacity)
152
+ frames.forEachIndexed { index, packet ->
153
+ assertTrue(packet.size <= capacity)
154
+ assertEquals(0, packet[1].toInt())
155
+ val expectedIndex = if (index == frames.lastIndex) 0 else index + 1
156
+ assertEquals(expectedIndex, (packet[6].toInt() and 255) or ((packet[7].toInt() and 255) shl 8))
157
+ assertEquals(NimoCanvasCodec.crc16(packet.copyOfRange(8, packet.size)),
158
+ (packet[4].toInt() and 255) or ((packet[5].toInt() and 255) shl 8))
159
+ }
160
+ assertArrayEquals(baseline, frames.flatMap { it.drop(8) }.toByteArray())
161
+ }
162
+ assertThrows(IllegalArgumentException::class.java) { NimoCanvasCodec.frames(4, content, 19) }
163
+ assertThrows(IllegalArgumentException::class.java) { NimoCanvasCodec.frames(4, ByteArray(12281), 512) }
164
+ }
165
+
166
+ @Test fun strictResponseParserAcceptsStatusOnlyErrors() {
167
+ fun packet(hex: String) = hex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
168
+ val good = packet("BF000900D87900000704050000FD000000")
169
+ assertArrayEquals(byteArrayOf(0, 0xFD.toByte(), 0, 0, 0), NimoCanvasCodec.response(good)!!.second)
170
+ assertNull(NimoCanvasCodec.response(good + 0))
171
+ assertNull(NimoCanvasCodec.response(good.copyOf().also { it[10] = 4 }))
172
+ assertNull(NimoCanvasCodec.response(good.copyOf().also { it[1] = 2 }))
173
+ val body = byteArrayOf(7, 4, 1, 0, 7)
174
+ val crc = NimoCanvasCodec.crc16(body)
175
+ val statusOnly = byteArrayOf(0xBF.toByte(), 0, 5, 0, crc.toByte(), (crc shr 8).toByte(), 0, 0) + body
176
+ assertArrayEquals(byteArrayOf(7), NimoCanvasCodec.response(statusOnly)!!.second)
177
+ }
178
+
179
+ @Test fun sceneCompilerKeepsGeometryOrderAndBorderBeforeText() {
180
+ val text = NimoCanvasCodec.Element("text", 10, 20, 100, 40, "hello", border = 2, radius = 3)
181
+ val rectangle = NimoCanvasCodec.Element("rect", 100, 100, 50, 20)
182
+ val actual = NimoCanvasCodec.scene(listOf(text, rectangle)) { _, _, _ -> error("Not an image") }
183
+ val expected = NimoCanvasCodec.replace(listOf(
184
+ NimoCanvasCodec.rectangle(10, 20, 100, 40, 2, 3),
185
+ NimoCanvasCodec.label("hello", 10, 20, 100, 20),
186
+ NimoCanvasCodec.rectangle(100, 100, 50, 20, 1, 0)
187
+ ))
188
+ assertArrayEquals(expected, actual)
189
+ assertFalse(actual.contentEquals(NimoCanvasCodec.scene(listOf(rectangle, text)) { _, _, _ -> error("Not an image") }))
190
+ assertFalse(actual.contentEquals(NimoCanvasCodec.scene(listOf(text.copy(x = 11), rectangle)) { _, _, _ -> error("Not an image") }))
191
+ }
192
+
193
+ @Test fun scenePreflightsInvalidImageBudgetBeforeDecodingAnyPng() {
194
+ val image = NimoCanvasCodec.Element("image", 0, 0, 500, 220, data = "unused")
195
+ var decoded = 0
196
+ assertThrows(IllegalArgumentException::class.java) {
197
+ NimoCanvasCodec.scene(listOf(image, image)) { _, _, _ -> decoded++; ByteArray(110000) }
198
+ }
199
+ assertEquals(0, decoded)
200
+ assertThrows(IllegalArgumentException::class.java) {
201
+ NimoCanvasCodec.scene(listOf(image.copy(type = "html"))) { _, _, _ -> decoded++; ByteArray(110000) }
202
+ }
203
+ assertEquals(0, decoded)
204
+ }
205
+
206
+ @Test fun zlibCompressesDenseRepeatingImagesAndInflatesToPackedPixels() {
207
+ val gray = ByteArray(32768) { (((it / 17) xor (it / 43)) % 4 * 85).toByte() }
208
+ val bitmap = NimoCanvasCodec.bitmap(0, 0, 256, 128, gray)
209
+ assertEquals(7, bitmap.payload[20].toInt())
210
+ val inflater = java.util.zip.Inflater()
211
+ try {
212
+ inflater.setInput(bitmap.payload.copyOfRange(29, bitmap.payload.size))
213
+ val output = ByteArray((gray.size + 3) / 4)
214
+ assertEquals(output.size, inflater.inflate(output))
215
+ assertTrue(inflater.finished())
216
+ assertArrayEquals(NimoCanvasCodec.pack2(gray), output)
217
+ } finally { inflater.end() }
218
+ NimoCanvasCodec.replace(listOf(bitmap))
219
+ }
220
+ }
@@ -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
+ }