@wordpress/core-data 7.41.2-next.v.202603102151.0 → 7.42.0

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 (166) hide show
  1. package/CHANGELOG.md +1 -1
  2. package/README.md +19 -0
  3. package/build/actions.cjs +17 -25
  4. package/build/actions.cjs.map +2 -2
  5. package/build/awareness/post-editor-awareness.cjs +46 -6
  6. package/build/awareness/post-editor-awareness.cjs.map +2 -2
  7. package/build/awareness/types.cjs.map +1 -1
  8. package/build/entities.cjs +33 -7
  9. package/build/entities.cjs.map +2 -2
  10. package/build/hooks/use-entity-prop.cjs +2 -1
  11. package/build/hooks/use-entity-prop.cjs.map +2 -2
  12. package/build/hooks/use-post-editor-awareness-state.cjs +84 -3
  13. package/build/hooks/use-post-editor-awareness-state.cjs.map +2 -2
  14. package/build/index.cjs +3 -0
  15. package/build/index.cjs.map +2 -2
  16. package/build/private-apis.cjs +3 -1
  17. package/build/private-apis.cjs.map +2 -2
  18. package/build/queried-data/get-query-parts.cjs +7 -0
  19. package/build/queried-data/get-query-parts.cjs.map +2 -2
  20. package/build/queried-data/selectors.cjs +19 -5
  21. package/build/queried-data/selectors.cjs.map +2 -2
  22. package/build/reducer.cjs +6 -0
  23. package/build/reducer.cjs.map +2 -2
  24. package/build/resolvers.cjs +110 -74
  25. package/build/resolvers.cjs.map +2 -2
  26. package/build/selectors.cjs +29 -0
  27. package/build/selectors.cjs.map +2 -2
  28. package/build/sync.cjs +3 -0
  29. package/build/sync.cjs.map +2 -2
  30. package/build/types.cjs +16 -0
  31. package/build/types.cjs.map +3 -3
  32. package/build/utils/block-selection-history.cjs +1 -1
  33. package/build/utils/block-selection-history.cjs.map +2 -2
  34. package/build/utils/crdt-blocks.cjs +17 -3
  35. package/build/utils/crdt-blocks.cjs.map +2 -2
  36. package/build/utils/crdt-selection.cjs +4 -1
  37. package/build/utils/crdt-selection.cjs.map +2 -2
  38. package/build/utils/crdt-user-selections.cjs +9 -6
  39. package/build/utils/crdt-user-selections.cjs.map +2 -2
  40. package/build/utils/crdt-utils.cjs +54 -2
  41. package/build/utils/crdt-utils.cjs.map +2 -2
  42. package/build/utils/crdt.cjs +4 -23
  43. package/build/utils/crdt.cjs.map +2 -2
  44. package/build/utils/index.cjs +3 -0
  45. package/build/utils/index.cjs.map +2 -2
  46. package/build/utils/normalize-query-for-resolution.cjs +35 -0
  47. package/build/utils/normalize-query-for-resolution.cjs.map +7 -0
  48. package/build-module/actions.mjs +17 -25
  49. package/build-module/actions.mjs.map +2 -2
  50. package/build-module/awareness/post-editor-awareness.mjs +46 -6
  51. package/build-module/awareness/post-editor-awareness.mjs.map +2 -2
  52. package/build-module/entities.mjs +33 -7
  53. package/build-module/entities.mjs.map +2 -2
  54. package/build-module/hooks/use-entity-prop.mjs +2 -1
  55. package/build-module/hooks/use-entity-prop.mjs.map +2 -2
  56. package/build-module/hooks/use-post-editor-awareness-state.mjs +81 -2
  57. package/build-module/hooks/use-post-editor-awareness-state.mjs.map +2 -2
  58. package/build-module/index.mjs +2 -0
  59. package/build-module/index.mjs.map +2 -2
  60. package/build-module/private-apis.mjs +6 -2
  61. package/build-module/private-apis.mjs.map +2 -2
  62. package/build-module/queried-data/get-query-parts.mjs +7 -0
  63. package/build-module/queried-data/get-query-parts.mjs.map +2 -2
  64. package/build-module/queried-data/selectors.mjs +19 -5
  65. package/build-module/queried-data/selectors.mjs.map +2 -2
  66. package/build-module/reducer.mjs +6 -0
  67. package/build-module/reducer.mjs.map +2 -2
  68. package/build-module/resolvers.mjs +112 -75
  69. package/build-module/resolvers.mjs.map +2 -2
  70. package/build-module/selectors.mjs +28 -0
  71. package/build-module/selectors.mjs.map +2 -2
  72. package/build-module/sync.mjs +2 -0
  73. package/build-module/sync.mjs.map +2 -2
  74. package/build-module/types.mjs +9 -0
  75. package/build-module/types.mjs.map +4 -4
  76. package/build-module/utils/block-selection-history.mjs +5 -2
  77. package/build-module/utils/block-selection-history.mjs.map +2 -2
  78. package/build-module/utils/crdt-blocks.mjs +17 -3
  79. package/build-module/utils/crdt-blocks.mjs.map +2 -2
  80. package/build-module/utils/crdt-selection.mjs +8 -2
  81. package/build-module/utils/crdt-selection.mjs.map +2 -2
  82. package/build-module/utils/crdt-user-selections.mjs +10 -7
  83. package/build-module/utils/crdt-user-selections.mjs.map +2 -2
  84. package/build-module/utils/crdt-utils.mjs +51 -1
  85. package/build-module/utils/crdt-utils.mjs.map +2 -2
  86. package/build-module/utils/crdt.mjs +4 -23
  87. package/build-module/utils/crdt.mjs.map +2 -2
  88. package/build-module/utils/index.mjs +2 -0
  89. package/build-module/utils/index.mjs.map +2 -2
  90. package/build-module/utils/normalize-query-for-resolution.mjs +14 -0
  91. package/build-module/utils/normalize-query-for-resolution.mjs.map +7 -0
  92. package/build-types/actions.d.ts.map +1 -1
  93. package/build-types/awareness/post-editor-awareness.d.ts +2 -2
  94. package/build-types/awareness/post-editor-awareness.d.ts.map +1 -1
  95. package/build-types/awareness/types.d.ts +1 -1
  96. package/build-types/awareness/types.d.ts.map +1 -1
  97. package/build-types/entities.d.ts +1 -1
  98. package/build-types/entities.d.ts.map +1 -1
  99. package/build-types/hooks/use-entity-prop.d.ts.map +1 -1
  100. package/build-types/hooks/use-post-editor-awareness-state.d.ts +34 -10
  101. package/build-types/hooks/use-post-editor-awareness-state.d.ts.map +1 -1
  102. package/build-types/index.d.ts +2 -0
  103. package/build-types/index.d.ts.map +1 -1
  104. package/build-types/private-apis.d.ts.map +1 -1
  105. package/build-types/queried-data/get-query-parts.d.ts +7 -0
  106. package/build-types/queried-data/get-query-parts.d.ts.map +1 -1
  107. package/build-types/queried-data/selectors.d.ts.map +1 -1
  108. package/build-types/reducer.d.ts.map +1 -1
  109. package/build-types/resolvers.d.ts +2 -1
  110. package/build-types/resolvers.d.ts.map +1 -1
  111. package/build-types/selectors.d.ts +17 -0
  112. package/build-types/selectors.d.ts.map +1 -1
  113. package/build-types/sync.d.ts +2 -2
  114. package/build-types/sync.d.ts.map +1 -1
  115. package/build-types/types.d.ts +18 -1
  116. package/build-types/types.d.ts.map +1 -1
  117. package/build-types/utils/block-selection-history.d.ts.map +1 -1
  118. package/build-types/utils/crdt-blocks.d.ts.map +1 -1
  119. package/build-types/utils/crdt-selection.d.ts.map +1 -1
  120. package/build-types/utils/crdt-user-selections.d.ts +9 -5
  121. package/build-types/utils/crdt-user-selections.d.ts.map +1 -1
  122. package/build-types/utils/crdt-utils.d.ts +20 -0
  123. package/build-types/utils/crdt-utils.d.ts.map +1 -1
  124. package/build-types/utils/crdt.d.ts +6 -7
  125. package/build-types/utils/crdt.d.ts.map +1 -1
  126. package/build-types/utils/index.d.ts +1 -0
  127. package/build-types/utils/normalize-query-for-resolution.d.ts +12 -0
  128. package/build-types/utils/normalize-query-for-resolution.d.ts.map +1 -0
  129. package/build-types/utils/test/crdt-utils.d.ts +2 -0
  130. package/build-types/utils/test/crdt-utils.d.ts.map +1 -0
  131. package/package.json +18 -18
  132. package/src/actions.js +25 -40
  133. package/src/awareness/post-editor-awareness.ts +106 -7
  134. package/src/awareness/test/post-editor-awareness.ts +50 -10
  135. package/src/awareness/types.ts +1 -1
  136. package/src/entities.js +38 -6
  137. package/src/hooks/test/use-post-editor-awareness-state.ts +446 -3
  138. package/src/hooks/use-entity-prop.js +2 -0
  139. package/src/hooks/use-post-editor-awareness-state.ts +160 -8
  140. package/src/index.js +1 -0
  141. package/src/private-apis.js +6 -2
  142. package/src/queried-data/get-query-parts.js +13 -0
  143. package/src/queried-data/selectors.js +33 -8
  144. package/src/queried-data/test/get-query-parts.js +34 -0
  145. package/src/queried-data/test/selectors.js +183 -0
  146. package/src/reducer.js +11 -0
  147. package/src/resolvers.js +136 -88
  148. package/src/selectors.ts +56 -0
  149. package/src/sync.ts +2 -0
  150. package/src/test/entities.js +185 -1
  151. package/src/test/resolvers.js +64 -11
  152. package/src/test/selectors.js +150 -0
  153. package/src/test/store.js +66 -0
  154. package/src/types.ts +26 -1
  155. package/src/utils/block-selection-history.ts +5 -2
  156. package/src/utils/crdt-blocks.ts +32 -3
  157. package/src/utils/crdt-selection.ts +8 -2
  158. package/src/utils/crdt-user-selections.ts +20 -8
  159. package/src/utils/crdt-utils.ts +99 -0
  160. package/src/utils/crdt.ts +8 -32
  161. package/src/utils/index.js +1 -0
  162. package/src/utils/normalize-query-for-resolution.js +23 -0
  163. package/src/utils/test/crdt-blocks.ts +146 -0
  164. package/src/utils/test/crdt-user-selections.ts +5 -0
  165. package/src/utils/test/crdt-utils.ts +387 -0
  166. package/src/utils/test/crdt.ts +120 -53
