@mentra/bluetooth-sdk 3.2.1 → 3.3.0-beta.942

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +68 -6
  2. package/android/build.gradle +15 -0
  3. package/android/lc3Lib/build.gradle +6 -1
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +63 -14
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTracker.kt +55 -1
  6. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +1 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkInstallId.kt +23 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +2 -2
  9. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheck.kt +184 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTracker.kt +388 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +4 -1
  12. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +2 -1
  13. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +6 -6
  14. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +223 -36
  15. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +24 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/NetworkDiagnostics.kt +25 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +3 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +4 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscovery.kt +70 -0
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +11 -32
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/LatinTextSanitizer.kt +39 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +93 -18
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +3 -1
  24. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTrackerTest.kt +58 -3
  25. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheckTest.kt +124 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTrackerTest.kt +263 -0
  27. package/android/src/test/java/com/mentra/bluetoothsdk/NetworkDiagnosticsTest.kt +55 -0
  28. package/android/src/test/java/com/mentra/bluetoothsdk/ScanCompletionTest.kt +5 -1
  29. package/android/src/test/java/com/mentra/bluetoothsdk/debug/BleTraceLoggerTest.kt +69 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscoveryTest.kt +124 -0
  31. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +29 -4
  32. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveClassicDisconnectTest.kt +133 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveIncidentDispatchTest.kt +126 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveWifiCredentialLogTest.kt +55 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +20 -7
  36. package/build/BluetoothSdk.types.d.ts +3 -0
  37. package/build/BluetoothSdk.types.d.ts.map +1 -1
  38. package/build/BluetoothSdk.types.js.map +1 -1
  39. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  40. package/build/_private/BluetoothSdkModule.js +2 -1
  41. package/build/_private/BluetoothSdkModule.js.map +1 -1
  42. package/build/_private/MentraLocalNetworkModule.d.ts +23 -0
  43. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
  44. package/build/_private/MentraLocalNetworkModule.js.map +1 -1
  45. package/build/_private/nativeLogConsole.d.ts +19 -0
  46. package/build/_private/nativeLogConsole.d.ts.map +1 -1
  47. package/build/_private/nativeLogConsole.js +12 -0
  48. package/build/_private/nativeLogConsole.js.map +1 -1
  49. package/build/generated/changelogCatalog.d.ts +4 -1
  50. package/build/generated/changelogCatalog.d.ts.map +1 -1
  51. package/build/generated/changelogCatalog.js +5 -1
  52. package/build/generated/changelogCatalog.js.map +1 -1
  53. package/build/generated/releaseMetadata.js +6 -6
  54. package/build/generated/releaseMetadata.js.map +1 -1
  55. package/build/ota-transport/index.d.ts +4 -0
  56. package/build/ota-transport/index.d.ts.map +1 -1
  57. package/build/ota-transport/index.js +2 -0
  58. package/build/ota-transport/index.js.map +1 -1
  59. package/ios/BluetoothSdkModule.swift +8 -4
  60. package/ios/Source/BluetoothSdkDefaults.swift +2 -2
  61. package/ios/Source/DeviceManager.swift +6 -3
  62. package/ios/Source/GeneratedChangelogCatalog.swift +2 -1
  63. package/ios/Source/GeneratedReleaseMetadata.swift +6 -6
  64. package/ios/Source/MentraBluetoothSDK.swift +295 -54
  65. package/ios/Source/internal/BluetoothAvailability.swift +6 -0
  66. package/ios/Source/internal/BluetoothSdkAnalytics.swift +87 -13
  67. package/ios/Source/internal/BluetoothSdkAnalyticsTracker.swift +54 -1
  68. package/ios/Source/internal/BluetoothSdkInstallId.swift +21 -0
  69. package/ios/Source/internal/BluetoothSdkUpdateCheck.swift +223 -0
  70. package/ios/Source/internal/BluetoothSdkUsageTracker.swift +428 -0
  71. package/ios/Source/sgcs/MentraLive.swift +71 -41
  72. package/ios/Source/sgcs/NimoCanvasCodec.swift +4 -1
  73. package/ios/Source/sgcs/SGCManager.swift +1 -1
  74. package/ios/Source/utils/AudioSessionMonitor.swift +44 -8
  75. package/ios/Source/utils/G1Text.swift +2 -24
  76. package/ios/Source/utils/G1TextSanitizer.swift +27 -0
  77. package/ios/Source/utils/LatinTextSanitizer.swift +48 -0
  78. package/ios/Tests/BluetoothAudioRouteTests.swift +26 -0
  79. package/ios/Tests/BluetoothSdkAnalyticsTrackerTests.swift +54 -3
  80. package/ios/Tests/BluetoothSdkUpdateCheckTests.swift +153 -0
  81. package/ios/Tests/BluetoothSdkUsageTrackerTests.swift +296 -0
  82. package/ios/Tests/G1TextSanitizerTests.swift +32 -0
  83. package/ios/Tests/IncidentLogDispatchTests.swift +111 -0
  84. package/ios/Tests/MentraLiveSessionReadinessTests.swift +163 -0
  85. package/ios/Tests/NimoCanvasCodecTests.swift +17 -4
  86. package/ios/Tests/VersionInfoCommandTests.swift +142 -1
  87. package/ios/Tests/WifiCredentialLoggingTests.swift +42 -0
  88. package/package.json +2 -2
  89. package/src/BluetoothSdk.types.ts +1 -0
  90. package/src/_private/BluetoothSdkModule.ts +2 -1
  91. package/src/_private/MentraLocalNetworkModule.ts +28 -0
  92. package/src/_private/nativeLogConsole.ts +19 -0
  93. package/src/generated/changelogCatalog.ts +5 -1
  94. package/src/generated/releaseMetadata.ts +6 -6
  95. package/src/ota-transport/index.ts +10 -1
