@wordpress/core-data 7.39.1-next.v.202602091733.0 → 7.40.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 (124) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/README.md +41 -0
  3. package/build/actions.cjs +52 -0
  4. package/build/actions.cjs.map +2 -2
  5. package/build/awareness/base-awareness.cjs +1 -8
  6. package/build/awareness/base-awareness.cjs.map +2 -2
  7. package/build/awareness/types.cjs.map +1 -1
  8. package/build/awareness/utils.cjs +8 -51
  9. package/build/awareness/utils.cjs.map +2 -2
  10. package/build/entities.cjs +7 -1
  11. package/build/entities.cjs.map +2 -2
  12. package/build/hooks/use-entity-block-editor.cjs +13 -19
  13. package/build/hooks/use-entity-block-editor.cjs.map +2 -2
  14. package/build/index.cjs +6 -1
  15. package/build/index.cjs.map +2 -2
  16. package/build/private-actions.cjs +8 -0
  17. package/build/private-actions.cjs.map +2 -2
  18. package/build/private-apis.cjs +2 -1
  19. package/build/private-apis.cjs.map +2 -2
  20. package/build/private-selectors.cjs +5 -0
  21. package/build/private-selectors.cjs.map +2 -2
  22. package/build/reducer.cjs +31 -1
  23. package/build/reducer.cjs.map +2 -2
  24. package/build/resolvers.cjs +26 -1
  25. package/build/resolvers.cjs.map +2 -2
  26. package/build/selectors.cjs +15 -0
  27. package/build/selectors.cjs.map +2 -2
  28. package/build/utils/crdt-blocks.cjs +5 -3
  29. package/build/utils/crdt-blocks.cjs.map +2 -2
  30. package/build/utils/crdt.cjs +23 -19
  31. package/build/utils/crdt.cjs.map +2 -2
  32. package/build-module/actions.mjs +50 -0
  33. package/build-module/actions.mjs.map +2 -2
  34. package/build-module/awareness/base-awareness.mjs +1 -8
  35. package/build-module/awareness/base-awareness.mjs.map +2 -2
  36. package/build-module/awareness/utils.mjs +8 -51
  37. package/build-module/awareness/utils.mjs.map +2 -2
  38. package/build-module/entities.mjs +7 -1
  39. package/build-module/entities.mjs.map +2 -2
  40. package/build-module/hooks/use-entity-block-editor.mjs +13 -19
  41. package/build-module/hooks/use-entity-block-editor.mjs.map +2 -2
  42. package/build-module/index.mjs +3 -0
  43. package/build-module/index.mjs.map +2 -2
  44. package/build-module/private-actions.mjs +7 -0
  45. package/build-module/private-actions.mjs.map +2 -2
  46. package/build-module/private-apis.mjs +6 -2
  47. package/build-module/private-apis.mjs.map +2 -2
  48. package/build-module/private-selectors.mjs +8 -1
  49. package/build-module/private-selectors.mjs.map +2 -2
  50. package/build-module/reducer.mjs +29 -1
  51. package/build-module/reducer.mjs.map +2 -2
  52. package/build-module/resolvers.mjs +25 -1
  53. package/build-module/resolvers.mjs.map +2 -2
  54. package/build-module/selectors.mjs +14 -0
  55. package/build-module/selectors.mjs.map +2 -2
  56. package/build-module/utils/crdt-blocks.mjs +3 -2
  57. package/build-module/utils/crdt-blocks.mjs.map +2 -2
  58. package/build-module/utils/crdt.mjs +25 -20
  59. package/build-module/utils/crdt.mjs.map +2 -2
  60. package/build-types/actions.d.ts +12 -0
  61. package/build-types/actions.d.ts.map +1 -1
  62. package/build-types/awareness/base-awareness.d.ts.map +1 -1
  63. package/build-types/awareness/test/awareness-state.d.ts +2 -0
  64. package/build-types/awareness/test/awareness-state.d.ts.map +1 -0
  65. package/build-types/awareness/test/base-awareness.d.ts +2 -0
  66. package/build-types/awareness/test/base-awareness.d.ts.map +1 -0
  67. package/build-types/awareness/test/post-editor-awareness.d.ts +2 -0
  68. package/build-types/awareness/test/post-editor-awareness.d.ts.map +1 -0
  69. package/build-types/awareness/test/typed-awareness.d.ts +2 -0
  70. package/build-types/awareness/test/typed-awareness.d.ts.map +1 -0
  71. package/build-types/awareness/test/utils.d.ts +2 -0
  72. package/build-types/awareness/test/utils.d.ts.map +1 -0
  73. package/build-types/awareness/types.d.ts +0 -1
  74. package/build-types/awareness/types.d.ts.map +1 -1
  75. package/build-types/awareness/utils.d.ts +2 -3
  76. package/build-types/awareness/utils.d.ts.map +1 -1
  77. package/build-types/entities.d.ts.map +1 -1
  78. package/build-types/hooks/test/use-post-editor-awareness-state.d.ts +2 -0
  79. package/build-types/hooks/test/use-post-editor-awareness-state.d.ts.map +1 -0
  80. package/build-types/hooks/use-entity-block-editor.d.ts.map +1 -1
  81. package/build-types/index.d.ts +5 -0
  82. package/build-types/index.d.ts.map +1 -1
  83. package/build-types/private-actions.d.ts +8 -0
  84. package/build-types/private-actions.d.ts.map +1 -1
  85. package/build-types/private-apis.d.ts.map +1 -1
  86. package/build-types/private-selectors.d.ts +8 -1
  87. package/build-types/private-selectors.d.ts.map +1 -1
  88. package/build-types/reducer.d.ts +23 -0
  89. package/build-types/reducer.d.ts.map +1 -1
  90. package/build-types/resolvers.d.ts +3 -0
  91. package/build-types/resolvers.d.ts.map +1 -1
  92. package/build-types/selectors.d.ts +17 -0
  93. package/build-types/selectors.d.ts.map +1 -1
  94. package/build-types/utils/crdt-blocks.d.ts +9 -0
  95. package/build-types/utils/crdt-blocks.d.ts.map +1 -1
  96. package/build-types/utils/crdt.d.ts +6 -4
  97. package/build-types/utils/crdt.d.ts.map +1 -1
  98. package/package.json +18 -18
  99. package/src/actions.js +78 -0
  100. package/src/awareness/base-awareness.ts +1 -14
  101. package/src/awareness/test/awareness-state.ts +417 -0
  102. package/src/awareness/test/base-awareness.ts +321 -0
  103. package/src/awareness/test/post-editor-awareness.ts +561 -0
  104. package/src/awareness/test/typed-awareness.ts +148 -0
  105. package/src/awareness/test/utils.ts +305 -0
  106. package/src/awareness/types.ts +0 -1
  107. package/src/awareness/utils.ts +8 -82
  108. package/src/entities.js +7 -1
  109. package/src/hooks/test/use-post-editor-awareness-state.ts +477 -0
  110. package/src/hooks/use-entity-block-editor.js +15 -21
  111. package/src/index.js +7 -0
  112. package/src/private-actions.js +14 -0
  113. package/src/private-apis.js +5 -1
  114. package/src/private-selectors.ts +16 -1
  115. package/src/reducer.js +45 -0
  116. package/src/resolvers.js +31 -2
  117. package/src/selectors.ts +41 -0
  118. package/src/test/actions.js +79 -0
  119. package/src/test/entity-provider.js +74 -0
  120. package/src/test/resolvers.js +2 -0
  121. package/src/test/store.js +30 -0
  122. package/src/utils/crdt-blocks.ts +2 -2
  123. package/src/utils/crdt.ts +44 -29
  124. package/src/utils/test/crdt.ts +212 -7