@@ -0,0 +1,387 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import { describe, expect, it } from '@jest/globals';
5
+
6
+ /**
7
+ * Internal dependencies
8
+ */
9
+ import {
10
+ htmlIndexToRichTextOffset,
11
+ richTextOffsetToHtmlIndex,
12
+ } from '../crdt-utils';
13
+
14
+ describe( 'htmlIndexToRichTextOffset', () => {
15
+ it( 'returns the index unchanged when there are no tags', () => {
16
+ expect( htmlIndexToRichTextOffset( 'hello world', 5 ) ).toBe( 5 );
17
+ } );
18
+
19
+ it( 'returns 0 for index 0', () => {
20
+ expect(
21
+ htmlIndexToRichTextOffset( '<strong>bold</strong> text', 0 )
22
+ ).toBe( 0 );
23
+ } );
24
+
25
+ it( 'skips a simple opening tag', () => {
26
+ // "<strong>bold</strong> text"
27
+ // HTML index 8 = first char of "bold" (right after <strong>)
28
+ expect(
29
+ htmlIndexToRichTextOffset( '<strong>bold</strong> text', 8 )
30
+ ).toBe( 0 );
31
+ } );
32
+
33
+ it( 'counts text characters inside a tag', () => {
34
+ // "<strong>bold</strong> text"
35
+ // HTML index 10 = 'l' in "bold" (after "bo")
36
+ // Text: "bold text", offset should be 2
37
+ expect(
38
+ htmlIndexToRichTextOffset( '<strong>bold</strong> text', 10 )
39
+ ).toBe( 2 );
40
+ } );
41
+
42
+ it( 'skips closing tags', () => {
43
+ // "<strong>bold</strong> text"
44
+ // HTML index 21 = ' ' (the space after </strong>)
45
+ // Text: "bold text", offset should be 4
46
+ expect(
47
+ htmlIndexToRichTextOffset( '<strong>bold</strong> text', 21 )
48
+ ).toBe( 4 );
49
+ } );
50
+
51
+ it( 'handles text before a tag', () => {
52
+ // "some <strong>words</strong> test"
53
+ // HTML index 3 = 'e' in "some"
54
+ expect(
55
+ htmlIndexToRichTextOffset( 'some <strong>words</strong> test', 3 )
56
+ ).toBe( 3 );
57
+ } );
58
+
59
+ it( 'handles an index right before a tag', () => {
60
+ // "some <strong>words</strong> test"
61
+ // HTML index 5 = '<' of <strong>
62
+ expect(
63
+ htmlIndexToRichTextOffset( 'some <strong>words</strong> test', 5 )
64
+ ).toBe( 5 );
65
+ } );
66
+
67
+ it( 'handles an index right after an opening tag', () => {
68
+ // "some <strong>words</strong> test"
69
+ // HTML index 13 = 'w' of "words" (right after <strong>)
70
+ expect(
71
+ htmlIndexToRichTextOffset( 'some <strong>words</strong> test', 13 )
72
+ ).toBe( 5 );
73
+ } );
74
+
75
+ it( 'handles an index at the end of formatted text', () => {
76
+ // "some <strong>words</strong> test"
77
+ // HTML index 18 = '<' of </strong>
78
+ // Text offset should be 10 ("some words")
79
+ expect(
80
+ htmlIndexToRichTextOffset( 'some <strong>words</strong> test', 18 )
81
+ ).toBe( 10 );
82
+ } );
83
+
84
+ it( 'handles an index after the closing tag', () => {
85
+ // "some <strong>words</strong> test"
86
+ // HTML index 27 = ' ' after </strong>
87
+ expect(
88
+ htmlIndexToRichTextOffset( 'some <strong>words</strong> test', 27 )
89
+ ).toBe( 10 );
90
+ } );
91
+
92
+ it( 'handles the end of the string', () => {
93
+ const html = 'some <strong>words</strong> test';
94
+ expect( htmlIndexToRichTextOffset( html, html.length ) ).toBe( 15 );
95
+ } );
96
+
97
+ it( 'handles nested tags', () => {
98
+ // "a<strong><em>b</em></strong>c"
99
+ // Text: "abc"
100
+ // HTML index 13 = 'b' (after <strong><em>)
101
+ expect(
102
+ htmlIndexToRichTextOffset( 'a<strong><em>b</em></strong>c', 13 )
103
+ ).toBe( 1 );
104
+ // HTML index 28 = 'c' (after </strong>)
105
+ expect(
106
+ htmlIndexToRichTextOffset( 'a<strong><em>b</em></strong>c', 28 )
107
+ ).toBe( 2 );
108
+ } );
109
+
110
+ it( 'handles tags with attributes', () => {
111
+ // '<a href="https://example.com">link</a> text'
112
+ // HTML index 30 = 'l' in "link"
113
+ expect(
114
+ htmlIndexToRichTextOffset(
115
+ '<a href="https://example.com">link</a> text',
116
+ 30
117
+ )
118
+ ).toBe( 0 );
119
+ } );
120
+
121
+ it( 'handles HTML entity &amp;', () => {
122
+ // "Tom &amp; Jerry"
123
+ // Text: "Tom & Jerry" (11 chars)
124
+ // HTML index 4 = '&' start of &amp;
125
+ expect( htmlIndexToRichTextOffset( 'Tom &amp; Jerry', 4 ) ).toBe( 4 );
126
+ // HTML index 9 = ' ' after &amp;
127
+ expect( htmlIndexToRichTextOffset( 'Tom &amp; Jerry', 9 ) ).toBe( 5 );
128
+ } );
129
+
130
+ it( 'handles HTML entity &lt;', () => {
131
+ // "a &lt; b"
132
+ // Text: "a < b" (5 chars)
133
+ // HTML index 2 = '&' start of &lt;
134
+ expect( htmlIndexToRichTextOffset( 'a &lt; b', 2 ) ).toBe( 2 );
135
+ // HTML index 6 = ' ' after &lt;
136
+ expect( htmlIndexToRichTextOffset( 'a &lt; b', 6 ) ).toBe( 3 );
137
+ } );
138
+
139
+ it( 'handles numeric character references', () => {
140
+ // "a&#38;b" (&#38; = &)
141
+ // Text: "a&b" (3 chars)
142
+ // HTML index 1 = '&' start of &#38;
143
+ expect( htmlIndexToRichTextOffset( 'a&#38;b', 1 ) ).toBe( 1 );
144
+ // HTML index 6 = 'b'
145
+ expect( htmlIndexToRichTextOffset( 'a&#38;b', 6 ) ).toBe( 2 );
146
+ } );
147
+
148
+ // These tests document the behavior when htmlIndex lands inside an
149
+ // HTML tag or entity, possible from non-synced peers. The marker is
150
+ // inserted at the raw index, which may break the HTML, but create()
151
+ // produces a best-effort parse. Pinning the current behavior here so
152
+ // any future changes are intentional.
153
+
154
+ it( 'handles an htmlIndex pointing inside an opening tag', () => {
155
+ // "some <strong>words</strong> test"
156
+ // HTML index 7 = 'n' inside <strong>
157
+ // The marker breaks the tag, so create() treats the broken
158
+ // fragments as text. The marker position in the resulting
159
+ // (corrupted) text happens to equal the raw htmlIndex.
160
+ const result = htmlIndexToRichTextOffset(
161
+ 'some <strong>words</strong> test',
162
+ 7
163
+ );
164
+ expect( typeof result ).toBe( 'number' );
165
+ expect( result ).toBe( 7 );
166
+ } );
167
+
168
+ it( 'handles an htmlIndex pointing inside a closing tag', () => {
169
+ // "some <strong>words</strong> test"
170
+ // HTML index 20 = 't' inside </strong>
171
+ // Same as above, the broken closing tag becomes text.
172
+ const result = htmlIndexToRichTextOffset(
173
+ 'some <strong>words</strong> test',
174
+ 20
175
+ );
176
+ expect( typeof result ).toBe( 'number' );
177
+ expect( result ).toBe( 20 );
178
+ } );
179
+
180
+ it( 'handles an htmlIndex pointing inside an entity', () => {
181
+ // "Tom &amp; Jerry"
182
+ // HTML index 6 = 'p' inside &amp;
183
+ // The broken entity is not parsed, so the raw text including
184
+ // the marker is preserved and the position equals htmlIndex.
185
+ const result = htmlIndexToRichTextOffset( 'Tom &amp; Jerry', 6 );
186
+ expect( typeof result ).toBe( 'number' );
187
+ expect( result ).toBe( 6 );
188
+ } );
189
+
190
+ it( 'handles self-closing tags like <br />', () => {
191
+ // "line1<br />line2"
192
+ // Gutenberg's rich-text treats <br> as a line separator character,
193
+ // so text = "line1\u2028line2" (11 chars). HTML index 11 = 'l' of
194
+ // "line2" → rich-text offset 6 (after "line1" + line separator).
195
+ expect( htmlIndexToRichTextOffset( 'line1<br />line2', 11 ) ).toBe( 6 );
196
+ } );
197
+
198
+ it( 'handles multiple adjacent tags', () => {
199
+ // "<em><strong>text</strong></em>"
200
+ // HTML index 12 = 't' (after <em><strong>)
201
+ expect(
202
+ htmlIndexToRichTextOffset( '<em><strong>text</strong></em>', 12 )
203
+ ).toBe( 0 );
204
+ } );
205
+
206
+ it( 'handles empty content', () => {
207
+ expect( htmlIndexToRichTextOffset( '', 0 ) ).toBe( 0 );
208
+ } );
209
+
210
+ it( 'handles tag attribute containing ">" inside quotes', () => {
211
+ // '<a title="a>b">link</a>'
212
+ // The DOM parser correctly handles > inside quoted attributes.
213
+ // HTML index 15 = 'l' in "link", rich-text offset = 0.
214
+ const html = '<a title="a>b">link</a>';
215
+ const result = htmlIndexToRichTextOffset( html, 15 );
216
+ expect( result ).toBe( 0 );
217
+ } );
218
+ } );
219
+
220
+ describe( 'richTextOffsetToHtmlIndex', () => {
221
+ it( 'returns the offset unchanged when there are no tags', () => {
222
+ expect( richTextOffsetToHtmlIndex( 'hello world', 5 ) ).toBe( 5 );
223
+ } );
224
+
225
+ it( 'returns position after the opening tag for offset 0 with tags', () => {
226
+ // Rich-text offset 0 = 'b' → HTML index 8 (after <strong>)
227
+ expect(
228
+ richTextOffsetToHtmlIndex( '<strong>bold</strong> text', 0 )
229
+ ).toBe( 8 );
230
+ } );
231
+
232
+ it( 'maps offset inside a formatted word', () => {
233
+ // "some <strong>words</strong> test"
234
+ // Rich-text offset 5 = 'w' → HTML index 13 (after <strong>)
235
+ expect(
236
+ richTextOffsetToHtmlIndex( 'some <strong>words</strong> test', 5 )
237
+ ).toBe( 13 );
238
+ } );
239
+
240
+ it( 'maps offset at the middle of a formatted word', () => {
241
+ // Rich-text offset 7 = 'r' in "words" → HTML index 15
242
+ expect(
243
+ richTextOffsetToHtmlIndex( 'some <strong>words</strong> test', 7 )
244
+ ).toBe( 15 );
245
+ } );
246
+
247
+ it( 'maps offset right after a formatted word', () => {
248
+ // Rich-text offset 10 = ' ' after "words" → HTML index 27 (after </strong>)
249
+ expect(
250
+ richTextOffsetToHtmlIndex( 'some <strong>words</strong> test', 10 )
251
+ ).toBe( 27 );
252
+ } );
253
+
254
+ it( 'maps offset before any tags', () => {
255
+ // Rich-text offset 3 = 'e' in "some"
256
+ expect(
257
+ richTextOffsetToHtmlIndex( 'some <strong>words</strong> test', 3 )
258
+ ).toBe( 3 );
259
+ } );
260
+
261
+ it( 'maps offset at end of string', () => {
262
+ const html = 'some <strong>words</strong> test';
263
+ // Rich-text offset 15 = end of "some words test"
264
+ expect( richTextOffsetToHtmlIndex( html, 15 ) ).toBe( html.length );
265
+ } );
266
+
267
+ it( 'handles nested formatting', () => {
268
+ // "a<strong><em>b</em></strong>c"
269
+ // Rich-text offset 1 = 'b' → HTML index 13
270
+ expect(
271
+ richTextOffsetToHtmlIndex( 'a<strong><em>b</em></strong>c', 1 )
272
+ ).toBe( 13 );
273
+ } );
274
+
275
+ it( 'handles tags with attributes', () => {
276
+ // '<a href="https://example.com">link</a> text'
277
+ // Rich-text offset 0 = 'l' → HTML index 30
278
+ expect(
279
+ richTextOffsetToHtmlIndex(
280
+ '<a href="https://example.com">link</a> text',
281
+ 0
282
+ )
283
+ ).toBe( 30 );
284
+ } );
285
+
286
+ it( 'is the inverse of htmlIndexToRichTextOffset for text positions', () => {
287
+ const html = 'some <strong>words</strong> test';
288
+ const textPositions = [ 0, 3, 5, 7, 10, 15 ];
289
+
290
+ for ( const textOffset of textPositions ) {
291
+ const htmlIndex = richTextOffsetToHtmlIndex( html, textOffset );
292
+ const roundTripped = htmlIndexToRichTextOffset( html, htmlIndex );
293
+ expect( roundTripped ).toBe( textOffset );
294
+ }
295
+ } );
296
+
297
+ it( 'handles empty string', () => {
298
+ expect( richTextOffsetToHtmlIndex( '', 0 ) ).toBe( 0 );
299
+ } );
300
+
301
+ it( 'handles HTML entity &amp;', () => {
302
+ // "Tom &amp; Jerry"
303
+ // Text: "Tom & Jerry" (11 chars)
304
+ // Rich-text offset 4 = '&' → HTML index 4 (start of &amp;)
305
+ expect( richTextOffsetToHtmlIndex( 'Tom &amp; Jerry', 4 ) ).toBe( 4 );
306
+ // Rich-text offset 5 = ' ' after '&' → HTML index 9 (after &amp;)
307
+ expect( richTextOffsetToHtmlIndex( 'Tom &amp; Jerry', 5 ) ).toBe( 9 );
308
+ } );
309
+
310
+ it( 'handles HTML entity &lt;', () => {
311
+ // "a &lt; b"
312
+ // Text: "a < b" (5 chars)
313
+ // Rich-text offset 2 = '<' → HTML index 2 (start of &lt;)
314
+ expect( richTextOffsetToHtmlIndex( 'a &lt; b', 2 ) ).toBe( 2 );
315
+ // Rich-text offset 3 = ' ' after '<' → HTML index 6 (after &lt;)
316
+ expect( richTextOffsetToHtmlIndex( 'a &lt; b', 3 ) ).toBe( 6 );
317
+ } );
318
+
319
+ it( 'handles numeric character references', () => {
320
+ // "a&#38;b" (&#38; = &)
321
+ // Text: "a&b" (3 chars)
322
+ // Rich-text offset 1 = '&' → HTML index 1 (start of &#38;)
323
+ expect( richTextOffsetToHtmlIndex( 'a&#38;b', 1 ) ).toBe( 1 );
324
+ // Rich-text offset 2 = 'b' → HTML index 6 (after &#38;)
325
+ expect( richTextOffsetToHtmlIndex( 'a&#38;b', 2 ) ).toBe( 6 );
326
+ } );
327
+
328
+ it( 'handles multiple formatted ranges', () => {
329
+ // "a<strong>b</strong>c<em>d</em>e"
330
+ // Text: "abcde"
331
+ // Offset 1 = 'b' → HTML index 9 (after <strong>)
332
+ expect(
333
+ richTextOffsetToHtmlIndex( 'a<strong>b</strong>c<em>d</em>e', 1 )
334
+ ).toBe( 9 );
335
+ // Offset 3 = 'd' → HTML index 24 (after <em>)
336
+ expect(
337
+ richTextOffsetToHtmlIndex( 'a<strong>b</strong>c<em>d</em>e', 3 )
338
+ ).toBe( 24 );
339
+ } );
340
+ } );
341
+
342
+ describe( 'round-trip consistency', () => {
343
+ const testCases: [ string, string ][] = [
344
+ [ 'plain text', 'hello world' ],
345
+ [ 'single bold', 'some <strong>words</strong> test' ],
346
+ [ 'nested formatting', 'a<strong><em>bc</em>d</strong>e' ],
347
+ [
348
+ 'link with attributes',
349
+ '<a href="https://example.com">link</a> text',
350
+ ],
351
+ [ 'multiple ranges', 'a<strong>b</strong>c<em>d</em>e' ],
352
+ [ 'adjacent tags', '<em>a</em><strong>b</strong>' ],
353
+ [ 'entity &amp;', 'Tom &amp; Jerry' ],
354
+ [ 'entity &lt;', 'a &lt; b' ],
355
+ [ 'numeric entity &#38;', 'a&#38;b' ],
356
+ ];
357
+
358
+ for ( const [ label, html ] of testCases ) {
359
+ it( `round-trips all text positions for: ${ label }`, () => {
360
+ // Determine total text length by finding max valid offset.
361
+ // Walk the HTML and count text chars for total length.
362
+ const totalTextLen = htmlIndexToRichTextOffset( html, html.length );
363
+
364
+ for (
365
+ let textOffset = 0;
366
+ textOffset <= totalTextLen;
367
+ textOffset++
368
+ ) {
369
+ const htmlIndex = richTextOffsetToHtmlIndex( html, textOffset );
370
+ const roundTripped = htmlIndexToRichTextOffset(
371
+ html,
372
+ htmlIndex
373
+ );
374
+ expect( {
375
+ label,
376
+ textOffset,
377
+ htmlIndex,
378
+ roundTripped,
379
+ } ).toMatchObject( {
380
+ label,
381
+ textOffset,
382
+ roundTripped: textOffset,
383
+ } );
384
+ }
385
+ } );
386
+ }
387
+ } );