@@ -0,0 +1,296 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ final class BluetoothSdkUsageTrackerTests: XCTestCase {
5
+ private var tracker = BluetoothSdkUsageTracker()
6
+
7
+ func testApiPhotoReportsItsRequestShapeLatencyAndOutcome() {
8
+ let ok = tracker.photoSettled(
9
+ size: "medium", mode: "photo", compress: "none", save: true, transferMethod: "auto",
10
+ latencyMs: 812, fileSizeBytes: 204_800, errorCode: nil
11
+ )
12
+ XCTAssertEqual(ok.name, "bluetooth_sdk_photo_taken")
13
+ XCTAssertEqual(ok.properties["trigger"] as? String, "api")
14
+ XCTAssertEqual(ok.properties["success"] as? Bool, true)
15
+ XCTAssertEqual(ok.properties["latency_ms"] as? Int64, 812)
16
+ XCTAssertEqual(ok.properties["photo_size"] as? String, "medium")
17
+ XCTAssertEqual(ok.properties["save_to_gallery"] as? Bool, true)
18
+ XCTAssertEqual(ok.properties["transfer_method"] as? String, "auto")
19
+ XCTAssertEqual(ok.properties["file_size_bytes"] as? Int64, 204_800)
20
+ XCTAssertNil(ok.properties["error_code"])
21
+
22
+ let failed = tracker.photoSettled(
23
+ size: "max", mode: "photo", compress: "none", save: false, transferMethod: "ble",
24
+ latencyMs: 30000, fileSizeBytes: nil, errorCode: "request_timeout"
25
+ )
26
+ XCTAssertEqual(failed.properties["success"] as? Bool, false)
27
+ XCTAssertEqual(failed.properties["error_code"] as? String, "request_timeout")
28
+ XCTAssertNil(failed.properties["file_size_bytes"])
29
+ }
30
+
31
+ func testFailedButtonPhotoIsReportedAsButtonTrigger() {
32
+ let event = tracker.buttonPhotoFailed(errorCode: "CAMERA_BUSY")
33
+ XCTAssertEqual(event.properties["trigger"] as? String, "button")
34
+ XCTAssertEqual(event.properties["success"] as? Bool, false)
35
+ XCTAssertEqual(event.properties["error_code"] as? String, "CAMERA_BUSY")
36
+ XCTAssertEqual(tracker.buttonPhotoFailed(errorCode: nil).properties["error_code"] as? String, "photo_request_failed")
37
+ }
38
+
39
+ func testApiRecordingReportsLengthParametersAndAppStopOnce() throws {
40
+ tracker.videoRequested(requestId: "rec-1", width: 1920, height: 1080, fps: 30, save: true, maxDurationMinutes: 0)
41
+ XCTAssertNil(tracker.videoStatus(requestId: "rec-1", status: "recording_started", success: true, nowMs: 1000))
42
+ // A duplicate start does not move the start time.
43
+ XCTAssertNil(tracker.videoStatus(requestId: "rec-1", status: "recording_started", success: true, nowMs: 2000))
44
+ tracker.videoStopRequested(requestId: "rec-1")
45
+
46
+ let stopped = try XCTUnwrap(tracker.videoStatus(requestId: "rec-1", status: "recording_stopped", success: true, nowMs: 61000))
47
+ XCTAssertEqual(stopped.name, "bluetooth_sdk_video_recorded")
48
+ XCTAssertEqual(stopped.properties["trigger"] as? String, "api")
49
+ XCTAssertEqual(stopped.properties["success"] as? Bool, true)
50
+ XCTAssertEqual(stopped.properties["duration_ms"] as? Int64, 60000)
51
+ XCTAssertEqual(stopped.properties["width"] as? Int, 1920)
52
+ XCTAssertEqual(stopped.properties["fps"] as? Int, 30)
53
+ XCTAssertEqual(stopped.properties["save_to_gallery"] as? Bool, true)
54
+ XCTAssertEqual(stopped.properties["stopped_by_app"] as? Bool, true)
55
+ XCTAssertNil(stopped.properties["max_duration_minutes"])
56
+
57
+ XCTAssertNil(tracker.videoStatus(requestId: "rec-1", status: "recording_stopped", success: true, nowMs: 62000))
58
+ }
59
+
60
+ func testButtonRecordingIsReportedWithButtonTriggerAndNoAppStop() throws {
61
+ let id = "local_video_1728000000_ab12"
62
+ _ = tracker.videoStatus(requestId: id, status: "recording_started", success: true, nowMs: 0)
63
+ let stopped = try XCTUnwrap(tracker.videoStatus(requestId: id, status: "recording_stopped", success: true, nowMs: 5000))
64
+ XCTAssertEqual(stopped.properties["trigger"] as? String, "button")
65
+ XCTAssertEqual(stopped.properties["duration_ms"] as? Int64, 5000)
66
+ XCTAssertEqual(stopped.properties["stopped_by_app"] as? Bool, false)
67
+ }
68
+
69
+ func testRecordingFailureIsReportedButNotRecordingIsNotARecording() throws {
70
+ tracker.videoRequested(requestId: "rec-2", width: 0, height: 0, fps: 0, save: false, maxDurationMinutes: 5)
71
+ let failed = try XCTUnwrap(tracker.videoStatus(requestId: "rec-2", status: "battery_low", success: false, nowMs: 0))
72
+ XCTAssertEqual(failed.properties["success"] as? Bool, false)
73
+ XCTAssertEqual(failed.properties["error_code"] as? String, "battery_low")
74
+ XCTAssertEqual(failed.properties["max_duration_minutes"] as? Int, 5)
75
+ XCTAssertNil(failed.properties["duration_ms"])
76
+ XCTAssertNil(failed.properties["width"])
77
+
78
+ XCTAssertNil(tracker.videoStatus(requestId: "rec-3", status: "not_recording", success: false, nowMs: 0))
79
+ XCTAssertNil(tracker.videoStatus(requestId: "", status: "recording_stopped", success: true, nowMs: 0))
80
+ }
81
+
82
+ func testStreamIsReportedAtStartAndOnceAtItsTerminalStatus() throws {
83
+ XCTAssertNil(tracker.streamRequested(streamId: "s1", streamUrl: "rtmps://live.example.com/app/secret-key", nowMs: 6600))
84
+ // The first live status can arrive before startStream returns.
85
+ XCTAssertNil(stream("streaming", nowMs: 10000))
86
+ let started = tracker.streamStartSettled(
87
+ streamUrl: "rtmps://live.example.com/app/secret-key",
88
+ width: 1280,
89
+ height: 720,
90
+ fps: 30,
91
+ bitrate: 2_000_000,
92
+ captureAudio: true,
93
+ glassesNetwork: "wifi",
94
+ latencyMs: 3400,
95
+ errorCode: nil
96
+ )
97
+ XCTAssertEqual(started.name, "bluetooth_sdk_stream_started")
98
+ XCTAssertEqual(started.properties["success"] as? Bool, true)
99
+ XCTAssertEqual(started.properties["protocol"] as? String, "rtmp")
100
+ XCTAssertEqual(started.properties["glasses_network"] as? String, "wifi")
101
+ XCTAssertFalse(started.properties.values.contains { "\($0)".contains("secret-key") })
102
+
103
+ XCTAssertNil(stream("reconnecting", reconnectAttempt: 1, nowMs: 20000))
104
+ XCTAssertNil(stream("reconnected", reconnectAttempt: 2, nowMs: 21000))
105
+ XCTAssertNil(stream("streaming", bitrate: 1_800_000, droppedFrames: 12, nowMs: 30000))
106
+ // Statuses for another stream id do not touch this one.
107
+ XCTAssertNil(tracker.streamStatus(
108
+ streamId: "other", state: "stopped", terminal: true, reconnectAttempt: nil, bitrate: nil, fps: nil,
109
+ droppedFrames: nil, temperatureC: nil, errorDetails: nil, nowMs: 31000
110
+ ))
111
+ tracker.streamStopRequested()
112
+
113
+ let ended = try XCTUnwrap(stream("stopped", terminal: true, nowMs: 70000))
114
+ XCTAssertEqual(ended.name, "bluetooth_sdk_stream_ended")
115
+ XCTAssertEqual(ended.properties["protocol"] as? String, "rtmp")
116
+ XCTAssertEqual(ended.properties["duration_ms"] as? Int64, 60000)
117
+ XCTAssertEqual(ended.properties["end_state"] as? String, "stopped")
118
+ XCTAssertEqual(ended.properties["stopped_by_app"] as? Bool, true)
119
+ XCTAssertEqual(ended.properties["reconnect_attempts"] as? Int, 2)
120
+ XCTAssertEqual(ended.properties["bitrate"] as? Int64, 1_800_000)
121
+ XCTAssertEqual(ended.properties["dropped_frames"] as? Int64, 12)
122
+
123
+ XCTAssertNil(stream("stopped", terminal: true, nowMs: 71000))
124
+ }
125
+
126
+ func testStreamThatEndsRightAfterGoingLiveIsStillReported() throws {
127
+ _ = tracker.streamRequested(streamId: "s1", streamUrl: "rtmp://host/app", nowMs: 0)
128
+ XCTAssertNil(stream("streaming", nowMs: 1000))
129
+ let ended = try XCTUnwrap(stream("error", terminal: true, nowMs: 1500))
130
+ XCTAssertEqual(ended.properties["duration_ms"] as? Int64, 500)
131
+ XCTAssertEqual(ended.properties["end_state"] as? String, "error")
132
+ }
133
+
134
+ func testNewStartReportsTheLiveStreamItReplaces() throws {
135
+ _ = tracker.streamRequested(streamId: "s1", streamUrl: "rtmp://host/app", nowMs: 0)
136
+ _ = stream("streaming", nowMs: 1000)
137
+ let replaced = try XCTUnwrap(tracker.streamRequested(streamId: "s2", streamUrl: "srt://host:9000", nowMs: 11000))
138
+ XCTAssertEqual(replaced.name, "bluetooth_sdk_stream_ended")
139
+ XCTAssertEqual(replaced.properties["protocol"] as? String, "rtmp")
140
+ XCTAssertEqual(replaced.properties["end_state"] as? String, "replaced")
141
+ XCTAssertEqual(replaced.properties["duration_ms"] as? Int64, 10000)
142
+ // The replaced stream's own late terminal status is not reported again.
143
+ XCTAssertNil(stream("stopped", terminal: true, nowMs: 12000))
144
+ XCTAssertNil(tracker.streamStatus(
145
+ streamId: "s2", state: "streaming", terminal: nil, reconnectAttempt: nil, bitrate: nil, fps: nil,
146
+ droppedFrames: nil, temperatureC: nil, errorDetails: nil, nowMs: 12000
147
+ ))
148
+ let second = try XCTUnwrap(tracker.streamStatus(
149
+ streamId: "s2", state: "stopped", terminal: true, reconnectAttempt: nil, bitrate: nil, fps: nil,
150
+ droppedFrames: nil, temperatureC: nil, errorDetails: nil, nowMs: 20000
151
+ ))
152
+ XCTAssertEqual(second.properties["protocol"] as? String, "srt")
153
+ XCTAssertEqual(second.properties["duration_ms"] as? Int64, 8000)
154
+ }
155
+
156
+ func testOlderGlassesEndAStreamOnStoppedAndErrorDetailsLoseTheirUrls() throws {
157
+ _ = tracker.streamRequested(streamId: "s2", streamUrl: "https://whip.example.com/whip/abc", nowMs: 0)
158
+ XCTAssertNil(tracker.streamStatus(
159
+ streamId: nil, state: "streaming", terminal: nil, reconnectAttempt: nil, bitrate: nil, fps: nil,
160
+ droppedFrames: nil, temperatureC: nil, errorDetails: nil, nowMs: 500
161
+ ))
162
+ XCTAssertNil(tracker.streamStatus(
163
+ streamId: nil, state: "error", terminal: nil, reconnectAttempt: nil, bitrate: nil, fps: nil,
164
+ droppedFrames: nil, temperatureC: nil, errorDetails: nil, nowMs: 1000
165
+ ))
166
+ let ended = try XCTUnwrap(tracker.streamStatus(
167
+ streamId: nil, state: "reconnect_failed", terminal: nil, reconnectAttempt: nil, bitrate: nil, fps: nil,
168
+ droppedFrames: nil, temperatureC: nil, errorDetails: "connect to https://whip.example.com/whip/abc failed",
169
+ nowMs: 9000
170
+ ))
171
+ XCTAssertEqual(ended.properties["protocol"] as? String, "whip")
172
+ XCTAssertEqual(ended.properties["end_state"] as? String, "reconnect_failed")
173
+ XCTAssertEqual(ended.properties["error_code"] as? String, "connect to <url> failed")
174
+ XCTAssertEqual(ended.properties["stopped_by_app"] as? Bool, false)
175
+ }
176
+
177
+ func testStreamThatNeverWentLiveEndsWithoutASecondEvent() {
178
+ _ = tracker.streamRequested(streamId: "s3", streamUrl: "srt://host:9000", nowMs: 0)
179
+ let failed = tracker.streamStartSettled(
180
+ streamUrl: "srt://host:9000", width: nil, height: nil, fps: nil, bitrate: nil,
181
+ captureAudio: true, glassesNetwork: "none", latencyMs: 30000, errorCode: "stream_start_failed"
182
+ )
183
+ XCTAssertEqual(failed.properties["success"] as? Bool, false)
184
+ XCTAssertEqual(failed.properties["error_code"] as? String, "stream_start_failed")
185
+ XCTAssertEqual(failed.properties["protocol"] as? String, "srt")
186
+ XCTAssertNil(tracker.streamStatus(
187
+ streamId: "s3", state: "stopped", terminal: true, reconnectAttempt: nil, bitrate: nil, fps: nil,
188
+ droppedFrames: nil, temperatureC: nil, errorDetails: nil, nowMs: 1000
189
+ ))
190
+ // Nothing live was replaced either.
191
+ XCTAssertNil(tracker.streamRequested(streamId: "s4", streamUrl: "srt://host:9000", nowMs: 2000))
192
+ }
193
+
194
+ func testStreamProtocolFollowsTheGlassesSchemeMapping() {
195
+ XCTAssertEqual(BluetoothSdkUsageTracker.streamProtocol("rtmp://a"), "rtmp")
196
+ XCTAssertEqual(BluetoothSdkUsageTracker.streamProtocol("RTMPS://a"), "rtmp")
197
+ XCTAssertEqual(BluetoothSdkUsageTracker.streamProtocol("srt://a"), "srt")
198
+ XCTAssertEqual(BluetoothSdkUsageTracker.streamProtocol("http://a"), "whip")
199
+ XCTAssertEqual(BluetoothSdkUsageTracker.streamProtocol("rtmpt://a"), "unknown")
200
+ XCTAssertEqual(BluetoothSdkUsageTracker.streamProtocol("not a url"), "unknown")
201
+ }
202
+
203
+ func testMicSessionEndsWhenTheSourceTurnsOffOrChanges() throws {
204
+ XCTAssertNil(tracker.observeMic(currentMic: "", transcribing: false, nowMs: 0))
205
+ XCTAssertNil(tracker.observeMic(currentMic: "glasses", transcribing: false, nowMs: 1000))
206
+ XCTAssertNil(tracker.observeMic(currentMic: "glasses", transcribing: true, nowMs: 2000))
207
+
208
+ let switched = try XCTUnwrap(tracker.observeMic(currentMic: "phone", transcribing: false, nowMs: 11000))
209
+ XCTAssertEqual(switched.name, "bluetooth_sdk_mic_session")
210
+ XCTAssertEqual(switched.properties["mic_source"] as? String, "glasses")
211
+ XCTAssertEqual(switched.properties["duration_ms"] as? Int64, 10000)
212
+ XCTAssertEqual(switched.properties["local_transcription"] as? Bool, true)
213
+
214
+ let off = try XCTUnwrap(tracker.observeMic(currentMic: "", transcribing: false, nowMs: 14000))
215
+ XCTAssertEqual(off.properties["mic_source"] as? String, "phone")
216
+ XCTAssertEqual(off.properties["duration_ms"] as? Int64, 3000)
217
+ XCTAssertEqual(off.properties["local_transcription"] as? Bool, false)
218
+ XCTAssertNil(tracker.observeMic(currentMic: "", transcribing: false, nowMs: 15000))
219
+ }
220
+
221
+ func testWifiAndHotspotSetupReportOutcomeAndLatency() {
222
+ let wifi = tracker.wifiSetupSettled(latencyMs: 4000, errorCode: nil)
223
+ XCTAssertEqual(wifi.properties["success"] as? Bool, true)
224
+ XCTAssertEqual(wifi.properties["latency_ms"] as? Int64, 4000)
225
+ XCTAssertEqual(
226
+ tracker.wifiSetupSettled(latencyMs: 15000, errorCode: "connect_timeout").properties["error_code"] as? String,
227
+ "connect_timeout"
228
+ )
229
+ XCTAssertEqual(tracker.hotspotStartSettled(latencyMs: 2500, errorCode: nil).name, "bluetooth_sdk_hotspot_started")
230
+ }
231
+
232
+ func testOtaSessionIsReportedOnceWithItsStartingVersions() throws {
233
+ XCTAssertNil(ota("ota-1", "in_progress", nowMs: 0, fromBuild: "38"))
234
+ XCTAssertNil(ota("ota-1", "step_complete", nowMs: 60000, fromBuild: "39"))
235
+ let done = try XCTUnwrap(ota("ota-1", "complete", nowMs: 300_000, fromBuild: "40"))
236
+ XCTAssertEqual(done.name, "bluetooth_sdk_ota_finished")
237
+ XCTAssertEqual(done.properties["success"] as? Bool, true)
238
+ XCTAssertEqual(done.properties["duration_ms"] as? Int64, 300_000)
239
+ XCTAssertEqual(done.properties["from_build_number"] as? String, "38")
240
+ XCTAssertEqual(done.properties["total_steps"] as? Int, 2)
241
+ XCTAssertNil(done.properties["failed_step_type"])
242
+ XCTAssertNil(ota("ota-1", "complete", nowMs: 301_000, fromBuild: "40"))
243
+
244
+ let failed = try XCTUnwrap(ota(
245
+ "ota-2", "failed", nowMs: 0, fromBuild: "40", stepType: "bes",
246
+ error: "download failed: https://ota.example.com/x.bin"
247
+ ))
248
+ XCTAssertEqual(failed.properties["success"] as? Bool, false)
249
+ XCTAssertEqual(failed.properties["failed_step_type"] as? String, "bes")
250
+ XCTAssertEqual(failed.properties["error_message"] as? String, "download failed: <url>")
251
+ XCTAssertNil(failed.properties["duration_ms"])
252
+ }
253
+
254
+ func testRequestTimeoutsAreCappedPerTracker() throws {
255
+ var capped = BluetoothSdkUsageTracker(maxRequestTimeouts: 2)
256
+ let first = try XCTUnwrap(capped.requestTimedOut(operation: "photo request"))
257
+ XCTAssertEqual(first.name, "bluetooth_sdk_request_failed")
258
+ XCTAssertEqual(first.properties["operation"] as? String, "photo request")
259
+ XCTAssertEqual(first.properties["error_code"] as? String, "request_timeout")
260
+ XCTAssertNotNil(capped.requestTimedOut(operation: "start stream"))
261
+ XCTAssertNil(capped.requestTimedOut(operation: "start stream"))
262
+ }
263
+
264
+ func testDetailsAreSanitizedAndCapped() {
265
+ XCTAssertNil(BluetoothSdkUsageTracker.sanitizedDetail(" "))
266
+ XCTAssertEqual(BluetoothSdkUsageTracker.sanitizedDetail(String(repeating: "x", count: 500))?.count, 200)
267
+ }
268
+
269
+ private func stream(
270
+ _ state: String,
271
+ terminal: Bool? = nil,
272
+ reconnectAttempt: Int? = nil,
273
+ bitrate: Int64? = nil,
274
+ droppedFrames: Int64? = nil,
275
+ nowMs: Int64
276
+ ) -> AnalyticsEvent? {
277
+ tracker.streamStatus(
278
+ streamId: "s1", state: state, terminal: terminal, reconnectAttempt: reconnectAttempt, bitrate: bitrate,
279
+ fps: nil, droppedFrames: droppedFrames, temperatureC: nil, errorDetails: nil, nowMs: nowMs
280
+ )
281
+ }
282
+
283
+ private func ota(
284
+ _ sessionId: String,
285
+ _ status: String,
286
+ nowMs: Int64,
287
+ fromBuild: String,
288
+ stepType: String = "apk",
289
+ error: String? = nil
290
+ ) -> AnalyticsEvent? {
291
+ tracker.otaStatus(
292
+ sessionId: sessionId, status: status, stepType: stepType, totalSteps: 2, errorMessage: error,
293
+ fromBuildNumber: fromBuild, fromBesFirmwareVersion: "", fromMtkFirmwareVersion: "", nowMs: nowMs
294
+ )
295
+ }
296
+ }
@@ -0,0 +1,32 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ final class G1TextSanitizerTests: XCTestCase {
5
+ func testPreservesSwedishTextAndComposesDecomposedAccents() {
6
+ let text = "“Hallå, hallå”\nÅÄÖ åäö"
7
+ XCTAssertEqual(G1Text.sanitizeForDisplay(text), text)
8
+ XCTAssertEqual(G1Text.sanitizeForDisplay("Halla\u{030A}, halla\u{030A}"), "Hallå, hallå")
9
+ XCTAssertEqual(G1Text.sanitizeForDisplay("Cafe\u{0301}\nĐa Nang"), "Café\nDa Nang")
10
+ }
11
+
12
+ func testFallsBackOnlyForUnmappedLatinAndIsIdempotent() {
13
+ let text = "Tiếng Việt: Đặng, Nguyễn, phở bò; Øresund Łódź Æsir Œuvre Straße Ðingvellir Þingvellir"
14
+ let expected = "Tieng Viet: Dang, Nguyen, pho bò; Øresund Łódź Æsir OEuvre Straße Ðingvellir Þingvellir"
15
+ XCTAssertEqual(G1Text.sanitizeForDisplay(text), expected)
16
+ XCTAssertEqual(G1Text.sanitizeForDisplay(expected), expected)
17
+ }
18
+
19
+ func testPreservesOtherScriptsAndNimoKeepsItsOwnFallback() {
20
+ let otherScripts = "が カ\u{3099} 한\u{302E} Α\u{0301} مُرَحَّبًا शि ❤️"
21
+ XCTAssertEqual(G1Text.sanitizeForDisplay(otherScripts), otherScripts)
22
+ XCTAssertEqual(LatinTextSanitizer.sanitizeForDisplay("Hallå Æsir Straße", preserveOtherScripts: true),
23
+ "Halla AEsir Strasse")
24
+ }
25
+
26
+ func testPreservesEveryEmbeddedExtendedLatinGlyphInComposedAndDecomposedForms() {
27
+ let glyphs = "ÀÂÇÈÉÊËÎÏÔÙÛÜàçèéêëîïôùûüÿŸÄäÖößẞâÁáÍíÑñúÓóÚⁱIJijŠšŽžÃÅÆÌÐÒÕØÝÞãåæìðòõøýþĄąĆćČčĎďĘęĚěĞğŁłŃńŇňŘřŚśŞşŤťŮůŹźŻżĂăıȘșȚțǖǘǚǜǎǐǒǔŐőŰű"
28
+ XCTAssertEqual(Array(G1Text.sanitizeForDisplay(glyphs).utf8), Array(glyphs.utf8))
29
+ let decomposed = glyphs.decomposedStringWithCanonicalMapping
30
+ XCTAssertEqual(Array(G1Text.sanitizeForDisplay(decomposed).utf8), Array(glyphs.utf8))
31
+ }
32
+ }
@@ -0,0 +1,111 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ /// Keep native admission, serialization and fragment packing; replace only the radio writes.
5
+ @MainActor
6
+ private final class IncidentLogTransport: MentraLive {
7
+ var ready = true
8
+ var writes: [(Data, BleWriteTrace?)] = []
9
+
10
+ override var incidentLogTransportReady: Bool {
11
+ ready
12
+ }
13
+
14
+ override func queueSend(_ data: Data, id _: String, trace: BleWriteTrace?) {
15
+ writes.append((data, trace))
16
+ }
17
+ }
18
+
19
+ @MainActor
20
+ final class IncidentLogDispatchTests: XCTestCase {
21
+ private let incident = "rep_01M4HBVT88DFSN8TYNZ1WTV4PB"
22
+ private let apiBase = "https://core.dev.us-west-2.mentraglass.com"
23
+
24
+ func testMissingNativeLinkRejectsDespiteCachedConnectedStatus() {
25
+ let live = MentraLive()
26
+ let store = DeviceStore.shared.store
27
+ let saved = store.getCategory("glasses")["connected"]
28
+ store.set("glasses", "connected", true)
29
+ defer {
30
+ if let saved { store.set("glasses", "connected", saved) }
31
+ else { store.remove("glasses", "connected") }
32
+ }
33
+ XCTAssertThrowsError(try live.sendIncidentId(incident, apiBaseUrl: apiBase)) { error in
34
+ XCTAssertEqual((error as? BluetoothSdkError)?.code, "glasses_not_connected")
35
+ }
36
+ }
37
+
38
+ func testNoDriverRejectsThroughDeviceManager() {
39
+ let manager = DeviceManager()
40
+ manager.sgc = nil
41
+ XCTAssertThrowsError(try manager.sendIncidentId(incident, apiBaseUrl: apiBase)) { error in
42
+ XCTAssertEqual((error as? BluetoothSdkError)?.code, "glasses_not_connected")
43
+ }
44
+ }
45
+
46
+ func testMissingCurrentTokenDoesNotQueueAnUpload() throws {
47
+ try withToken("") {
48
+ let live = IncidentLogTransport()
49
+ XCTAssertThrowsError(try live.sendIncidentId(incident, apiBaseUrl: apiBase)) { error in
50
+ XCTAssertEqual((error as? BluetoothSdkError)?.code, "core_token_unavailable")
51
+ }
52
+ XCTAssertTrue(live.writes.isEmpty)
53
+ }
54
+ }
55
+
56
+ func testCurrentTokenPrecedesUploadAndNeverAppearsInLogs() throws {
57
+ let token = "synthetic-\(UUID().uuidString)"
58
+ try withToken(token) {
59
+ let live = IncidentLogTransport()
60
+ let manager = DeviceManager()
61
+ manager.sgc = live
62
+ var logs: [String] = []
63
+ let sink = Bridge.addEventSink { event, body in
64
+ if event == "log", let message = body["message"] as? String { logs.append(message) }
65
+ }
66
+ defer { Bridge.removeEventSink(sink) }
67
+
68
+ try manager.sendIncidentId(incident, apiBaseUrl: apiBase)
69
+
70
+ XCTAssertEqual(live.writes.compactMap { $0.1?.commandType }, ["auth_token", "upload_incident_logs"])
71
+ XCTAssertTrue(live.writes.allSatisfy { $0.1?.wakeup == true })
72
+ let wire = live.writes.map { String(decoding: $0.0, as: UTF8.self) }.joined()
73
+ XCTAssertTrue(wire.contains(token), "The current token must reach the glasses")
74
+ XCTAssertTrue(wire.contains(incident))
75
+ XCTAssertFalse(logs.contains { $0.contains(token) }, "Tokens must stay out of native and incident logs")
76
+ }
77
+ }
78
+
79
+ func testAllTokenFragmentsPrecedeUpload() throws {
80
+ try withToken(String(repeating: "synthetic", count: 120)) {
81
+ let live = IncidentLogTransport()
82
+ try live.sendIncidentId(incident, apiBaseUrl: apiBase)
83
+ let types = live.writes.compactMap { $0.1?.commandType }
84
+ XCTAssertGreaterThan(types.filter { $0 == "auth_token" }.count, 1)
85
+ XCTAssertEqual(types.last, "upload_incident_logs")
86
+ XCTAssertTrue(types.dropLast().allSatisfy { $0 == "auth_token" })
87
+ }
88
+ }
89
+
90
+ func testCommandQueueInsertionAndSessionClearAreSynchronous() {
91
+ let queue = MentraLive.CommandQueue()
92
+ let first = MentraLive.PendingMessage(data: Data([1]), id: "auth", retries: 0)
93
+ let second = MentraLive.PendingMessage(data: Data([2]), id: "upload", retries: 0)
94
+ XCTAssertEqual(queue.enqueue(first), 1)
95
+ XCTAssertEqual(queue.enqueue(second), 2)
96
+ XCTAssertEqual(queue.dequeue()?.id, "auth")
97
+ queue.removeAll()
98
+ XCTAssertNil(queue.dequeue())
99
+ }
100
+
101
+ private func withToken<T>(_ value: String, body: () throws -> T) rethrows -> T {
102
+ let store = DeviceStore.shared.store
103
+ let saved = store.getCategory("bluetooth")["core_token"]
104
+ store.set("bluetooth", "core_token", value)
105
+ defer {
106
+ if let saved { store.set("bluetooth", "core_token", saved) }
107
+ else { store.remove("bluetooth", "core_token") }
108
+ }
109
+ return try body()
110
+ }
111
+ }
@@ -0,0 +1,163 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ @MainActor
5
+ private final class SessionReadinessTransport: MentraLive {
6
+ var commands: [[String: Any]] = []
7
+
8
+ override func sendJson(_ jsonOriginal: [String: Any], wakeUp: Bool, requireAck: Bool) {
9
+ commands.append(jsonOriginal)
10
+ }
11
+ }
12
+
13
+ @MainActor
14
+ final class MentraLiveSessionReadinessTests: XCTestCase {
15
+ func testChangedSessionPublishesPendingReadinessBeforeItsBuildAndRecoversAtGlassesReady() throws {
16
+ let store = DeviceStore.shared.store
17
+ let saved = ["bluetooth", "glasses"].map { ($0, store.getCategory($0)) }
18
+ let previous = DeviceManager.shared.sgc
19
+ let transport = SessionReadinessTransport()
20
+ let sdk = MentraBluetoothSDK()
21
+ DeviceManager.shared.sgc = transport
22
+ var observations: [(key: String, ready: Bool, connected: Bool)] = []
23
+ let listener = store.addListener { category, changed in
24
+ guard category == "glasses" else { return }
25
+ for key in changed.keys where key == "fullyBooted" || key == "buildNumber" {
26
+ observations.append((key, sdk.glassesStatus.fullyBooted, sdk.glassesStatus.connected))
27
+ }
28
+ }
29
+ var sessionEvents: [Bool] = []
30
+ let sink = Bridge.addEventSink { event, body in
31
+ if event == "glasses_session_changed" {
32
+ sessionEvents.append(sdk.glassesStatus.fullyBooted)
33
+ }
34
+ }
35
+ defer {
36
+ store.removeListener(listener)
37
+ Bridge.removeEventSink(sink)
38
+ sdk.invalidate()
39
+ transport.cleanup()
40
+ DeviceManager.shared.sgc = previous
41
+ for (category, values) in saved {
42
+ for key in store.getCategory(category).keys where values[key] == nil {
43
+ store.remove(category, key)
44
+ }
45
+ for (key, value) in values { store.set(category, key, value) }
46
+ }
47
+ }
48
+
49
+ func receive(_ value: [String: Any]) throws {
50
+ try transport.processReceivedData(JSONSerialization.data(withJSONObject: value))
51
+ }
52
+ try receive(["type": "glasses_ready", "sid": "original"])
53
+ observations.removeAll()
54
+ transport.commands.removeAll()
55
+
56
+ // The physical BLE link remains live across APK replacement. Its first
57
+ // version chunk can prove the target build before remote wire readiness.
58
+ try receive(["type": "version_info_1", "sid": "replacement", "build_number": "302010070"])
59
+ XCTAssertEqual(observations.map(\.key), ["fullyBooted", "buildNumber"])
60
+ XCTAssertTrue(observations.allSatisfy { !$0.ready && $0.connected })
61
+ XCTAssertEqual(sessionEvents, [false])
62
+ XCTAssertEqual(transport.commands.compactMap { $0["type"] as? String }, ["phone_ready"])
63
+
64
+ // Another chunk from the same pending process must not restart readiness.
65
+ try receive(["type": "version_info_3", "sid": "replacement"])
66
+ XCTAssertEqual(sessionEvents, [false])
67
+ XCTAssertFalse(sdk.glassesStatus.fullyBooted)
68
+
69
+ try receive(["type": "glasses_ready", "sid": "replacement"])
70
+ XCTAssertTrue(sdk.glassesStatus.connected)
71
+ XCTAssertTrue(sdk.glassesStatus.fullyBooted)
72
+ XCTAssertEqual(observations.last?.key, "fullyBooted")
73
+ XCTAssertEqual(observations.last?.ready, true)
74
+ XCTAssertEqual(transport.commands.filter { $0["type"] as? String == "request_version" }.count, 1)
75
+
76
+ observations.removeAll()
77
+ try receive(["type": "version_info_1", "sid": "replacement", "build_number": "302010070"])
78
+ XCTAssertTrue(sdk.glassesStatus.fullyBooted)
79
+ XCTAssertFalse(observations.contains { $0.key == "fullyBooted" })
80
+ XCTAssertEqual(sessionEvents, [false])
81
+
82
+ // A physical teardown before the next logical restart becomes ready must
83
+ // still run disconnect cleanup, even though readiness is already false.
84
+ try receive(["type": "version_info_1", "sid": "third", "build_number": "303000267"])
85
+ store.set("glasses", "headUp", true)
86
+ store.set("glasses", "voiceActivityDetectionEnabled", !BluetoothSdkDefaults.voiceActivityDetectionEnabled)
87
+ XCTAssertFalse(sdk.glassesStatus.fullyBooted)
88
+ XCTAssertTrue(sdk.glassesStatus.connected)
89
+ transport.cleanup()
90
+ XCTAssertFalse(sdk.glassesStatus.connected)
91
+ XCTAssertEqual(store.get("glasses", "headUp") as? Bool, false)
92
+ XCTAssertEqual(store.get("glasses", "voiceActivityDetectionEnabled") as? Bool,
93
+ BluetoothSdkDefaults.voiceActivityDetectionEnabled)
94
+ }
95
+
96
+ func testPairingYieldDuringPendingReadinessCleansUpAndReclaimsAfterItsWindow() async throws {
97
+ let store = DeviceStore.shared.store
98
+ let saved = ["bluetooth", "glasses"].map { ($0, store.getCategory($0)) }
99
+ let previous = DeviceManager.shared.sgc
100
+ let transport = SessionReadinessTransport()
101
+ let sdk = MentraBluetoothSDK()
102
+ DeviceManager.shared.sgc = transport
103
+ defer {
104
+ sdk.invalidate()
105
+ transport.cleanup()
106
+ DeviceManager.shared.sgc = previous
107
+ for (category, values) in saved {
108
+ for key in store.getCategory(category).keys where values[key] == nil {
109
+ store.remove(category, key)
110
+ }
111
+ for (key, value) in values { store.set(category, key, value) }
112
+ }
113
+ }
114
+
115
+ func receive(_ value: [String: Any]) throws {
116
+ try transport.processReceivedData(JSONSerialization.data(withJSONObject: value))
117
+ }
118
+ try receive(["type": "glasses_ready", "sid": "original"])
119
+ try receive(["type": "version_info_1", "sid": "replacement", "build_number": "302010070"])
120
+ XCTAssertTrue(sdk.glassesStatus.connected)
121
+ XCTAssertFalse(sdk.glassesStatus.fullyBooted)
122
+ store.set("glasses", "headUp", true)
123
+ store.set("glasses", "voiceActivityDetectionEnabled", !BluetoothSdkDefaults.voiceActivityDetectionEnabled)
124
+
125
+ try receive(["type": "entering_pairing_mode", "window_ms": 5000])
126
+ XCTAssertTrue(transport.isPairingYieldActive())
127
+ XCTAssertFalse(sdk.glassesStatus.connected)
128
+ XCTAssertFalse(sdk.glassesStatus.fullyBooted)
129
+ XCTAssertEqual(store.get("glasses", "headUp") as? Bool, false)
130
+ XCTAssertEqual(store.get("glasses", "voiceActivityDetectionEnabled") as? Bool,
131
+ BluetoothSdkDefaults.voiceActivityDetectionEnabled)
132
+
133
+ // Readiness queued on the relinquished GATT must not close the yield.
134
+ try receive(["type": "glasses_ready", "sid": "replacement"])
135
+ XCTAssertTrue(transport.isPairingYieldActive())
136
+ XCTAssertFalse(sdk.glassesStatus.fullyBooted)
137
+
138
+ // Let the existing minimum yield expire, then accept a fresh readiness.
139
+ try await Task.sleep(nanoseconds: 5_100_000_000)
140
+ XCTAssertFalse(transport.isPairingYieldActive())
141
+ try receive(["type": "glasses_ready", "sid": "reclaimed"])
142
+ XCTAssertTrue(sdk.glassesStatus.connected)
143
+ XCTAssertTrue(sdk.glassesStatus.fullyBooted)
144
+ }
145
+
146
+ func testInitialVersionChunkDoesNotStartAnExtraReadinessExchange() throws {
147
+ let store = DeviceStore.shared.store
148
+ let saved = store.getCategory("glasses")
149
+ let transport = SessionReadinessTransport()
150
+ defer {
151
+ transport.cleanup()
152
+ for key in store.getCategory("glasses").keys where saved[key] == nil { store.remove("glasses", key) }
153
+ for (key, value) in saved { store.set("glasses", key, value) }
154
+ }
155
+ store.set("glasses", "fullyBooted", false)
156
+ try transport.processReceivedData(JSONSerialization.data(withJSONObject: [
157
+ "type": "version_info_1", "sid": "initial", "build_number": "303000267",
158
+ ]))
159
+ XCTAssertTrue(transport.commands.isEmpty)
160
+ XCTAssertEqual(store.get("glasses", "buildNumber") as? String, "303000267")
161
+ XCTAssertEqual(store.get("glasses", "fullyBooted") as? Bool, false)
162
+ }
163
+ }
@@ -5,6 +5,14 @@ import XCTest
5
5
  import zlib
