@glassly/bluetooth-sdk 0.1.0-dev.98 → 0.2.8-dev.444

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 (155) hide show
  1. package/README.md +47 -6
  2. package/android/build.gradle +7 -7
  3. package/android/lc3Lib/build.gradle +2 -2
  4. package/android/src/main/assets/G2Firmware/README.md +27 -2
  5. package/android/src/main/assets/G2Firmware/{g2-stock-2.2.9.22.evenota.bin → g2-glassly-cfw-2.3.0.24.evenota.bin} +0 -0
  6. package/android/src/main/assets/G2Firmware/{g2-glassly-cfw-2.2.9.22.evenota.bin → g2-stock-2.3.0.24.evenota.bin} +0 -0
  7. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdk.kt +38 -0
  8. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdkModule.kt +50 -14
  9. package/android/src/main/java/com/glassly/bluetoothsdk/Bridge.kt +18 -0
  10. package/android/src/main/java/com/glassly/bluetoothsdk/DeviceManager.kt +139 -10
  11. package/android/src/main/java/com/glassly/bluetoothsdk/DeviceStore.kt +28 -1
  12. package/android/src/main/java/com/glassly/bluetoothsdk/DisplayPacer.kt +199 -0
  13. package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedChangelogCatalog.kt +10 -1
  14. package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedReleaseMetadata.kt +4 -4
  15. package/android/src/main/java/com/glassly/bluetoothsdk/GlassesNotifications.kt +155 -0
  16. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2.kt +1075 -257
  17. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2BleLink.kt +119 -0
  18. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassification.kt +29 -28
  19. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwRenderer.kt +138 -33
  20. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwScene.kt +673 -0
  21. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCache.kt +235 -162
  22. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTransport.kt +402 -0
  23. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2DisplayDiagnostics.kt +84 -0
  24. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2FlashProtocol.kt +3 -3
  25. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Flasher.kt +9 -9
  26. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Keyboard.kt +689 -0
  27. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardBridge.kt +452 -0
  28. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardHid.kt +208 -0
  29. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2NotificationFile.kt +181 -0
  30. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2RingBattery.kt +20 -0
  31. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Sensors.kt +67 -0
  32. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2TouchInput.kt +39 -0
  33. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraLive.kt +5 -5
  34. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraNex.kt +14 -14
  35. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/{GlasslyBle.java → NexProto.java} +6462 -7618
  36. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/NotificationMessageIds.kt +58 -0
  37. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/SGCManager.kt +77 -2
  38. package/android/src/main/java/com/glassly/bluetoothsdk/speakerid/SpeakerIdEngine.kt +1 -1
  39. package/android/src/main/java/com/glassly/bluetoothsdk/status/DeviceStatus.kt +55 -0
  40. package/android/src/main/java/com/glassly/bluetoothsdk/stt/STTTools.kt +1 -1
  41. package/android/src/main/java/com/glassly/bluetoothsdk/stt/SherpaOnnxTranscriber.kt +1 -1
  42. package/android/src/main/java/com/glassly/bluetoothsdk/tts/TTSTools.kt +1 -1
  43. package/android/src/main/java/com/glassly/bluetoothsdk/utils/NexSGCUtils.kt +24 -149
  44. package/android/src/test/java/com/glassly/bluetoothsdk/DisplayPacerTest.kt +277 -0
  45. package/android/src/test/java/com/glassly/bluetoothsdk/G2BleLinkTest.kt +70 -0
  46. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassificationTest.kt +45 -56
  47. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwRendererTest.kt +78 -5
  48. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwSceneTest.kt +489 -0
  49. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCacheTest.kt +100 -43
  50. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwTransportTest.kt +252 -0
  51. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2DisplayDiagnosticsTest.kt +64 -0
  52. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardTest.kt +271 -0
  53. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2NotificationFileTest.kt +204 -0
  54. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2RingBatteryTest.kt +56 -0
  55. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2SensorsTest.kt +56 -0
  56. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2TouchInputTest.kt +60 -0
  57. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/NexProtoDescriptorTest.kt +32 -0
  58. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/NotificationMessageIdsTest.kt +58 -0
  59. package/build/BluetoothSdk.types.d.ts +209 -20
  60. package/build/BluetoothSdk.types.d.ts.map +1 -1
  61. package/build/BluetoothSdk.types.js.map +1 -1
  62. package/build/_internal.d.ts +1 -1
  63. package/build/_internal.d.ts.map +1 -1
  64. package/build/_internal.js.map +1 -1
  65. package/build/_private/BluetoothSdkModule.d.ts +30 -1
  66. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  67. package/build/_private/BluetoothSdkModule.js.map +1 -1
  68. package/build/generated/changelogCatalog.d.ts +28 -1
  69. package/build/generated/changelogCatalog.d.ts.map +1 -1
  70. package/build/generated/changelogCatalog.js +37 -1
  71. package/build/generated/changelogCatalog.js.map +1 -1
  72. package/build/generated/releaseMetadata.js +4 -4
  73. package/build/generated/releaseMetadata.js.map +1 -1
  74. package/build/index.d.ts +1 -1
  75. package/build/index.d.ts.map +1 -1
  76. package/build/index.js +14 -0
  77. package/build/index.js.map +1 -1
  78. package/ios/BluetoothSdkModule.swift +102 -13
  79. package/ios/GlasslyBluetoothSDK.podspec +1 -1
  80. package/ios/Packages/SherpaOnnx/SherpaOnnx.swift +8 -3
  81. package/ios/Source/BluetoothSDK.swift +53 -0
  82. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  83. package/ios/Source/Bridge.swift +13 -0
  84. package/ios/Source/DeviceManager.swift +151 -33
  85. package/ios/Source/DeviceStore.swift +28 -14
  86. package/ios/Source/DisplayPacer.swift +180 -0
  87. package/ios/Source/GeneratedChangelogCatalog.swift +10 -1
  88. package/ios/Source/GeneratedReleaseMetadata.swift +4 -4
  89. package/ios/Source/GlassesNotifications.swift +181 -0
  90. package/ios/Source/ObservableStore.swift +14 -2
  91. package/ios/Source/Resources/G2Firmware/README.md +27 -2
  92. package/ios/Source/Resources/G2Firmware/{g2-stock-2.2.9.22.evenota.bin → g2-glassly-cfw-2.3.0.24.evenota.bin} +0 -0
  93. package/ios/Source/Resources/G2Firmware/{g2-glassly-cfw-2.2.9.22.evenota.bin → g2-stock-2.3.0.24.evenota.bin} +0 -0
  94. package/ios/Source/controllers/R1.swift +0 -1
  95. package/ios/Source/internal/BluetoothAvailability.swift +31 -0
  96. package/ios/Source/sgcs/G2.swift +1434 -504
  97. package/ios/Source/sgcs/G2AncsRelay.swift +268 -0
  98. package/ios/Source/sgcs/G2BleLink.swift +102 -0
  99. package/ios/Source/sgcs/G2CfwClassification.swift +45 -27
  100. package/ios/Source/sgcs/G2CfwRenderer.swift +166 -33
  101. package/ios/Source/sgcs/G2CfwScene.swift +779 -0
  102. package/ios/Source/sgcs/G2CfwTextureCache.swift +244 -117
  103. package/ios/Source/sgcs/G2CfwTransport.swift +485 -0
  104. package/ios/Source/sgcs/G2DisplayDiagnostics.swift +154 -0
  105. package/ios/Source/sgcs/G2FlashProtocol.swift +2 -2
  106. package/ios/Source/sgcs/G2Flasher.swift +9 -9
  107. package/ios/Source/sgcs/G2Keyboard.swift +780 -0
  108. package/ios/Source/sgcs/G2KeyboardBridge.swift +438 -0
  109. package/ios/Source/sgcs/G2KeyboardHid.swift +290 -0
  110. package/ios/Source/sgcs/G2NotificationTuning.swift +24 -0
  111. package/ios/Source/sgcs/G2RingBattery.swift +22 -0
  112. package/ios/Source/sgcs/G2Sensors.swift +75 -0
  113. package/ios/Source/sgcs/G2TouchInput.swift +51 -0
  114. package/ios/Source/sgcs/G2WritePacing.swift +18 -0
  115. package/ios/Source/sgcs/MentraNex.swift +44 -44
  116. package/ios/Source/sgcs/SGCManager.swift +120 -13
  117. package/ios/Source/sgcs/{glassly_ble.pb.swift → nex_ble.pb.swift} +405 -465
  118. package/ios/Source/speakerid/SpeakerIdEngine.swift +2 -2
  119. package/ios/Source/status/DeviceStatus.swift +27 -0
  120. package/ios/Source/stt/SherpaOnnxTranscriber.swift +15 -14
  121. package/ios/Source/utils/Enums.swift +0 -1
  122. package/ios/Source/utils/G1Text.swift +0 -1
  123. package/ios/Source/utils/G2NotifyFile.swift +20 -10
  124. package/ios/Source/utils/JSCExperiment.swift +6 -6
  125. package/ios/Source/utils/Models.swift +0 -1
  126. package/ios/Tests/BluetoothSDKTests/BluetoothAvailabilityTests.swift +29 -0
  127. package/ios/Tests/BluetoothSDKTests/DisplayPacerTests.swift +255 -0
  128. package/ios/Tests/BluetoothSDKTests/G2AncsRelayTests.swift +186 -0
  129. package/ios/Tests/BluetoothSDKTests/G2BleLinkTests.swift +52 -0
  130. package/ios/Tests/BluetoothSDKTests/G2CfwClassificationTests.swift +46 -51
  131. package/ios/Tests/BluetoothSDKTests/G2CfwRendererTests.swift +164 -5
  132. package/ios/Tests/BluetoothSDKTests/G2CfwSceneTests.swift +497 -0
  133. package/ios/Tests/BluetoothSDKTests/G2CfwTextureCacheTests.swift +108 -44
  134. package/ios/Tests/BluetoothSDKTests/G2CfwTransportTests.swift +259 -0
  135. package/ios/Tests/BluetoothSDKTests/G2DisplayDiagnosticsTests.swift +89 -0
  136. package/ios/Tests/BluetoothSDKTests/G2FlashProtocolTests.swift +3 -2
  137. package/ios/Tests/BluetoothSDKTests/G2ImageAckTests.swift +104 -0
  138. package/ios/Tests/BluetoothSDKTests/G2KeyboardTests.swift +274 -0
  139. package/ios/Tests/BluetoothSDKTests/G2NotifyFileTests.swift +21 -5
  140. package/ios/Tests/BluetoothSDKTests/G2RingBatteryTests.swift +56 -0
  141. package/ios/Tests/BluetoothSDKTests/G2SensorsTests.swift +56 -0
  142. package/ios/Tests/BluetoothSDKTests/G2TouchInputTests.swift +71 -0
  143. package/ios/Tests/BluetoothSDKTests/G2WritePacingTests.swift +35 -0
  144. package/ios/Tests/BluetoothSDKTests/GlassesNotificationTests.swift +95 -0
  145. package/package.json +1 -1
  146. package/plugin/build/withAndroid.js +2 -2
  147. package/plugin/build/withIos.js +1 -1
  148. package/scripts/gen-nex-proto.sh +12 -0
  149. package/scripts/inject-ios-sdk-version.mjs +1 -1
  150. package/src/BluetoothSdk.types.ts +214 -19
  151. package/src/_internal.ts +1 -1
  152. package/src/_private/BluetoothSdkModule.ts +40 -0
  153. package/src/generated/changelogCatalog.ts +37 -1
  154. package/src/generated/releaseMetadata.ts +4 -4
  155. package/src/index.ts +24 -0