@@ -0,0 +1,561 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import { Y } from '@wordpress/sync';
5
+ import { dispatch, select, subscribe, resolveSelect } from '@wordpress/data';
6
+
7
+ /**
8
+ * Internal dependencies
9
+ */
10
+ import { PostEditorAwareness } from '../post-editor-awareness';
11
+ import { SelectionType } from '../../utils/crdt-user-selections';
12
+ import type { SelectionNone, SelectionCursor } from '../../types';
13
+ import { CRDT_RECORD_MAP_KEY } from '../../sync';
14
+ import type { CollaboratorInfo } from '../types';
15
+
16
+ // Mock WordPress dependencies
17
+ jest.mock( '@wordpress/data', () => ( {
18
+ dispatch: jest.fn(),
19
+ select: jest.fn(),
20
+ subscribe: jest.fn(),
21
+ resolveSelect: jest.fn(),
22
+ } ) );
23
+
24
+ jest.mock( '@wordpress/block-editor', () => ( {
25
+ store: 'core/block-editor',
26
+ } ) );
27
+
28
+ // Mock window.navigator.userAgent
29
+ const mockUserAgent = ( userAgent: string ) => {
30
+ Object.defineProperty( window.navigator, 'userAgent', {
31
+ value: userAgent,
32
+ configurable: true,
33
+ } );
34
+ };
35
+
36
+ const mockAvatarUrls = {
37
+ '24': 'https://example.com/avatar-24.png',
38
+ '48': 'https://example.com/avatar-48.png',
39
+ '96': 'https://example.com/avatar-96.png',
40
+ };
41
+
42
+ const createMockUser = () => ( {
43
+ id: 1,
44
+ name: 'Test User',
45
+ slug: 'test-user',
46
+ avatar_urls: mockAvatarUrls,
47
+ } );
48
+
49
+ /**
50
+ * Helper function to create a Y.Doc with blocks structure for testing
51
+ */
52
+ function createTestDocWithBlocks() {
53
+ const ydoc = new Y.Doc();
54
+ const documentMap = ydoc.getMap( CRDT_RECORD_MAP_KEY );
55
+ const blocks = new Y.Array();
56
+ documentMap.set( 'blocks', blocks );
57
+
58
+ // Create a block with content
59
+ const block = new Y.Map();
60
+ block.set( 'clientId', 'block-1' );
61
+ const attrs = new Y.Map();
62
+ attrs.set( 'content', new Y.Text( 'Hello world' ) );
63
+ block.set( 'attributes', attrs );
64
+ block.set( 'innerBlocks', new Y.Array() );
65
+ blocks.push( [ block ] );
66
+
67
+ return ydoc;
68
+ }
69
+
70
+ describe( 'PostEditorAwareness', () => {
71
+ let doc: Y.Doc;
72
+ let subscribeCallback: ( () => void ) | null = null;
73
+ let mockEditEntityRecord: jest.Mock;
74
+
75
+ beforeEach( () => {
76
+ jest.useFakeTimers();
77
+ doc = createTestDocWithBlocks();
78
+
79
+ mockUserAgent(
80
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
81
+ );
82
+
83
+ jest.spyOn( Date, 'now' ).mockReturnValue( 1704067200000 );
84
+
85
+ // Mock select to return block editor selectors
86
+ ( select as jest.Mock ).mockReturnValue( {
87
+ getSelectionStart: jest.fn().mockReturnValue( {} ),
88
+ getSelectionEnd: jest.fn().mockReturnValue( {} ),
89
+ getSelectedBlocksInitialCaretPosition: jest
90
+ .fn()
91
+ .mockReturnValue( null ),
92
+ } );
93
+
94
+ // Mock subscribe to capture the callback
95
+ ( subscribe as jest.Mock ).mockImplementation( ( callback ) => {
96
+ subscribeCallback = callback;
97
+ return jest.fn(); // unsubscribe
98
+ } );
99
+
100
+ // Mock dispatch
101
+ mockEditEntityRecord = jest.fn();
102
+ ( dispatch as jest.Mock ).mockReturnValue( {
103
+ editEntityRecord: mockEditEntityRecord,
104
+ } );
105
+
106
+ // Mock resolveSelect for getCurrentUser
107
+ ( resolveSelect as jest.Mock ).mockReturnValue( {
108
+ getCurrentUser: jest.fn().mockResolvedValue( createMockUser() ),
109
+ } );
110
+ } );
111
+
112
+ afterEach( () => {
113
+ jest.useRealTimers();
114
+ jest.restoreAllMocks();
115
+ subscribeCallback = null;
116
+ doc.destroy();
117
+ } );
118
+
119
+ describe( 'construction', () => {
120
+ test( 'should create instance with Y.Doc and entity info', () => {
121
+ const awareness = new PostEditorAwareness(
122
+ doc,
123
+ 'postType',
124
+ 'post',
125
+ 123
126
+ );
127
+ expect( awareness ).toBeInstanceOf( PostEditorAwareness );
128
+ } );
129
+
130
+ test( 'should have correct clientID from doc', () => {
131
+ const awareness = new PostEditorAwareness(
132
+ doc,
133
+ 'postType',
134
+ 'post',
135
+ 123
136
+ );
137
+ expect( awareness.clientID ).toBe( doc.clientID );
138
+ } );
139
+ } );
140
+
141
+ describe( 'setUp', () => {
142
+ test( 'should be idempotent', () => {
143
+ const awareness = new PostEditorAwareness(
144
+ doc,
145
+ 'postType',
146
+ 'post',
147
+ 123
148
+ );
149
+
150
+ awareness.setUp();
151
+ awareness.setUp();
152
+
153
+ // Subscribe should only be called once
154
+ expect( subscribe ).toHaveBeenCalledTimes( 1 );
155
+ } );
156
+
157
+ test( 'should subscribe to selection changes', () => {
158
+ const awareness = new PostEditorAwareness(
159
+ doc,
160
+ 'postType',
161
+ 'post',
162
+ 123
163
+ );
164
+
165
+ awareness.setUp();
166
+
167
+ expect( subscribe ).toHaveBeenCalled();
168
+ expect( subscribeCallback ).not.toBeNull();
169
+ } );
170
+ } );
171
+
172
+ describe( 'selection change handling', () => {
173
+ test( 'should not trigger update when selection has not changed', () => {
174
+ const awareness = new PostEditorAwareness(
175
+ doc,
176
+ 'postType',
177
+ 'post',
178
+ 123
179
+ );
180
+ awareness.setUp();
181
+
182
+ // Trigger subscribe callback with same selection
183
+ subscribeCallback?.();
184
+
185
+ // Should not call editEntityRecord for unchanged selection
186
+ expect( mockEditEntityRecord ).not.toHaveBeenCalled();
187
+ } );
188
+
189
+ test( 'should trigger update when selection changes', () => {
190
+ const mockGetSelectionStart = jest
191
+ .fn()
192
+ .mockReturnValueOnce( {} )
193
+ .mockReturnValueOnce( {
194
+ clientId: 'block-1',
195
+ attributeKey: 'content',
196
+ offset: 5,
197
+ } );
198
+
199
+ const mockGetSelectionEnd = jest
200
+ .fn()
201
+ .mockReturnValueOnce( {} )
202
+ .mockReturnValueOnce( {
203
+ clientId: 'block-1',
204
+ attributeKey: 'content',
205
+ offset: 5,
206
+ } );
207
+
208
+ ( select as jest.Mock ).mockReturnValue( {
209
+ getSelectionStart: mockGetSelectionStart,
210
+ getSelectionEnd: mockGetSelectionEnd,
211
+ getSelectedBlocksInitialCaretPosition: jest
212
+ .fn()
213
+ .mockReturnValue( null ),
214
+ } );
215
+
216
+ const awareness = new PostEditorAwareness(
217
+ doc,
218
+ 'postType',
219
+ 'post',
220
+ 123
221
+ );
222
+ awareness.setUp();
223
+
224
+ // Trigger subscribe callback with new selection
225
+ subscribeCallback?.();
226
+
227
+ // Should call editEntityRecord
228
+ expect( mockEditEntityRecord ).toHaveBeenCalledWith(
229
+ 'postType',
230
+ 'post',
231
+ 123,
232
+ expect.objectContaining( {
233
+ selection: expect.any( Object ),
234
+ } ),
235
+ expect.objectContaining( {
236
+ undoIgnore: true,
237
+ } )
238
+ );
239
+ } );
240
+
241
+ test( 'should debounce local cursor updates', () => {
242
+ const mockGetSelectionStart = jest
243
+ .fn()
244
+ .mockReturnValueOnce( {} )
245
+ .mockReturnValueOnce( {
246
+ clientId: 'block-1',
247
+ attributeKey: 'content',
248
+ offset: 5,
249
+ } );
250
+
251
+ const mockGetSelectionEnd = jest
252
+ .fn()
253
+ .mockReturnValueOnce( {} )
254
+ .mockReturnValueOnce( {
255
+ clientId: 'block-1',
256
+ attributeKey: 'content',
257
+ offset: 5,
258
+ } );
259
+
260
+ ( select as jest.Mock ).mockReturnValue( {
261
+ getSelectionStart: mockGetSelectionStart,
262
+ getSelectionEnd: mockGetSelectionEnd,
263
+ getSelectedBlocksInitialCaretPosition: jest
264
+ .fn()
265
+ .mockReturnValue( null ),
266
+ } );
267
+
268
+ const awareness = new PostEditorAwareness(
269
+ doc,
270
+ 'postType',
271
+ 'post',
272
+ 123
273
+ );
274
+ awareness.setUp();
275
+
276
+ // Trigger selection change
277
+ subscribeCallback?.();
278
+
279
+ // Advance timers past debounce
280
+ jest.advanceTimersByTime( 10 );
281
+
282
+ // Should have processed the debounced update
283
+ expect( mockEditEntityRecord ).toHaveBeenCalled();
284
+ } );
285
+ } );
286
+
287
+ describe( 'areEditorStatesEqual', () => {
288
+ test( 'should return true when both states are undefined', () => {
289
+ const awareness = new PostEditorAwareness(
290
+ doc,
291
+ 'postType',
292
+ 'post',
293
+ 123
294
+ );
295
+
296
+ // Access the protected method via testing
297
+ // We can test this indirectly through setLocalStateField behavior
298
+ awareness.setUp();
299
+
300
+ // Set editorState with a selection
301
+ const selectionState: SelectionNone = {
302
+ type: SelectionType.None,
303
+ };
304
+
305
+ awareness.setLocalStateField( 'editorState', {
306
+ selection: selectionState,
307
+ } );
308
+
309
+ // Subscribe to track updates
310
+ const callback = jest.fn();
311
+ awareness.onStateChange( callback );
312
+
313
+ // Emit change event
314
+ awareness.emit( 'change', [
315
+ {
316
+ added: [],
317
+ updated: [ awareness.clientID ],
318
+ removed: [],
319
+ },
320
+ ] );
321
+ callback.mockClear();
322
+
323
+ // Set same state again - should not trigger unnecessary updates
324
+ awareness.setLocalStateField( 'editorState', {
325
+ selection: selectionState,
326
+ } );
327
+
328
+ // Emit change event again
329
+ awareness.emit( 'change', [
330
+ {
331
+ added: [],
332
+ updated: [ awareness.clientID ],
333
+ removed: [],
334
+ },
335
+ ] );
336
+
337
+ // Callback should not be called for equal editor states
338
+ expect( callback ).not.toHaveBeenCalled();
339
+ } );
340
+ } );
341
+
342
+ describe( 'getAbsolutePositionIndex', () => {
343
+ test( 'should return null when relative position cannot be resolved', () => {
344
+ const awareness = new PostEditorAwareness(
345
+ doc,
346
+ 'postType',
347
+ 'post',
348
+ 123
349
+ );
350
+
351
+ // Create a Y.Doc for creating a relative position, then destroy it
352
+ // This creates a relative position that cannot be resolved in the awareness doc
353
+ const tempDoc = new Y.Doc();
354
+ const tempText = tempDoc.getText( 'temp' );
355
+ tempText.insert( 0, 'Hello' );
356
+ const relativePosition = Y.createRelativePositionFromTypeIndex(
357
+ tempText,
358
+ 2
359
+ );
360
+ tempDoc.destroy();
361
+
362
+ const selection: SelectionCursor = {
363
+ type: SelectionType.Cursor,
364
+ blockId: 'block-1',
365
+ cursorPosition: {
366
+ relativePosition,
367
+ absoluteOffset: 2,
368
+ },
369
+ };
370
+
371
+ const result = awareness.getAbsolutePositionIndex( selection );
372
+
373
+ // Should return null when the relative position's type cannot be found
374
+ expect( result ).toBeNull();
375
+ } );
376
+
377
+ test( 'should return absolute position index for valid selection', () => {
378
+ const awareness = new PostEditorAwareness(
379
+ doc,
380
+ 'postType',
381
+ 'post',
382
+ 123
383
+ );
384
+
385
+ // Get the Y.Text from the doc
386
+ const documentMap = doc.getMap( CRDT_RECORD_MAP_KEY );
387
+ const blocks = documentMap.get( 'blocks' ) as Y.Array<
388
+ Y.Map< any >
389
+ >;
390
+ const block = blocks.get( 0 );
391
+ const attrs = block.get( 'attributes' ) as Y.Map< Y.Text >;
392
+ const yText = attrs.get( 'content' );
393
+
394
+ // Create a relative position
395
+ const relativePosition = Y.createRelativePositionFromTypeIndex(
396
+ yText as Y.Text,
397
+ 5
398
+ );
399
+
400
+ const selection: SelectionCursor = {
401
+ type: SelectionType.Cursor,
402
+ blockId: 'block-1',
403
+ cursorPosition: {
404
+ relativePosition,
405
+ absoluteOffset: 5,
406
+ },
407
+ };
408
+
409
+ const result = awareness.getAbsolutePositionIndex( selection );
410
+
411
+ expect( result ).toBe( 5 );
412
+ } );
413
+ } );
414
+
415
+ describe( 'getDebugData', () => {
416
+ test( 'should return debug data object', async () => {
417
+ const awareness = new PostEditorAwareness(
418
+ doc,
419
+ 'postType',
420
+ 'post',
421
+ 123
422
+ );
423
+ awareness.setUp();
424
+
425
+ // Wait for async setup
426
+ await Promise.resolve();
427
+
428
+ // Emit a change to populate seenStates
429
+ awareness.emit( 'change', [
430
+ {
431
+ added: [],
432
+ updated: [ awareness.clientID ],
433
+ removed: [],
434
+ },
435
+ ] );
436
+
437
+ const debugData = awareness.getDebugData();
438
+
439
+ expect( debugData ).toHaveProperty( 'doc' );
440
+ expect( debugData ).toHaveProperty( 'clients' );
441
+ expect( debugData ).toHaveProperty( 'collaboratorMap' );
442
+ } );
443
+
444
+ test( 'should include document data', async () => {
445
+ const awareness = new PostEditorAwareness(
446
+ doc,
447
+ 'postType',
448
+ 'post',
449
+ 123
450
+ );
451
+ awareness.setUp();
452
+ await Promise.resolve();
453
+
454
+ const debugData = awareness.getDebugData();
455
+
456
+ expect( debugData.doc ).toHaveProperty( CRDT_RECORD_MAP_KEY );
457
+ } );
458
+
459
+ test( 'should include client items', async () => {
460
+ const awareness = new PostEditorAwareness(
461
+ doc,
462
+ 'postType',
463
+ 'post',
464
+ 123
465
+ );
466
+ awareness.setUp();
467
+ await Promise.resolve();
468
+
469
+ const debugData = awareness.getDebugData();
470
+
471
+ expect( debugData.clients ).toBeDefined();
472
+ expect( typeof debugData.clients ).toBe( 'object' );
473
+ } );
474
+ } );
475
+
476
+ describe( 'equalityFieldChecks', () => {
477
+ test( 'should include collaboratorInfo check from baseEqualityFieldChecks', async () => {
478
+ const awareness = new PostEditorAwareness(
479
+ doc,
480
+ 'postType',
481
+ 'post',
482
+ 123
483
+ );
484
+ awareness.setUp();
485
+ await Promise.resolve();
486
+
487
+ // Set collaboratorInfo and verify equality check works
488
+ const collaboratorInfo: CollaboratorInfo = {
489
+ id: 1,
490
+ name: 'Test',
491
+ slug: 'test',
492
+ avatar_urls: mockAvatarUrls,
493
+ browserType: 'Chrome',
494
+ enteredAt: 1704067200000,
495
+ };
496
+
497
+ awareness.setLocalStateField(
498
+ 'collaboratorInfo',
499
+ collaboratorInfo
500
+ );
501
+ const storedInfo =
502
+ awareness.getLocalStateField( 'collaboratorInfo' );
503
+
504
+ expect( storedInfo ).toEqual( collaboratorInfo );
505
+ } );
506
+
507
+ test( 'should include editorState check', async () => {
508
+ const awareness = new PostEditorAwareness(
509
+ doc,
510
+ 'postType',
511
+ 'post',
512
+ 123
513
+ );
514
+ awareness.setUp();
515
+ await Promise.resolve();
516
+
517
+ const editorState = {
518
+ selection: {
519
+ type: SelectionType.None,
520
+ } as SelectionNone,
521
+ };
522
+
523
+ awareness.setLocalStateField( 'editorState', editorState );
524
+ const storedState = awareness.getLocalStateField( 'editorState' );
525
+
526
+ expect( storedState ).toEqual( editorState );
527
+ } );
528
+ } );
529
+
530
+ describe( 'state subscription', () => {
531
+ test( 'should notify subscribers on editorState change', async () => {
532
+ const awareness = new PostEditorAwareness(
533
+ doc,
534
+ 'postType',
535
+ 'post',
536
+ 123
537
+ );
538
+ const callback = jest.fn();
539
+
540
+ awareness.onStateChange( callback );
541
+ awareness.setUp();
542
+ await Promise.resolve();
543
+
544
+ // Set initial state to trigger callback
545
+ awareness.setLocalStateField( 'editorState', {
546
+ selection: { type: SelectionType.None },
547
+ } );
548
+
549
+ // Emit change event
550
+ awareness.emit( 'change', [
551
+ {
552
+ added: [],
553
+ updated: [ awareness.clientID ],
554
+ removed: [],
555
+ },
556
+ ] );
557
+
558
+ expect( callback ).toHaveBeenCalled();
559
+ } );
560
+ } );
561
+ } );
@@ -0,0 +1,148 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import { describe, expect, test, beforeEach, afterEach } from '@jest/globals';
5
+
6
+ /**
7
+ * WordPress dependencies
8
+ */
9
+ import { Y } from '@wordpress/sync';
10
+
11
+ /**
12
+ * Internal dependencies
13
+ */
14
+ import { TypedAwareness } from '../typed-awareness';
15
+ import type { EnhancedState } from '../types';
16
+
17
+ interface TestState {
18
+ name: string;
19
+ count: number;
20
+ isActive: boolean;
21
+ }
22
+
23
+ describe( 'TypedAwareness', () => {
24
+ let doc: Y.Doc;
25
+ let awareness: TypedAwareness< TestState >;
26
+
27
+ beforeEach( () => {
28
+ doc = new Y.Doc();
29
+ awareness = new TypedAwareness< TestState >( doc );
30
+ } );
31
+
32
+ afterEach( () => {
33
+ doc.destroy();
34
+ } );
35
+
36
+ describe( 'getStates', () => {
37
+ test( 'should return empty map initially', () => {
38
+ const states = awareness.getStates();
39
+ // There should be at least one state (the local client)
40
+ expect( states ).toBeInstanceOf( Map );
41
+ } );
42
+
43
+ test( 'should return map with correct type', () => {
44
+ awareness.setLocalStateField( 'name', 'Test' );
45
+ awareness.setLocalStateField( 'count', 42 );
46
+
47
+ const states = awareness.getStates();
48
+ const localState = states.get( awareness.clientID );
49
+
50
+ expect( localState?.name ).toBe( 'Test' );
51
+ expect( localState?.count ).toBe( 42 );
52
+ } );
53
+ } );
54
+
55
+ describe( 'getLocalStateField', () => {
56
+ test( 'should return null for non-existent field', () => {
57
+ const result = awareness.getLocalStateField( 'name' );
58
+ expect( result ).toBeNull();
59
+ } );
60
+
61
+ test( 'should return field value after setting', () => {
62
+ awareness.setLocalStateField( 'name', 'John' );
63
+ const result = awareness.getLocalStateField( 'name' );
64
+ expect( result ).toBe( 'John' );
65
+ } );
66
+
67
+ test( 'should return correct type for number field', () => {
68
+ awareness.setLocalStateField( 'count', 100 );
69
+ const result = awareness.getLocalStateField( 'count' );
70
+ expect( result ).toBe( 100 );
71
+ } );
72
+
73
+ test( 'should return correct type for boolean field', () => {
74
+ awareness.setLocalStateField( 'isActive', true );
75
+ const result = awareness.getLocalStateField( 'isActive' );
76
+ expect( result ).toBe( true );
77
+ } );
78
+
79
+ test( 'should return null when local state is null', () => {
80
+ // Clear local state
81
+ awareness.setLocalState( null );
82
+ const result = awareness.getLocalStateField( 'name' );
83
+ expect( result ).toBeNull();
84
+ } );
85
+ } );
86
+
87
+ describe( 'setLocalStateField', () => {
88
+ test( 'should set string field', () => {
89
+ awareness.setLocalStateField( 'name', 'Alice' );
90
+ expect( awareness.getLocalStateField( 'name' ) ).toBe( 'Alice' );
91
+ } );
92
+
93
+ test( 'should set number field', () => {
94
+ awareness.setLocalStateField( 'count', 42 );
95
+ expect( awareness.getLocalStateField( 'count' ) ).toBe( 42 );
96
+ } );
97
+
98
+ test( 'should set boolean field', () => {
99
+ awareness.setLocalStateField( 'isActive', false );
100
+ expect( awareness.getLocalStateField( 'isActive' ) ).toBe( false );
101
+ } );
102
+
103
+ test( 'should update existing field value', () => {
104
+ awareness.setLocalStateField( 'name', 'First' );
105
+ awareness.setLocalStateField( 'name', 'Second' );
106
+ expect( awareness.getLocalStateField( 'name' ) ).toBe( 'Second' );
107
+ } );
108
+
109
+ test( 'should preserve other fields when setting one field', () => {
110
+ awareness.setLocalStateField( 'name', 'Test' );
111
+ awareness.setLocalStateField( 'count', 10 );
112
+
113
+ expect( awareness.getLocalStateField( 'name' ) ).toBe( 'Test' );
114
+ expect( awareness.getLocalStateField( 'count' ) ).toBe( 10 );
115
+ } );
116
+ } );
117
+
118
+ describe( 'clientID', () => {
119
+ test( 'should have a numeric clientID', () => {
120
+ expect( typeof awareness.clientID ).toBe( 'number' );
121
+ } );
122
+
123
+ test( 'should have the same clientID as the doc', () => {
124
+ expect( awareness.clientID ).toBe( doc.clientID );
125
+ } );
126
+ } );
127
+ } );
128
+
129
+ describe( 'EnhancedState type', () => {
130
+ test( 'should include base state properties and metadata', () => {
131
+ // This is a compile-time type check
132
+ const enhancedState: EnhancedState< TestState > = {
133
+ name: 'Test',
134
+ count: 5,
135
+ isActive: true,
136
+ clientId: 123,
137
+ isConnected: true,
138
+ isMe: false,
139
+ };
140
+
141
+ expect( enhancedState.name ).toBe( 'Test' );
142
+ expect( enhancedState.count ).toBe( 5 );
143
+ expect( enhancedState.isActive ).toBe( true );
144
+ expect( enhancedState.clientId ).toBe( 123 );
145
+ expect( enhancedState.isConnected ).toBe( true );
146
+ expect( enhancedState.isMe ).toBe( false );
147
+ } );
148
+ } );