6
6
 
7
7
  final class NimoCanvasCodecTests: XCTestCase {
8
+ func testLatinFallbackPreservesOtherScriptsAndTheirMarks() throws {
9
+ let otherScripts = "ありがとう が パ カ\u{3099} 한국어 한\u{302E} Α\u{0301} مُرَحَّبًا शि ❤️"
10
+ let label = try NimoCanvasCodec.label("\(otherScripts) café—Æ\u{0301}", 0, 0, 500, 220)
11
+ let expected = Data("\(otherScripts) cafe-AE".utf8)
12
+ XCTAssertEqual(Data(label.payload.dropFirst(14)), expected)
13
+ XCTAssertEqual(label.textBytes, expected.count)
14
+ }
15
+
8
16
  private func hex(_ data: Data) -> String {
9
17
  data.map { String(format: "%02X", $0) }.joined()
10
18
  }
@@ -107,9 +115,14 @@ final class NimoCanvasCodecTests: XCTestCase {
107
115
  }
108
116
 
109
117
  func testUtf8RowsAndGeometryValidation() throws {
110
- XCTAssertEqual(try NimoCanvasCodec.label("😀é", 0, 0, 100, 20).textBytes, 6)
118
+ let dashes = try NimoCanvasCodec.label("—éÆß— - –", 0, 0, 100, 20)
119
+ let expectedText = Data("-eAEss- - –".utf8)
120
+ XCTAssertEqual(Data(dashes.payload.dropFirst(14)), expectedText)
121
+ XCTAssertEqual(dashes.textBytes, expectedText.count)
122
+ XCTAssertEqual(Int(dashes.payload[12]), expectedText.count)
123
+ XCTAssertEqual(try NimoCanvasCodec.label("😀界", 0, 0, 100, 20).textBytes, 7)
111
124
  XCTAssertThrowsError(try NimoCanvasCodec.label("a\0b", 0, 0, 100, 20))
112
- XCTAssertThrowsError(try NimoCanvasCodec.label(String(repeating: "é", count: 1025), 0, 0, 100, 20))
125
+ XCTAssertThrowsError(try NimoCanvasCodec.label(String(repeating: "界", count: 683), 0, 0, 100, 20))
113
126
  let rows = try NimoCanvasCodec.textRows("first\n\nthird", 12, 15, 400, 100)
114
127
  XCTAssertEqual(rows.count, 2)
115
128
  XCTAssertEqual(rows.map { $0.payload[2] }, [15, 55])
@@ -156,11 +169,11 @@ final class NimoCanvasCodecTests: XCTestCase {
156
169
  }
157
170
 
158
171
  func testWholeScenePreservesOrderAndIgnoresDiffAnnotations() throws {
159
- let text = SceneElement(id: "caption", type: "text", x: 10, y: 20, w: 100, h: 40, text: "hello",
172
+ let text = SceneElement(id: "caption", type: "text", x: 10, y: 20, w: 100, h: 40, text: "héllo—Æsir",
160
173
  data: nil, border: 2, radius: 3, change: "unchanged", contentHash: "")
161
174
  let expected = try NimoCanvasCodec.replace([
162
175
  NimoCanvasCodec.rectangle(10, 20, 100, 40, stroke: 2, radius: 3),
163
- NimoCanvasCodec.label("hello", 10, 20, 100, 20),
176
+ NimoCanvasCodec.label("hello-AEsir", 10, 20, 100, 20),
164
177
  ])
165
178
  XCTAssertEqual(try NimoCanvasCodec.scene([text]) { _, _, _ in XCTFail("Not an image"); return Data() }, expected)
166
179
  }