@openeditor/react-native-prose-editor 0.0.6 → 0.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/build.gradle +35 -0
- package/android/src/main/java/com/apollohg/editor/CaretGeometry.kt +2 -2
- package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +138 -597
- package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +13 -3
- package/android/src/main/java/com/apollohg/editor/InputSnapshotSupport.kt +6 -0
- package/android/src/main/java/com/apollohg/editor/NativeBlockEditorSurface.kt +1086 -0
- package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +1 -1
- package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +26 -50
- package/android/src/main/java/com/apollohg/editor/NativeImageLoader.kt +89 -0
- package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +31 -66
- package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +98 -33
- package/dist/EditorToolbar.d.ts +1 -1
- package/dist/EditorToolbar.js +19 -0
- package/dist/NativeEditorBridge.d.ts +62 -35
- package/dist/NativeEditorBridge.js +122 -79
- package/dist/NativeProseViewer.d.ts +13 -34
- package/dist/NativeProseViewer.js +40 -465
- package/dist/NativeRichTextEditor.d.ts +10 -1
- package/dist/NativeRichTextEditor.js +72 -34
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -1
- package/dist/schemas.d.ts +11 -0
- package/dist/schemas.js +149 -0
- package/dist/useNativeEditor.d.ts +3 -3
- package/dist/useNativeEditor.js +3 -3
- package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
- package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
- package/ios/Generated_editor_core.swift +97 -2
- package/ios/NativeBlockEditorSurface.swift +1393 -0
- package/ios/NativeEditorExpoView.swift +3 -3
- package/ios/NativeEditorModule.swift +30 -45
- package/ios/NativeInputSupport.swift +396 -0
- package/ios/PositionBridge.swift +4 -4
- package/ios/RichTextEditorView.swift +333 -786
- package/ios/editor_coreFFI/editor_coreFFI.h +147 -70
- package/package.json +1 -1
- package/rust/android/arm64-v8a/libeditor_core.so +0 -0
- package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
- package/rust/android/x86_64/libeditor_core.so +0 -0
- package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +2087 -1399
- package/android/src/androidTest/AndroidManifest.xml +0 -8
- package/android/src/androidTest/java/com/apollohg/editor/MeasureHeightInstrumentedTest.kt +0 -53
- package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceCollaborationInitialSyncTest.kt +0 -241
- package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceImeRegressionTest.kt +0 -338
- package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceOutsideTapTest.kt +0 -789
- package/android/src/androidTest/java/com/apollohg/editor/NativeDevicePerformanceTest.kt +0 -350
- package/android/src/androidTest/java/com/apollohg/editor/NativeEditorOutsideTapActivity.kt +0 -5
- package/android/src/main/java/com/apollohg/editor/NativeProseViewerExpoView.kt +0 -315
- package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +0 -2172
- package/android/src/sharedTest/java/com/apollohg/editor/NativePerformanceSupport.kt +0 -482
- package/android/src/test/java/com/apollohg/editor/CaretGeometryTest.kt +0 -137
- package/android/src/test/java/com/apollohg/editor/EditorEditTextHardwareKeyTest.kt +0 -321
- package/android/src/test/java/com/apollohg/editor/EditorInputConnectionTest.kt +0 -4202
- package/android/src/test/java/com/apollohg/editor/NativeEditorExpoViewTest.kt +0 -2581
- package/android/src/test/java/com/apollohg/editor/NativeEditorModuleTest.kt +0 -27
- package/android/src/test/java/com/apollohg/editor/NativePerformanceTest.kt +0 -197
- package/android/src/test/java/com/apollohg/editor/NativeProseViewerExpoViewTest.kt +0 -84
- package/android/src/test/java/com/apollohg/editor/NativeToolbarTest.kt +0 -544
- package/android/src/test/java/com/apollohg/editor/PositionBridgeTest.kt +0 -461
- package/android/src/test/java/com/apollohg/editor/RenderBridgeTest.kt +0 -2054
- package/android/src/test/java/com/apollohg/editor/RichTextEditorViewTest.kt +0 -1367
- package/ios/EditorLayoutManager.swift +0 -774
- package/ios/NativeProseViewerExpoView.swift +0 -276
- package/ios/RenderBridge.swift +0 -1708
- package/ios/Tests/NativePerformanceTests.swift +0 -687
- package/ios/Tests/PositionBridgeTests.swift +0 -706
- package/ios/Tests/RenderBridgeTests.swift +0 -2236
- package/ios/Tests/RichTextEditorViewTests.swift +0 -6548
|
@@ -1,461 +0,0 @@
|
|
|
1
|
-
package com.openeditor.editor
|
|
2
|
-
|
|
3
|
-
import org.junit.Assert.assertEquals
|
|
4
|
-
import org.junit.Assert.assertTrue
|
|
5
|
-
import org.junit.Test
|
|
6
|
-
import org.junit.runner.RunWith
|
|
7
|
-
import org.robolectric.RobolectricTestRunner
|
|
8
|
-
import org.robolectric.annotation.Config
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Unit tests for [PositionBridge] — UTF-16 <-> Unicode scalar offset conversion
|
|
12
|
-
* and grapheme cluster boundary snapping.
|
|
13
|
-
*
|
|
14
|
-
* These tests are pure JVM (no Android framework dependencies) and mirror
|
|
15
|
-
* the iOS PositionBridgeTests.swift test suite.
|
|
16
|
-
*
|
|
17
|
-
* Test coverage:
|
|
18
|
-
* - ASCII text (1 UTF-16 = 1 scalar)
|
|
19
|
-
* - BMP characters (accented chars, CJK)
|
|
20
|
-
* - Surrogate pairs (emoji above U+FFFF)
|
|
21
|
-
* - ZWJ sequences (family emoji)
|
|
22
|
-
* - Regional indicator pairs (flag emoji)
|
|
23
|
-
* - Mixed content
|
|
24
|
-
* - Empty string edge cases
|
|
25
|
-
* - Grapheme boundary snapping
|
|
26
|
-
* - Roundtrip consistency
|
|
27
|
-
*/
|
|
28
|
-
@RunWith(RobolectricTestRunner::class)
|
|
29
|
-
@Config(sdk = [34])
|
|
30
|
-
class PositionBridgeTest {
|
|
31
|
-
|
|
32
|
-
// ── UTF-16 -> Scalar: ASCII ─────────────────────────────────────────
|
|
33
|
-
|
|
34
|
-
/** ASCII characters are 1 UTF-16 code unit = 1 scalar each. */
|
|
35
|
-
@Test
|
|
36
|
-
fun `utf16ToScalar - ASCII only`() {
|
|
37
|
-
val text = "Hello"
|
|
38
|
-
// "Hello" = 5 UTF-16 code units = 5 scalars
|
|
39
|
-
assertEquals(
|
|
40
|
-
"Offset 0 should map to scalar 0",
|
|
41
|
-
0, PositionBridge.utf16ToScalar(0, text)
|
|
42
|
-
)
|
|
43
|
-
assertEquals(
|
|
44
|
-
"Offset 1 in ASCII should map to scalar 1",
|
|
45
|
-
1, PositionBridge.utf16ToScalar(1, text)
|
|
46
|
-
)
|
|
47
|
-
assertEquals(
|
|
48
|
-
"Offset 5 (end of 'Hello') should map to scalar 5",
|
|
49
|
-
5, PositionBridge.utf16ToScalar(5, text)
|
|
50
|
-
)
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Empty string edge case. */
|
|
54
|
-
@Test
|
|
55
|
-
fun `utf16ToScalar - empty string`() {
|
|
56
|
-
assertEquals(
|
|
57
|
-
"Empty string at offset 0 should return scalar 0",
|
|
58
|
-
0, PositionBridge.utf16ToScalar(0, "")
|
|
59
|
-
)
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// ── UTF-16 -> Scalar: BMP Characters ────────────────────────────────
|
|
63
|
-
|
|
64
|
-
/** BMP characters (e.g. U+00E9, e-acute) are 1 UTF-16 code unit, 1 scalar. */
|
|
65
|
-
@Test
|
|
66
|
-
fun `utf16ToScalar - BMP characters`() {
|
|
67
|
-
val text = "caf\u00E9" // "cafe" with accent
|
|
68
|
-
assertEquals(
|
|
69
|
-
"BMP character: UTF-16 offset 4 should be scalar 4",
|
|
70
|
-
4, PositionBridge.utf16ToScalar(4, text)
|
|
71
|
-
)
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// ── UTF-16 -> Scalar: Surrogate Pairs ───────────────────────────────
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Characters above U+FFFF (supplementary plane) are encoded as
|
|
78
|
-
* surrogate pairs in UTF-16: 2 code units per scalar.
|
|
79
|
-
*/
|
|
80
|
-
@Test
|
|
81
|
-
fun `utf16ToScalar - surrogate pair`() {
|
|
82
|
-
// U+1F600 (grinning face) = 2 UTF-16 code units, 1 scalar
|
|
83
|
-
val text = "A\uD83D\uDE00B" // "A" + U+1F600 + "B"
|
|
84
|
-
// UTF-16: A(1) + U+1F600(2) + B(1) = 4 code units
|
|
85
|
-
// Scalars: A(1) + U+1F600(1) + B(1) = 3 scalars
|
|
86
|
-
|
|
87
|
-
assertEquals("Before 'A'", 0, PositionBridge.utf16ToScalar(0, text))
|
|
88
|
-
assertEquals("After 'A', before emoji", 1, PositionBridge.utf16ToScalar(1, text))
|
|
89
|
-
assertEquals(
|
|
90
|
-
"After emoji (UTF-16 offset 3 = scalar 2)",
|
|
91
|
-
2, PositionBridge.utf16ToScalar(3, text)
|
|
92
|
-
)
|
|
93
|
-
assertEquals(
|
|
94
|
-
"After 'B' (end of string)",
|
|
95
|
-
3, PositionBridge.utf16ToScalar(4, text)
|
|
96
|
-
)
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* The family emoji is composed of multiple scalars joined by ZWJ (U+200D).
|
|
101
|
-
* U+1F468 U+200D U+1F469 U+200D U+1F467 U+200D U+1F466
|
|
102
|
-
* = 7 scalars, 11 UTF-16 code units (4 surrogates + 3 ZWJ)
|
|
103
|
-
* But it renders as 1 grapheme cluster.
|
|
104
|
-
*/
|
|
105
|
-
@Test
|
|
106
|
-
fun `utf16ToScalar - family emoji (ZWJ sequence)`() {
|
|
107
|
-
val text = "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66"
|
|
108
|
-
val expectedUtf16Count = 11
|
|
109
|
-
val expectedScalarCount = 7
|
|
110
|
-
|
|
111
|
-
assertEquals(
|
|
112
|
-
"Family emoji should be $expectedUtf16Count UTF-16 code units",
|
|
113
|
-
expectedUtf16Count, text.length
|
|
114
|
-
)
|
|
115
|
-
assertEquals(
|
|
116
|
-
"Family emoji should be $expectedScalarCount Unicode scalars",
|
|
117
|
-
expectedScalarCount, text.codePointCount(0, text.length)
|
|
118
|
-
)
|
|
119
|
-
assertEquals(
|
|
120
|
-
"Full family emoji: UTF-16 end should map to scalar end",
|
|
121
|
-
expectedScalarCount,
|
|
122
|
-
PositionBridge.utf16ToScalar(expectedUtf16Count, text)
|
|
123
|
-
)
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// ── UTF-16 -> Scalar: CJK ──────────────────────────────────────────
|
|
127
|
-
|
|
128
|
-
/** CJK characters in the BMP: 1 UTF-16 = 1 scalar. */
|
|
129
|
-
@Test
|
|
130
|
-
fun `utf16ToScalar - CJK`() {
|
|
131
|
-
val text = "\u4F60\u597D" // nihao
|
|
132
|
-
assertEquals(
|
|
133
|
-
"CJK character at offset 1 should be scalar 1",
|
|
134
|
-
1, PositionBridge.utf16ToScalar(1, text)
|
|
135
|
-
)
|
|
136
|
-
assertEquals(
|
|
137
|
-
"End of CJK string should be scalar 2",
|
|
138
|
-
2, PositionBridge.utf16ToScalar(2, text)
|
|
139
|
-
)
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** CJK extension B characters (U+20000+) are in the supplementary plane. */
|
|
143
|
-
@Test
|
|
144
|
-
fun `utf16ToScalar - CJK Extension B`() {
|
|
145
|
-
// U+20000 (CJK Unified Ideographs Extension B) = 2 UTF-16 code units, 1 scalar
|
|
146
|
-
val text = "A\uD840\uDC00B" // A + U+20000 + B
|
|
147
|
-
// UTF-16: A(1) + U+20000(2) + B(1) = 4
|
|
148
|
-
// Scalars: A(1) + U+20000(1) + B(1) = 3
|
|
149
|
-
assertEquals(
|
|
150
|
-
"After CJK Extension B character",
|
|
151
|
-
2, PositionBridge.utf16ToScalar(3, text)
|
|
152
|
-
)
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// ── UTF-16 -> Scalar: Mixed Content ─────────────────────────────────
|
|
156
|
-
|
|
157
|
-
/** Mixed ASCII, emoji, and CJK in one string. */
|
|
158
|
-
@Test
|
|
159
|
-
fun `utf16ToScalar - mixed content`() {
|
|
160
|
-
val text = "Hi\uD83D\uDE00\u4F60\u597D!"
|
|
161
|
-
// UTF-16: H(1) + i(1) + U+1F600(2) + U+4F60(1) + U+597D(1) + !(1) = 7
|
|
162
|
-
// Scalars: H(1) + i(1) + U+1F600(1) + U+4F60(1) + U+597D(1) + !(1) = 6
|
|
163
|
-
|
|
164
|
-
assertEquals("After 'Hi'", 2, PositionBridge.utf16ToScalar(2, text))
|
|
165
|
-
assertEquals(
|
|
166
|
-
"After emoji (UTF-16 offset 4 = scalar 3)",
|
|
167
|
-
3, PositionBridge.utf16ToScalar(4, text)
|
|
168
|
-
)
|
|
169
|
-
assertEquals("End of mixed string", 6, PositionBridge.utf16ToScalar(7, text))
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// ── UTF-16 -> Scalar: Flag Emoji ────────────────────────────────────
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Flag emoji are composed of two regional indicator symbols.
|
|
176
|
-
* e.g. US flag = U+1F1FA U+1F1F8 = 4 UTF-16 code units, 2 scalars, 1 grapheme.
|
|
177
|
-
*/
|
|
178
|
-
@Test
|
|
179
|
-
fun `utf16ToScalar - flag emoji`() {
|
|
180
|
-
val text = "A\uD83C\uDDFA\uD83C\uDDF8B" // A + U+1F1FA + U+1F1F8 + B
|
|
181
|
-
// UTF-16: A(1) + U+1F1FA(2) + U+1F1F8(2) + B(1) = 6
|
|
182
|
-
// Scalars: A(1) + U+1F1FA(1) + U+1F1F8(1) + B(1) = 4
|
|
183
|
-
|
|
184
|
-
assertEquals("After 'A'", 1, PositionBridge.utf16ToScalar(1, text))
|
|
185
|
-
assertEquals(
|
|
186
|
-
"After flag emoji (UTF-16 offset 5 = scalar 3)",
|
|
187
|
-
3, PositionBridge.utf16ToScalar(5, text)
|
|
188
|
-
)
|
|
189
|
-
assertEquals("After 'B'", 4, PositionBridge.utf16ToScalar(6, text))
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// ── Scalar -> UTF-16: ASCII ─────────────────────────────────────────
|
|
193
|
-
|
|
194
|
-
@Test
|
|
195
|
-
fun `scalarToUtf16 - ASCII only`() {
|
|
196
|
-
val text = "Hello"
|
|
197
|
-
assertEquals(
|
|
198
|
-
"Scalar 0 should map to UTF-16 offset 0",
|
|
199
|
-
0, PositionBridge.scalarToUtf16(0, text)
|
|
200
|
-
)
|
|
201
|
-
assertEquals(
|
|
202
|
-
"Scalar 3 in ASCII should map to UTF-16 offset 3",
|
|
203
|
-
3, PositionBridge.scalarToUtf16(3, text)
|
|
204
|
-
)
|
|
205
|
-
assertEquals(
|
|
206
|
-
"Scalar 5 (end) should map to UTF-16 offset 5",
|
|
207
|
-
5, PositionBridge.scalarToUtf16(5, text)
|
|
208
|
-
)
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// ── Scalar -> UTF-16: Surrogate Pairs ───────────────────────────────
|
|
212
|
-
|
|
213
|
-
@Test
|
|
214
|
-
fun `scalarToUtf16 - surrogate pair`() {
|
|
215
|
-
val text = "A\uD83D\uDE00B" // A + U+1F600 + B
|
|
216
|
-
// Scalar 0 = A -> UTF-16 offset 0
|
|
217
|
-
// Scalar 1 = U+1F600 -> UTF-16 offset 1
|
|
218
|
-
// Scalar 2 = B -> UTF-16 offset 3 (after 2 UTF-16 code units for emoji)
|
|
219
|
-
assertEquals(
|
|
220
|
-
"Scalar 1 (emoji start) should be UTF-16 offset 1",
|
|
221
|
-
1, PositionBridge.scalarToUtf16(1, text)
|
|
222
|
-
)
|
|
223
|
-
assertEquals(
|
|
224
|
-
"Scalar 2 (after emoji) should be UTF-16 offset 3",
|
|
225
|
-
3, PositionBridge.scalarToUtf16(2, text)
|
|
226
|
-
)
|
|
227
|
-
assertEquals(
|
|
228
|
-
"Scalar 3 (after B) should be UTF-16 offset 4",
|
|
229
|
-
4, PositionBridge.scalarToUtf16(3, text)
|
|
230
|
-
)
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// ── Scalar -> UTF-16: Family Emoji ──────────────────────────────────
|
|
234
|
-
|
|
235
|
-
@Test
|
|
236
|
-
fun `scalarToUtf16 - family emoji`() {
|
|
237
|
-
val text = "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66"
|
|
238
|
-
// 7 scalars, 11 UTF-16 code units
|
|
239
|
-
assertEquals(
|
|
240
|
-
"End of family emoji: scalar 7 -> UTF-16 offset 11",
|
|
241
|
-
11, PositionBridge.scalarToUtf16(7, text)
|
|
242
|
-
)
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// ── Roundtrip Consistency ───────────────────────────────────────────
|
|
246
|
-
|
|
247
|
-
/** Verify that scalar->utf16->scalar roundtrips exactly. */
|
|
248
|
-
@Test
|
|
249
|
-
fun `roundtrip - scalar to utf16 and back`() {
|
|
250
|
-
val testCases = listOf(
|
|
251
|
-
"Hello, world!" to "ASCII",
|
|
252
|
-
"A\uD83D\uDE00B" to "surrogate pair",
|
|
253
|
-
"\u4F60\u597D" to "CJK",
|
|
254
|
-
"abc\uD83D\uDE00\u4F60xyz" to "mixed"
|
|
255
|
-
)
|
|
256
|
-
|
|
257
|
-
for ((text, label) in testCases) {
|
|
258
|
-
val scalarCount = text.codePointCount(0, text.length)
|
|
259
|
-
for (scalarOffset in 0..scalarCount) {
|
|
260
|
-
val utf16 = PositionBridge.scalarToUtf16(scalarOffset, text)
|
|
261
|
-
val backToScalar = PositionBridge.utf16ToScalar(utf16, text)
|
|
262
|
-
assertEquals(
|
|
263
|
-
"Roundtrip for '$label' at scalar $scalarOffset: " +
|
|
264
|
-
"utf16=$utf16, back=$backToScalar - should equal original scalar",
|
|
265
|
-
scalarOffset, backToScalar
|
|
266
|
-
)
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* Verify that utf16->scalar->utf16 roundtrips correctly for various inputs.
|
|
273
|
-
* Note: mid-surrogate offsets may snap forward, so we check >= originalOffset - 1.
|
|
274
|
-
*/
|
|
275
|
-
@Test
|
|
276
|
-
fun `roundtrip - utf16 to scalar and back`() {
|
|
277
|
-
val testCases = listOf(
|
|
278
|
-
"Hello, world!" to "ASCII",
|
|
279
|
-
"A\uD83D\uDE00B" to "surrogate pair",
|
|
280
|
-
"\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66" to "family emoji",
|
|
281
|
-
"\u4F60\u597D\u4E16\u754C" to "CJK",
|
|
282
|
-
"abc\uD83D\uDE00\u4F60xyz" to "mixed",
|
|
283
|
-
"A\u0301" to "combining character (A + combining acute)"
|
|
284
|
-
)
|
|
285
|
-
|
|
286
|
-
for ((text, label) in testCases) {
|
|
287
|
-
for (utf16Offset in 0..text.length) {
|
|
288
|
-
val scalar = PositionBridge.utf16ToScalar(utf16Offset, text)
|
|
289
|
-
val backToUtf16 = PositionBridge.scalarToUtf16(scalar, text)
|
|
290
|
-
assertTrue(
|
|
291
|
-
"Roundtrip for '$label' at UTF-16 offset $utf16Offset: " +
|
|
292
|
-
"scalar=$scalar, back=$backToUtf16",
|
|
293
|
-
backToUtf16 >= utf16Offset - 1
|
|
294
|
-
)
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// ── Grapheme Boundary Snapping ──────────────────────────────────────
|
|
300
|
-
|
|
301
|
-
/** Snapping at an already-valid boundary returns the same offset. */
|
|
302
|
-
@Test
|
|
303
|
-
fun `snapToGraphemeBoundary - already on boundary (ASCII)`() {
|
|
304
|
-
val text = "Hello"
|
|
305
|
-
for (i in 0..5) {
|
|
306
|
-
assertEquals(
|
|
307
|
-
"ASCII offset $i is already on a grapheme boundary",
|
|
308
|
-
i, PositionBridge.snapToGraphemeBoundary(i, text)
|
|
309
|
-
)
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/** Snapping in the middle of a surrogate pair snaps to the end of the grapheme cluster. */
|
|
314
|
-
@Test
|
|
315
|
-
fun `snapToGraphemeBoundary - mid surrogate pair`() {
|
|
316
|
-
val text = "A\uD83D\uDE00B"
|
|
317
|
-
// UTF-16: A(0), high surrogate(1), low surrogate(2), B(3)
|
|
318
|
-
// Offset 2 is mid-surrogate. Should snap to 3 (end of emoji grapheme).
|
|
319
|
-
val snapped = PositionBridge.snapToGraphemeBoundary(2, text)
|
|
320
|
-
assertEquals(
|
|
321
|
-
"Mid-surrogate offset 2 should snap to 3 (end of emoji grapheme)",
|
|
322
|
-
3, snapped
|
|
323
|
-
)
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
/** Snapping in the middle of a family emoji snaps to the end. */
|
|
327
|
-
@Test
|
|
328
|
-
fun `snapToGraphemeBoundary - mid family emoji`() {
|
|
329
|
-
val text = "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66"
|
|
330
|
-
// The entire sequence is 1 grapheme cluster = 11 UTF-16 code units.
|
|
331
|
-
// Any offset 1..10 should snap to 11.
|
|
332
|
-
for (midOffset in 1..10) {
|
|
333
|
-
val snapped = PositionBridge.snapToGraphemeBoundary(midOffset, text)
|
|
334
|
-
assertEquals(
|
|
335
|
-
"Mid-family-emoji offset $midOffset should snap to 11 (end of grapheme)",
|
|
336
|
-
11, snapped
|
|
337
|
-
)
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/** Snapping in the middle of a combining character sequence. */
|
|
342
|
-
@Test
|
|
343
|
-
fun `snapToGraphemeBoundary - combining character`() {
|
|
344
|
-
// "e" + combining acute accent = 2 UTF-16 code units but 1 grapheme cluster
|
|
345
|
-
val text = "e\u0301"
|
|
346
|
-
val snapped = PositionBridge.snapToGraphemeBoundary(1, text)
|
|
347
|
-
assertEquals(
|
|
348
|
-
"Between base and combining character should snap to end of grapheme",
|
|
349
|
-
2, snapped
|
|
350
|
-
)
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
/** Snapping on empty string always returns 0. */
|
|
354
|
-
@Test
|
|
355
|
-
fun `snapToGraphemeBoundary - empty string`() {
|
|
356
|
-
assertEquals(
|
|
357
|
-
"Empty string should always return 0",
|
|
358
|
-
0, PositionBridge.snapToGraphemeBoundary(0, "")
|
|
359
|
-
)
|
|
360
|
-
assertEquals(
|
|
361
|
-
"Empty string with out-of-range offset should return 0",
|
|
362
|
-
0, PositionBridge.snapToGraphemeBoundary(5, "")
|
|
363
|
-
)
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/** Snapping at the end of string returns the end offset. */
|
|
367
|
-
@Test
|
|
368
|
-
fun `snapToGraphemeBoundary - at end`() {
|
|
369
|
-
val text = "Hello"
|
|
370
|
-
assertEquals(
|
|
371
|
-
"At end of string should return end offset",
|
|
372
|
-
5, PositionBridge.snapToGraphemeBoundary(5, text)
|
|
373
|
-
)
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
/** Snapping with negative offset clamps to 0. */
|
|
377
|
-
@Test
|
|
378
|
-
fun `snapToGraphemeBoundary - negative offset`() {
|
|
379
|
-
val text = "Hello"
|
|
380
|
-
assertEquals(
|
|
381
|
-
"Negative offset should clamp to 0",
|
|
382
|
-
0, PositionBridge.snapToGraphemeBoundary(-1, text)
|
|
383
|
-
)
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
/** Flag emoji mid-offset snapping. */
|
|
387
|
-
@Test
|
|
388
|
-
fun `snapToGraphemeBoundary - flag emoji`() {
|
|
389
|
-
val text = "\uD83C\uDDFA\uD83C\uDDF8" // US flag
|
|
390
|
-
// 4 UTF-16 code units, 1 grapheme cluster.
|
|
391
|
-
// Offsets 1, 2, 3 should snap to 4.
|
|
392
|
-
for (midOffset in 1..3) {
|
|
393
|
-
val snapped = PositionBridge.snapToGraphemeBoundary(midOffset, text)
|
|
394
|
-
assertEquals(
|
|
395
|
-
"Mid-flag-emoji offset $midOffset should snap to 4",
|
|
396
|
-
4, snapped
|
|
397
|
-
)
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/** Scalar snapping only repairs surrogate-pair splits. */
|
|
402
|
-
@Test
|
|
403
|
-
fun `snapToScalarBoundary - mid surrogate pair`() {
|
|
404
|
-
val text = "A\uD83D\uDE00B"
|
|
405
|
-
|
|
406
|
-
assertEquals(1, PositionBridge.snapToScalarBoundary(2, text, biasForward = false))
|
|
407
|
-
assertEquals(3, PositionBridge.snapToScalarBoundary(2, text, biasForward = true))
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
/** Ranges expand away from invalid scalar boundaries. */
|
|
411
|
-
@Test
|
|
412
|
-
fun `snapRangeToScalarBoundaries - expands split surrogate range`() {
|
|
413
|
-
val text = "\uD83D\uDE00 ok"
|
|
414
|
-
|
|
415
|
-
assertEquals(0 to 2, PositionBridge.snapRangeToScalarBoundaries(1, 1, text))
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
// ── Edge Cases ──────────────────────────────────────────────────────
|
|
419
|
-
|
|
420
|
-
/** Offset beyond string length should be clamped. */
|
|
421
|
-
@Test
|
|
422
|
-
fun `utf16ToScalar - offset beyond string length`() {
|
|
423
|
-
val text = "Hi"
|
|
424
|
-
assertEquals(
|
|
425
|
-
"Offset beyond length should count all scalars",
|
|
426
|
-
2, PositionBridge.utf16ToScalar(10, text)
|
|
427
|
-
)
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
/** Scalar beyond code point count should return full UTF-16 length. */
|
|
431
|
-
@Test
|
|
432
|
-
fun `scalarToUtf16 - scalar beyond code point count`() {
|
|
433
|
-
val text = "Hi"
|
|
434
|
-
assertEquals(
|
|
435
|
-
"Scalar beyond count should return full UTF-16 length",
|
|
436
|
-
2, PositionBridge.scalarToUtf16(10, text)
|
|
437
|
-
)
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
/** Mid-surrogate UTF-16 offset should still produce a valid scalar count. */
|
|
441
|
-
@Test
|
|
442
|
-
fun `utf16ToScalar - mid surrogate pair offset`() {
|
|
443
|
-
val text = "A\uD83D\uDE00B"
|
|
444
|
-
// Offset 2 is in the middle of the surrogate pair for U+1F600.
|
|
445
|
-
// The method walks code points, so at offset 2 we've consumed
|
|
446
|
-
// A (1 code unit) + U+1F600 (2 code units, but offset 2 is mid-pair).
|
|
447
|
-
// Since we count code points via Character.codePointAt which handles
|
|
448
|
-
// surrogates, offset 2 still "fits" within the second code point's range,
|
|
449
|
-
// so only 1 scalar (A) is fully before offset 2.
|
|
450
|
-
val scalar = PositionBridge.utf16ToScalar(2, text)
|
|
451
|
-
// Should be 1 (only 'A' fully precedes offset 2) or 2 (if the emoji
|
|
452
|
-
// is counted because we started reading it). Implementation walks
|
|
453
|
-
// with codePointAt, charCount — at utf16Pos=1 we read codePoint
|
|
454
|
-
// U+1F600 (charCount=2), advance to utf16Pos=3 which > endIndex=2,
|
|
455
|
-
// but we already incremented scalarCount. So result is 2.
|
|
456
|
-
assertEquals(
|
|
457
|
-
"Mid-surrogate offset 2: implementation counts the emoji scalar",
|
|
458
|
-
2, scalar
|
|
459
|
-
)
|
|
460
|
-
}
|
|
461
|
-
}
|