@@ -0,0 +1,186 @@
1
+ @testable import BluetoothSDK
2
+ import Foundation
3
+ import XCTest
4
+
5
+ /// Pins the ANCS relay wire format shared with g2flash's patches/ancs_relay.c.
6
+ final class G2AncsRelayTests: XCTestCase {
7
+ private var relay = G2AncsRelay()
8
+
9
+ override func setUp() {
10
+ super.setUp()
11
+ relay = G2AncsRelay()
12
+ }
13
+
14
+ // MARK: - helpers
15
+
16
+ private func le16(_ v: Int) -> [UInt8] { [UInt8(v & 0xFF), UInt8(v >> 8)] }
17
+ private func le32(_ v: UInt32) -> [UInt8] {
18
+ [UInt8(v & 0xFF), UInt8((v >> 8) & 0xFF), UInt8((v >> 16) & 0xFF), UInt8(v >> 24)]
19
+ }
20
+
21
+ private func source(uid: UInt32, event: UInt8 = 0, flags: UInt8 = 0, category: UInt8 = 4, count: UInt8 = 1) -> Data {
22
+ Data([0x41, 0x4E, 1, 1, event, flags, category, count] + le32(uid))
23
+ }
24
+
25
+ private func attr(uid: UInt32, id: UInt8, _ value: String, total: Int? = nil, offset: Int = 0) -> Data {
26
+ let bytes = [UInt8](value.utf8)
27
+ return Data([0x41, 0x4E, 1, 2] + le32(uid) + [id] + le16(total ?? bytes.count) + le16(offset) + bytes)
28
+ }
29
+
30
+ private func app(_ id: String, name: String, total: Int? = nil, offset: Int = 0) -> Data {
31
+ let idBytes = [UInt8](id.utf8), nameBytes = [UInt8](name.utf8)
32
+ return Data([0x41, 0x4E, 1, 3] + le16(total ?? nameBytes.count) + le16(offset) + [UInt8(idBytes.count)] + idBytes + nameBytes)
33
+ }
34
+
35
+ /// Feed a whole notification; returns the event produced by the last attribute.
36
+ private func feedNotification(
37
+ uid: UInt32, flags: UInt8 = 0, app: String = "com.apple.MobileSMS",
38
+ title: String = "Alice", subtitle: String = "", message: String = "hi",
39
+ date: String = "20260902T183000"
40
+ ) -> G2AncsRelay.Event? {
41
+ XCTAssertNil(relay.ingest(source(uid: uid, flags: flags)))
42
+ let values: [(UInt8, String)] = [
43
+ (0, app), (1, title), (2, subtitle), (3, message), (4, "\(message.utf8.count)"),
44
+ (5, date), (6, "Open"), (7, "Clear"),
45
+ ]
46
+ var last: G2AncsRelay.Event?
47
+ for (id, value) in values {
48
+ last = relay.ingest(attr(uid: uid, id: id, value))
49
+ if id != 7 { XCTAssertNil(last, "attribute \(id) completed the notification early") }
50
+ }
51
+ return last
52
+ }
53
+
54
+ // MARK: - encoding
55
+
56
+ func testControlPacketFramesFieldOneHundredSix() {
57
+ XCTAssertEqual(
58
+ [UInt8](G2AncsRelay.controlPacket(op: .enable)),
59
+ [0x08, 0x01, 0x10, 0x00, 0xF2, 0x07, 0x04, 0x41, 0x4E, 0x01, 0x01]
60
+ )
61
+ XCTAssertEqual(
62
+ [UInt8](G2AncsRelay.actionPacket(uid: 0x1234_5678, positive: false)),
63
+ [0x08, 0x01, 0x10, 0x00, 0xF2, 0x07, 0x09, 0x41, 0x4E, 0x01, 0x04, 0x78, 0x56, 0x34, 0x12, 0x01]
64
+ )
65
+ XCTAssertEqual([UInt8](G2AncsRelay.actionPacket(uid: 1, positive: true)).last, 0)
66
+ }
67
+
68
+ // MARK: - reassembly
69
+
70
+ func testNotificationCompletesAfterAllEightAttributes() throws {
71
+ let event = try XCTUnwrap(feedNotification(uid: 42, flags: 0x02, subtitle: "Re: lunch"))
72
+ guard case let .notification(n) = event else { return XCTFail("expected a notification, got \(event)") }
73
+ XCTAssertEqual(n.uid, 42)
74
+ XCTAssertEqual(n.notificationId, "ancs-42")
75
+ XCTAssertEqual(n.event, .added)
76
+ XCTAssertTrue(n.flags.contains(.important))
77
+ XCTAssertFalse(n.flags.contains(.preExisting))
78
+ XCTAssertEqual(n.category, 4)
79
+ XCTAssertEqual(n.appIdentifier, "com.apple.MobileSMS")
80
+ XCTAssertEqual(n.title, "Alice")
81
+ XCTAssertEqual(n.subtitle, "Re: lunch")
82
+ XCTAssertEqual(n.message, "hi")
83
+ XCTAssertEqual(n.date, "20260902T183000")
84
+ XCTAssertEqual(n.positiveActionLabel, "Open")
85
+ XCTAssertEqual(n.negativeActionLabel, "Clear")
86
+ }
87
+
88
+ func testChunkedMessageReassemblesInAnyOrder() throws {
89
+ let message = String(repeating: "abcdefghij", count: 30) // 300 bytes, three firmware chunks
90
+ let bytes = [UInt8](message.utf8)
91
+ XCTAssertNil(relay.ingest(source(uid: 7)))
92
+ for (id, value) in [(0, "com.example"), (1, "t"), (2, ""), (4, "300"), (5, ""), (6, ""), (7, "")] as [(UInt8, String)] {
93
+ XCTAssertNil(relay.ingest(attr(uid: 7, id: id, value)))
94
+ }
95
+ let chunks = [(274, bytes[274...]), (0, bytes[0 ..< 137]), (137, bytes[137 ..< 274])]
96
+ var event: G2AncsRelay.Event?
97
+ for (offset, chunk) in chunks {
98
+ let record = Data([0x41, 0x4E, 1, 2] + le32(7) + [3] + le16(300) + le16(offset) + chunk)
99
+ event = relay.ingest(record)
100
+ }
101
+ guard case let .notification(n) = try XCTUnwrap(event) else { return XCTFail("expected a notification") }
102
+ XCTAssertEqual(n.message, message)
103
+ }
104
+
105
+ func testAttributesWithoutASourceAreIgnored() {
106
+ XCTAssertNil(relay.ingest(attr(uid: 9, id: 1, "orphan")))
107
+ XCTAssertNotNil(feedNotification(uid: 9))
108
+ }
109
+
110
+ func testRemovedReportsTheAppLearnedFromTheNotification() throws {
111
+ _ = feedNotification(uid: 5, app: "com.apple.mobilemail")
112
+ XCTAssertEqual(
113
+ relay.ingest(source(uid: 5, event: 2)),
114
+ .removed(uid: 5, appIdentifier: "com.apple.mobilemail")
115
+ )
116
+ // A second removal (or one for an unknown uid) carries no app.
117
+ XCTAssertEqual(relay.ingest(source(uid: 5, event: 2)), .removed(uid: 5, appIdentifier: nil))
118
+ }
119
+
120
+ func testRemovalDiscardsAPartialReassembly() {
121
+ XCTAssertNil(relay.ingest(source(uid: 3)))
122
+ XCTAssertNil(relay.ingest(attr(uid: 3, id: 0, "com.example")))
123
+ XCTAssertEqual(relay.ingest(source(uid: 3, event: 2)), .removed(uid: 3, appIdentifier: nil))
124
+ XCTAssertNil(relay.ingest(attr(uid: 3, id: 1, "late")))
125
+ }
126
+
127
+ func testAppNameRecordUpdatesTheDirectory() {
128
+ XCTAssertEqual(
129
+ relay.ingest(app("com.apple.MobileSMS", name: "Messages")),
130
+ .appName(appIdentifier: "com.apple.MobileSMS", displayName: "Messages")
131
+ )
132
+ XCTAssertEqual(relay.appNames["com.apple.MobileSMS"], "Messages")
133
+ }
134
+
135
+ func testAppNameChunksReassemble() {
136
+ let name = String(repeating: "N", count: 200)
137
+ let bytes = [UInt8](name.utf8)
138
+ XCTAssertNil(relay.ingest(app("com.example", name: String(decoding: bytes[0 ..< 120], as: UTF8.self), total: 200)))
139
+ XCTAssertEqual(
140
+ relay.ingest(app("com.example", name: String(decoding: bytes[120...], as: UTF8.self), total: 200, offset: 120)),
141
+ .appName(appIdentifier: "com.example", displayName: name)
142
+ )
143
+ }
144
+
145
+ func testStatusDecodes() {
146
+ let record = Data([0x41, 0x4E, 1, 4, 1, 1] + le16(3) + le16(70) + [2])
147
+ XCTAssertEqual(
148
+ relay.ingest(record),
149
+ .status(G2AncsRelay.Status(enabled: true, side: 1, drops: 3, sent: 70, sendErrors: 2))
150
+ )
151
+ }
152
+
153
+ func testUnknownVersionKindAndShortRecordsAreIgnored() {
154
+ XCTAssertNil(relay.ingest(Data([0x41, 0x4E, 2, 1, 0, 0, 0, 0, 1, 0, 0, 0])))
155
+ XCTAssertNil(relay.ingest(Data([0x41, 0x4E, 1, 9])))
156
+ XCTAssertNil(relay.ingest(Data([0x41, 0x4E, 1, 1, 0])))
157
+ XCTAssertNil(relay.ingest(Data([0x41, 0x4E, 1, 4, 1])))
158
+ }
159
+
160
+ func testPruneDropsStaleReassemblies() {
161
+ let start = Date(timeIntervalSince1970: 1000)
162
+ XCTAssertNil(relay.ingest(source(uid: 11), now: start))
163
+ relay.prune(olderThan: 30, now: start.addingTimeInterval(31))
164
+ XCTAssertNil(relay.ingest(attr(uid: 11, id: 0, "com.example")))
165
+ XCTAssertNil(relay.ingest(source(uid: 12), now: start))
166
+ relay.prune(olderThan: 30, now: start.addingTimeInterval(5))
167
+ // Still pending: attributes are accepted and the notification completes.
168
+ for id in UInt8(0) ... 6 { XCTAssertNil(relay.ingest(attr(uid: 12, id: id, "x"))) }
169
+ XCTAssertNotNil(relay.ingest(attr(uid: 12, id: 7, "x")))
170
+ }
171
+
172
+ // MARK: - timestamps
173
+
174
+ func testTimestampParsesLocalAncsDateAndFallsBackToNow() {
175
+ let now = Date(timeIntervalSince1970: 1_700_000_000)
176
+ XCTAssertEqual(G2AncsRelay.timestampMs(date: "", now: now), 1_700_000_000_000)
177
+ XCTAssertEqual(G2AncsRelay.timestampMs(date: "garbage", now: now), 1_700_000_000_000)
178
+ var components = DateComponents()
179
+ components.calendar = Calendar(identifier: .gregorian)
180
+ components.timeZone = .current
181
+ components.year = 2026; components.month = 9; components.day = 2
182
+ components.hour = 18; components.minute = 30; components.second = 0
183
+ let expected = Int64(components.date!.timeIntervalSince1970 * 1000)
184
+ XCTAssertEqual(G2AncsRelay.timestampMs(date: "20260902T183000", now: now), expected)
185
+ }
186
+ }
@@ -0,0 +1,52 @@
1
+ @testable import BluetoothSDK
2
+ import Foundation
3
+ import XCTest
4
+
5
+ /// Pins the BLE link control wire format shared with g2flash's patches/ble_link.c.
6
+ final class G2BleLinkTests: XCTestCase {
7
+ private func le16(_ v: Int) -> [UInt8] {
8
+ [UInt8(v & 0xFF), UInt8(v >> 8)]
9
+ }
10
+
11
+ func testControlPacketFramesField127OnDeviceReceiveInfo() {
12
+ XCTAssertEqual(
13
+ [UInt8](G2BleLink.controlPacket(op: .fast)),
14
+ [0x08, 0x01, 0x10, 0x00, 0xFA, 0x07, 4, 0x42, 0x4C, 1, 1]
15
+ )
16
+ XCTAssertEqual([UInt8](G2BleLink.controlPacket(op: .stock)).suffix(1), [0])
17
+ XCTAssertEqual([UInt8](G2BleLink.controlPacket(op: .query)).suffix(1), [2])
18
+ }
19
+
20
+ func testDecodesFullStatusWithLivePair() {
21
+ let body = Data([0x42, 0x4C, 1, 1, 1, 0xA3, 0xA3] + le16(6) + le16(6) + le16(0) + le16(12) + le16(0))
22
+ let status = G2BleLink.decodeStatus(body)
23
+ XCTAssertEqual(status, G2BleLink.Status(
24
+ fast: true, side: 1, wanted: .fast, applied: .fast,
25
+ requestedMinMs: 7.5, requestedMaxMs: 7.5, requestedLatency: 0,
26
+ liveIntervalMs: 15, liveLatency: 0
27
+ ))
28
+ XCTAssertEqual(status?.summary, "FAST wanted=fast applied=fast requested 7.50-7.50 ms / latency 0 live 15.00 ms / latency 0")
29
+ }
30
+
31
+ func testDecodesThirteenByteStatusWithoutLivePair() {
32
+ let body = Data([0x42, 0x4C, 1, 0, 2, 0xA4, 0] + le16(36) + le16(72) + le16(4))
33
+ let status = G2BleLink.decodeStatus(body)
34
+ XCTAssertEqual(status?.fast, false)
35
+ XCTAssertEqual(status?.side, 2)
36
+ XCTAssertEqual(status?.wanted, .slow)
37
+ XCTAssertEqual(status?.applied, .pending)
38
+ XCTAssertEqual(status?.requestedMinMs, 45)
39
+ XCTAssertEqual(status?.requestedMaxMs, 90)
40
+ XCTAssertEqual(status?.requestedLatency, 4)
41
+ XCTAssertNil(status?.liveIntervalMs)
42
+ XCTAssertNil(status?.liveLatency)
43
+ XCTAssertEqual(status?.summary, "stock wanted=slow applied=pending requested 45.00-90.00 ms / latency 4")
44
+ }
45
+
46
+ func testRejectsForeignShortOrVersionedBodies() {
47
+ XCTAssertNil(G2BleLink.decodeStatus(Data([0x41, 0x4E, 1, 1, 1, 0xA3, 0xA3] + le16(6) + le16(6) + le16(0))))
48
+ XCTAssertNil(G2BleLink.decodeStatus(Data([0x42, 0x4C, 2, 1, 1, 0xA3, 0xA3] + le16(6) + le16(6) + le16(0))))
49
+ XCTAssertNil(G2BleLink.decodeStatus(Data([0x42, 0x4C, 1, 1, 1, 0xA3, 0xA3] + le16(6) + le16(6))))
50
+ XCTAssertEqual(G2BleLink.decodeStatus(Data([0x42, 0x4C, 1, 0, 1, 0x99, 0] + le16(0) + le16(0) + le16(0)))?.wanted, .other(0x99))
51
+ }
52
+ }
@@ -2,10 +2,6 @@
2
2
  import Foundation
3
3
  import XCTest
4
4
 
5
- /// Pins the stock-vs-CFW identity rules that gate the connect banner. A
6
- /// regression here re-opens the stuck "// Glassly connected" bug: text drawn
7
- /// down the stock path before classification, cleared down the CFW path
8
- /// after it, never leaves the glass.
9
5
  final class G2CfwClassificationTests: XCTestCase {
10
6
  private func classify(
11
7
  _ capability: String?, definitive: Bool = false, current: String? = nil
@@ -17,62 +13,61 @@ final class G2CfwClassificationTests: XCTestCase {
17
13
  )
18
14
  }
19
15
 
20
- // MARK: - CFW detection
21
-
22
- func testCfwBannerClassifiesWithFeaturesAndRevision() throws {
23
- let result = try XCTUnwrap(classify("EVENCFW/15 img640 imgz rle directfb font15"))
24
- XCTAssertTrue(result.isCfw)
25
- XCTAssertEqual(result.capabilityString, "EVENCFW/15 img640 imgz rle directfb font15")
26
- XCTAssertEqual(result.features, ["img640", "imgz", "rle", "directfb", "font15"])
27
- XCTAssertEqual(result.protocolVersion, 15)
28
- }
29
-
30
- func testCfwDetectionDoesNotWaitForADefinitiveResponse() throws {
31
- // The banner may ride any settings packet; requiring `definitive`
32
- // would delay classification past the connect banner and re-split
33
- // the draw/clear paths.
34
- let result = try XCTUnwrap(classify("EVENCFW/12 img640", definitive: false))
35
- XCTAssertTrue(result.isCfw)
16
+ func testGlasslyUsesOneContractWithoutRevisionOrTokenNegotiation() throws {
17
+ for banner in ["GLASSLYCFW/24", "GLASSLYCFW/24 shapes36 scene37 anim38", "GLASSLYCFW/25"] {
18
+ let result = try XCTUnwrap(classify(banner))
19
+ XCTAssertEqual(result.family, .glassly)
20
+ XCTAssertTrue(result.isCfw)
21
+ XCTAssertTrue(result.isGlassly)
22
+ XCTAssertEqual(result.displayName, banner == "GLASSLYCFW/25" ? "Glassly CFW v25" : "Glassly CFW v24")
23
+ XCTAssertEqual(result.capabilityString, banner)
24
+ }
36
25
  }
37
26
 
38
- func testCfwBannerTrimsNulTermination() throws {
39
- let result = try XCTUnwrap(classify("EVENCFW/12 img640\0\0"))
40
- XCTAssertEqual(result.capabilityString, "EVENCFW/12 img640")
41
- XCTAssertEqual(result.features, ["img640"])
27
+ func testRevisionIsRetainedIndependentlyOfTheFirmwareFamily() throws {
28
+ XCTAssertEqual(classify("GLASSLYCFW/24 shapes36 scene37 anim38")?.revision, 24)
29
+ XCTAssertEqual(classify("GLASSLYCFW/25")?.revision, 25)
30
+ XCTAssertEqual(classify("EVENCFW/22 img640")?.revision, 22)
31
+ XCTAssertEqual(classify("GLASSLYCFW/24\0\0")?.revision, 24)
32
+ XCTAssertNil(G2CfwClassification.stock.revision)
33
+ for banner in ["GLASSLYCFW/", "GLASSLYCFW/beta", "GLASSLYCFW/0", "GLASSLYCFW/-1"] {
34
+ let result = try XCTUnwrap(classify(banner))
35
+ XCTAssertNil(result.revision)
36
+ XCTAssertTrue(result.isGlassly)
37
+ }
42
38
  }
43
39
 
44
- func testMalformedRevisionFallsBackToZero() throws {
45
- let result = try XCTUnwrap(classify("EVENCFW/beta img640"))
46
- XCTAssertTrue(result.isCfw)
47
- XCTAssertEqual(result.protocolVersion, 0)
40
+ func testEvenCfwIsUpstreamAndNeverGlassly() throws {
41
+ for banner in ["EVENCFW/22 img640 imgz rle font15 ringbat17", "EVENCFW/24 shapes36 scene37 anim38"] {
42
+ let result = try XCTUnwrap(classify(banner))
43
+ XCTAssertEqual(result.family, .upstream)
44
+ XCTAssertTrue(result.isCfw)
45
+ XCTAssertFalse(result.isGlassly)
46
+ XCTAssertEqual(result.displayName, "g2flash CFW")
47
+ }
48
48
  }
49
49
 
50
- // MARK: - Stock classification
51
-
52
- func testDefinitiveAbsenceClassifiesStock() throws {
53
- let result = try XCTUnwrap(classify(nil, definitive: true))
54
- XCTAssertFalse(result.isCfw)
55
- XCTAssertEqual(result, .stock)
50
+ func testBannerTrimsNulTermination() throws {
51
+ let result = try XCTUnwrap(classify("GLASSLYCFW/24\0\0"))
52
+ XCTAssertEqual(result.capabilityString, "GLASSLYCFW/24")
56
53
  }
57
54
 
58
- func testDefinitiveNonCfwPayloadClassifiesStock() throws {
59
- let result = try XCTUnwrap(classify("something-else", definitive: true))
60
- XCTAssertEqual(result, .stock)
61
- }
62
-
63
- // MARK: - Packets that prove nothing
64
-
65
- func testNonDefinitiveAbsenceLeavesConnectionUnclassified() {
66
- // Individual settings packets can legitimately omit field 100 or
67
- // arrive truncated — only a deviceReceiveRequest response is proof.
68
- XCTAssertNil(classify(nil, definitive: false))
69
- XCTAssertNil(classify("garbage", definitive: false))
55
+ func testDefinitiveAbsenceOrUnrecognizedPrefixClassifiesStock() throws {
56
+ for banner: String? in [nil, "something-else", "GLASSLYCFW-invalid", "EVENCFW-invalid"] {
57
+ let result = try XCTUnwrap(classify(banner, definitive: true))
58
+ XCTAssertEqual(result, .stock)
59
+ XCTAssertFalse(result.isCfw)
60
+ XCTAssertFalse(result.isGlassly)
61
+ XCTAssertNil(result.displayName)
62
+ }
70
63
  }
71
64
 
72
- func testDefinitiveAbsenceNeverDowngradesDetectedCfw() {
73
- // Firmware identity is connection-sticky: downgrading would
74
- // reinterpret an installed mode-6 carrier as a stock BMP.
75
- XCTAssertNil(classify(nil, definitive: true, current: "EVENCFW/15 img640"))
76
- XCTAssertNil(classify("garbage", definitive: true, current: "EVENCFW/15 img640"))
65
+ func testPartialSettingsPacketsPreserveTheKnownRenderPath() {
66
+ XCTAssertNil(classify(nil))
67
+ XCTAssertNil(classify("garbage"))
68
+ for banner in ["GLASSLYCFW/24", "EVENCFW/22"] {
69
+ XCTAssertNil(classify(nil, definitive: true, current: banner))
70
+ XCTAssertNil(classify("garbage", definitive: true, current: banner))
71
+ }
77
72
  }
78
73
  }
@@ -5,7 +5,161 @@ import XCTest
5
5
  final class G2CfwRendererTests: XCTestCase {
6
6
  private static let white: UInt8 = 200
7
7
 
8
+ func testGlobalDepthMovesFlatScenesWithoutMiniappDepth() async throws {
9
+ let renderer = G2CfwRenderer()
10
+ let content = frame([rect(x: 100, y: 40, w: 100, h: 50, border: 1)])
11
+ // Start flat, split the retained mirror, change sides, then converge again.
12
+ for (index, setting) in [2, 1, 3, 2].enumerated() {
13
+ let b = boxes(height: 4, depth: G2CfwRenderer.depthLevel(forSetting: setting))
14
+ let result = await renderer.encodeVector(
15
+ content, boxes: b, cacheGeneration: 1, invalidate: index == 0, inlineText: true
16
+ )
17
+ let encoded = try XCTUnwrap(result)
18
+ if setting == 2 {
19
+ XCTAssertEqual(encoded.shows.map(\.lenses), [G2CfwTransport.lensBoth])
20
+ XCTAssertEqual(firstRectX(encoded.shows[0].payload), 132)
21
+ } else {
22
+ XCTAssertEqual(encoded.shows.map(\.lenses), [G2CfwTransport.lensLeft, G2CfwTransport.lensRight])
23
+ XCTAssertEqual(firstRectX(encoded.shows[0].payload), setting == 1 ? 140 : 124)
24
+ XCTAssertEqual(firstRectX(encoded.shows[1].payload), setting == 1 ? 124 : 140)
25
+ }
26
+ }
27
+ }
28
+
29
+ func testElementDepthIsRelativeToEachGlobalLensOrigin() async throws {
30
+ let renderer = G2CfwRenderer()
31
+ let content = SceneElement(
32
+ id: "card", type: "rect", x: 100, y: 40, w: 100, h: 50,
33
+ text: nil, data: nil, border: 1, radius: 0, change: "created", contentHash: "card", depth: 5
34
+ )
35
+ let result = await renderer.encodeVector(
36
+ frame([content]), boxes: boxes(height: 4, depth: G2CfwRenderer.depthLevel(forSetting: 3)),
37
+ cacheGeneration: 1, invalidate: true, inlineText: true
38
+ )
39
+ let encoded = try XCTUnwrap(result)
40
+ XCTAssertEqual(encoded.shows.map(\.lenses), [G2CfwTransport.lensLeft, G2CfwTransport.lensRight])
41
+ XCTAssertEqual(firstRectX(encoded.shows[0].payload), 24 + 100 + 5)
42
+ XCTAssertEqual(firstRectX(encoded.shows[1].payload), 40 + 100 - 5)
43
+ }
44
+
45
+ private func firstRectX(_ payload: Data) -> Int {
46
+ let bytes = [UInt8](payload)
47
+ XCTAssertEqual(bytes[0], 38) // SHOW
48
+ XCTAssertEqual(bytes[7], 1) // one object PUT
49
+ // SHOW header (8), PUT opcode/id/version (9), type/flags/color/width (4).
50
+ return Int(Int16(bitPattern: UInt16(bytes[21]) | UInt16(bytes[22]) << 8))
51
+ }
52
+
53
+ func testGlobalDepthSettingsMapToAllThreeFirmwareLevels() {
54
+ XCTAssertEqual([1, 2, 3].map { G2CfwRenderer.depthLevel(forSetting: $0) }, [0, 1, 2])
55
+ XCTAssertEqual(G2CfwRenderer.depthLevel(forSetting: Int.min), 0)
56
+ XCTAssertEqual(G2CfwRenderer.depthLevel(forSetting: Int.max), 2)
57
+ }
58
+
59
+ func testRasterFallbackBakesTextIntoStereoWindows() async throws {
60
+ let renderer = G2CfwRenderer()
61
+ for font in ["builtin", "mono"] {
62
+ let text = SceneElement(
63
+ id: "text", type: "text", x: 10, y: 10, w: 200, h: 60,
64
+ text: "Depth", data: nil, border: 0, radius: 0,
65
+ change: "created", contentHash: font, font: font, fontSize: 20
66
+ )
67
+ for setting in [1, 3] {
68
+ let b = boxes(height: 4, depth: G2CfwRenderer.depthLevel(forSetting: setting))
69
+ let result = await renderer.encodeUpdate(
70
+ frame([text]), boxes: b, previousRaster: nil, forcedRects: [],
71
+ fids: (1 ... G2CfwRenderer.requiredFidCount).map { UInt16($0) },
72
+ textDraws: [], allowRectCopy: true, blankBoxes: nil, omitBuiltinText: false,
73
+ textureGlyphsEnabled: true, textureImagesEnabled: true
74
+ )
75
+ let update = try XCTUnwrap(result)
76
+ XCTAssertTrue(update.raster.contains { $0 > 0 }, "\(font) text must remain visible")
77
+ let payload = try XCTUnwrap(update.payload)
78
+ XCTAssertEqual(payload[0], 0x83, "a stereo bitmap, without lens-equal text/cache commands")
79
+ XCTAssertEqual(Int(payload[1]) * 4, Int(b.left.x))
80
+ XCTAssertEqual(Int(payload[5]) * 4, Int(b.right.x))
81
+ }
82
+ }
83
+ }
84
+
85
+ func testSustainedHalfBlockFramesStaySmallOnTheObjectCache() async throws {
86
+ let renderer = G2CfwRenderer()
87
+ let b = boxes(height: 4, depth: 1)
88
+ let glyphs: [Character] = [" ", "▀", "▄", "█"]
89
+ var rng: UInt32 = 42
90
+ var uploads = 0
91
+ var largest = 0
92
+ for index in 0 ..< 120 {
93
+ var lines: [String] = []
94
+ for _ in 0 ..< 20 {
95
+ var line = ""
96
+ for _ in 0 ..< 78 {
97
+ rng = rng &* 1_664_525 &+ 1_013_904_223
98
+ line.append(glyphs[Int(rng >> 30)])
99
+ }
100
+ lines.append(line)
101
+ }
102
+ let element = SceneElement(
103
+ id: "badapple", type: "text", x: 0, y: 0, w: 576, h: 288,
104
+ text: lines.joined(separator: "\n"), data: nil, border: 0, radius: 0,
105
+ change: "updated", contentHash: "frame-\(index)", font: "mono", fontSize: 12
106
+ )
107
+ let result = await renderer.encodeVector(
108
+ frame([element]), boxes: b, cacheGeneration: 1, invalidate: index == 0, inlineText: true
109
+ )
110
+ let encoded = try XCTUnwrap(result)
111
+ uploads += encoded.uploads.count
112
+ XCTAssertEqual(encoded.shows.map(\.lenses), [G2CfwTransport.lensBoth], "flat frames are one SHOW for both lenses")
113
+ let payload = try XCTUnwrap(encoded.shows.first?.payload)
114
+ let bytes = payload.count + (index == 0 ? 0 : encoded.uploads.reduce(0) { $0 + $1.count })
115
+ largest = max(largest, bytes)
116
+ XCTAssertLessThan(bytes, 4500, "frame \(index) fell back to image tiles")
117
+ XCTAssertEqual(payload.first, 38)
118
+
119
+ }
120
+ XCTAssertGreaterThan(uploads, 1, "the font goes once, then the changed strings")
121
+ print("120 half-block frames: largest encoded frame = \(largest) bytes, uploads = \(uploads)")
122
+ }
123
+
124
+ func testElementDepthSplitsTheSceneIntoLensSpecificShows() async throws {
125
+ let renderer = G2CfwRenderer()
126
+ let b = boxes(height: 4, depth: 1)
127
+ let content = rect(x: 10, y: 10, w: 100, h: 50, border: 1)
128
+ let card = SceneElement(
129
+ id: "card", type: "rect", x: 200, y: 40, w: 200, h: 120, text: nil, data: nil, border: 2, radius: 8,
130
+ change: "created", contentHash: "card", fill: true, color: 3, depth: 8
131
+ )
132
+ let flatResult = await renderer.encodeVector(frame([content]), boxes: b, cacheGeneration: 1, invalidate: true, inlineText: true)
133
+ let flat = try XCTUnwrap(flatResult)
134
+ XCTAssertEqual(flat.shows.map(\.lenses), [G2CfwTransport.lensBoth], "flat frames are one SHOW for both lenses")
135
+ XCTAssertTrue(flat.changed)
136
+
137
+ let stereoResult = await renderer.encodeVector(frame([content, card]), boxes: b, cacheGeneration: 1, invalidate: false, inlineText: true)
138
+ let stereo = try XCTUnwrap(stereoResult)
139
+ XCTAssertEqual(stereo.shows.map(\.lenses), [G2CfwTransport.lensLeft, G2CfwTransport.lensRight])
140
+ XCTAssertNotEqual(stereo.shows[0].payload, stereo.shows[1].payload)
141
+ XCTAssertEqual(stereo.puts, 4, "the card's fill and frame are defined once per lens; the content is a hit on both")
142
+ XCTAssertEqual(stereo.hits, 2)
143
+
144
+
145
+ let sameResult = await renderer.encodeVector(frame([content, card]), boxes: b, cacheGeneration: 1, invalidate: false, inlineText: true)
146
+ let same = try XCTUnwrap(sameResult)
147
+ XCTAssertFalse(same.changed, "an identical frame changes nothing on either lens")
148
+ XCTAssertEqual(same.shows.count, 2, "but a caller that always sends still gets one SHOW per lens")
149
+
150
+ let backResult = await renderer.encodeVector(frame([content]), boxes: b, cacheGeneration: 1, invalidate: false, inlineText: true)
151
+ let back = try XCTUnwrap(backResult)
152
+ XCTAssertEqual(back.shows.map(\.lenses), [G2CfwTransport.lensBoth], "with the card gone the lenses agree again")
153
+ XCTAssertTrue(back.changed)
154
+
155
+ let resetResult = await renderer.encodeVector(frame([content]), boxes: b, cacheGeneration: 2, invalidate: false, inlineText: true)
156
+ let reset = try XCTUnwrap(resetResult)
157
+ XCTAssertEqual(reset.shows.map(\.lenses), [G2CfwTransport.lensBoth])
158
+ XCTAssertEqual(reset.puts, 1, "a new generation redefines from the shared mirror")
159
+ }
160
+
8
161
  private func rect(x: Int32, y: Int32, w: Int32, h: Int32, border: Int32) -> SceneElement {
162
+
9
163
  SceneElement(
10
164
  id: "r", type: "rect", x: x, y: y, w: w, h: h, text: nil, data: nil,
11
165
  border: border, radius: 0, change: "created", contentHash: ""
@@ -108,7 +262,8 @@ final class G2CfwRendererTests: XCTestCase {
108
262
  let pixels = Data(count: 576 * 288)
109
263
  let payload = try XCTUnwrap(G2CfwRenderer.stereoFrame(pixels: pixels, boxes: b, fid: 0x1234))
110
264
  XCTAssertEqual(Array(payload.prefix(11)), [0x83, 6, 64, 144, 144, 10, 64, 144, 144, 0x34, 0x12])
111
- XCTAssertEqual(payload[11], 0x78) // zlib (RFC 1950) header
265
+ // Plain RLE follows the header (the transport compresses): 576*288 black pixels.
266
+ XCTAssertEqual(Array(payload[11 ..< 15]), [0x00, 0x00, 0xFF, 0xFF])
112
267
  }
113
268
 
114
269
  func testSolidStereoFrameMatchesPixelFrameHeader() throws {
@@ -143,7 +298,8 @@ final class G2CfwRendererTests: XCTestCase {
143
298
  func testBlackKeyframeIsMode6() throws {
144
299
  let payload = try XCTUnwrap(G2CfwRenderer.blackPayload())
145
300
  XCTAssertEqual(payload.first, 6)
146
- XCTAssertEqual(payload[1], 0x78)
301
+ // 307,200 black pixels as RLE: four 65,535 runs and a 45,060 remainder.
302
+ XCTAssertEqual(Array(payload.dropFirst()), [0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0x04, 0xB0])
147
303
  }
148
304
 
149
305
  func testSeededFramePayloadCompositesContentAtLensEqualWindow() throws {
@@ -242,8 +398,10 @@ final class G2CfwRendererTests: XCTestCase {
242
398
  func testVerticalScrollBecomesRectCopyPlusResidue() {
243
399
  let width = 64
244
400
  let height = 96
245
- // Distinct rows of "content" scrolled up by 8, with a fresh bottom row band.
246
- func content(_ row: Int) -> UInt8 { UInt8((row * 7) % 251) }
401
+ /// Distinct rows of "content" scrolled up by 8, with a fresh bottom row band.
402
+ func content(_ row: Int) -> UInt8 {
403
+ UInt8((row * 7) % 251)
404
+ }
247
405
  let prev = raster(width: width, height: height) { px in
248
406
  for y in 0 ..< height {
249
407
  for x in 0 ..< width {
@@ -305,7 +463,8 @@ final class G2CfwRendererTests: XCTestCase {
305
463
  [0x83, UInt8((24 + 100) / 4), UInt8((128 + 50) / 2), 8, 8,
306
464
  UInt8((40 + 100) / 4), UInt8((128 + 50) / 2), 8, 8, 5, 0]
307
465
  )
308
- XCTAssertEqual(payload[11], 0x78)
466
+ // Plain RLE of the 32x16 box follows (the transport compresses).
467
+ XCTAssertEqual(Array(payload[11...]), Array(G2CfwRenderer.rleEncode(G2CfwRenderer.pack4Bpp(Data(count: 32 * 16)))))
309
468
  }
310
469
 
311
470
  func testBoxDeltaFrameRejectsUnalignedRects() {