@wordpress/block-library 9.45.0 → 9.45.1-next.v.202605131006.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 (84) hide show
  1. package/build/breadcrumbs/edit.cjs +1 -1
  2. package/build/breadcrumbs/edit.cjs.map +1 -1
  3. package/build/buttons/edit.cjs +0 -13
  4. package/build/buttons/edit.cjs.map +3 -3
  5. package/build/comment-reply-link/edit.cjs +2 -1
  6. package/build/comment-reply-link/edit.cjs.map +2 -2
  7. package/build/comments/edit/placeholder.cjs +2 -1
  8. package/build/comments/edit/placeholder.cjs.map +2 -2
  9. package/build/embed/transforms.cjs +24 -1
  10. package/build/embed/transforms.cjs.map +2 -2
  11. package/build/freeform/convert-to-blocks-button.cjs +2 -14
  12. package/build/freeform/convert-to-blocks-button.cjs.map +3 -3
  13. package/build/freeform/edit.cjs +43 -15
  14. package/build/freeform/edit.cjs.map +3 -3
  15. package/build/freeform/index.cjs +7 -1
  16. package/build/freeform/index.cjs.map +3 -3
  17. package/build/freeform/migration-notice.cjs +58 -0
  18. package/build/freeform/migration-notice.cjs.map +7 -0
  19. package/build/image/image.cjs +16 -52
  20. package/build/image/image.cjs.map +3 -3
  21. package/build/image/use-open-image-media-editor-modal.cjs +239 -0
  22. package/build/image/use-open-image-media-editor-modal.cjs.map +7 -0
  23. package/build/index.cjs +1 -5
  24. package/build/index.cjs.map +2 -2
  25. package/build/shortcode/transforms.cjs +27 -1
  26. package/build/shortcode/transforms.cjs.map +2 -2
  27. package/build/site-logo/edit.cjs +1 -0
  28. package/build/site-logo/edit.cjs.map +2 -2
  29. package/build/table/edit.cjs +2 -2
  30. package/build/table/edit.cjs.map +2 -2
  31. package/build-module/breadcrumbs/edit.mjs +1 -1
  32. package/build-module/breadcrumbs/edit.mjs.map +1 -1
  33. package/build-module/buttons/edit.mjs +0 -13
  34. package/build-module/buttons/edit.mjs.map +2 -2
  35. package/build-module/comment-reply-link/edit.mjs +3 -2
  36. package/build-module/comment-reply-link/edit.mjs.map +2 -2
  37. package/build-module/comments/edit/placeholder.mjs +3 -2
  38. package/build-module/comments/edit/placeholder.mjs.map +2 -2
  39. package/build-module/embed/transforms.mjs +24 -1
  40. package/build-module/embed/transforms.mjs.map +2 -2
  41. package/build-module/freeform/convert-to-blocks-button.mjs +3 -15
  42. package/build-module/freeform/convert-to-blocks-button.mjs.map +2 -2
  43. package/build-module/freeform/edit.mjs +44 -16
  44. package/build-module/freeform/edit.mjs.map +2 -2
  45. package/build-module/freeform/index.mjs +7 -1
  46. package/build-module/freeform/index.mjs.map +2 -2
  47. package/build-module/freeform/migration-notice.mjs +37 -0
  48. package/build-module/freeform/migration-notice.mjs.map +7 -0
  49. package/build-module/image/image.mjs +16 -52
  50. package/build-module/image/image.mjs.map +3 -3
  51. package/build-module/image/use-open-image-media-editor-modal.mjs +215 -0
  52. package/build-module/image/use-open-image-media-editor-modal.mjs.map +7 -0
  53. package/build-module/index.mjs +1 -5
  54. package/build-module/index.mjs.map +2 -2
  55. package/build-module/shortcode/transforms.mjs +27 -1
  56. package/build-module/shortcode/transforms.mjs.map +2 -2
  57. package/build-module/site-logo/edit.mjs +1 -0
  58. package/build-module/site-logo/edit.mjs.map +2 -2
  59. package/build-module/table/edit.mjs +3 -3
  60. package/build-module/table/edit.mjs.map +2 -2
  61. package/build-types/table-of-contents/list.d.ts +1 -1
  62. package/build-types/table-of-contents/list.d.ts.map +1 -1
  63. package/package.json +41 -40
  64. package/src/breadcrumbs/edit.js +1 -1
  65. package/src/buttons/edit.js +0 -13
  66. package/src/comment-reply-link/edit.js +5 -2
  67. package/src/comments/edit/placeholder.js +5 -2
  68. package/src/cover/editor.scss +2 -2
  69. package/src/cover/style.scss +10 -6
  70. package/src/embed/transforms.js +30 -4
  71. package/src/freeform/convert-to-blocks-button.js +3 -18
  72. package/src/freeform/edit.js +40 -7
  73. package/src/freeform/index.js +9 -1
  74. package/src/freeform/migration-notice.js +51 -0
  75. package/src/image/image.js +14 -63
  76. package/src/image/test/use-open-image-media-editor-modal.js +791 -0
  77. package/src/image/use-open-image-media-editor-modal.js +337 -0
  78. package/src/index.js +3 -16
  79. package/src/navigation/index.php +11 -1
  80. package/src/query/editor.scss +1 -2
  81. package/src/shortcode/transforms.js +37 -0
  82. package/src/site-logo/edit.js +5 -0
  83. package/src/table/edit.js +3 -3
  84. package/src/template-part/editor.scss +1 -1
@@ -0,0 +1,791 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ /**
6
+ * External dependencies
7
+ */
8
+ import { act, renderHook } from '@testing-library/react';
9
+
10
+ /**
11
+ * WordPress dependencies
12
+ */
13
+ import { useRegistry, useSelect } from '@wordpress/data';
14
+
15
+ /**
16
+ * Internal dependencies
17
+ */
18
+ import {
19
+ getImageBlockMetadataFromAttachment,
20
+ getSyncedImageBlockAttributes,
21
+ useOpenImageMediaEditorModal,
22
+ } from '../use-open-image-media-editor-modal';
23
+
24
+ const mockOpenMediaEditorModalKey = 'openMediaEditorModal';
25
+
26
+ jest.mock( '@wordpress/core-data', () => ( {
27
+ store: {},
28
+ } ) );
29
+
30
+ jest.mock( '@wordpress/data', () => ( {
31
+ useRegistry: jest.fn(),
32
+ useSelect: jest.fn(),
33
+ } ) );
34
+
35
+ jest.mock( '@wordpress/block-editor', () => ( {
36
+ privateApis: {},
37
+ store: {},
38
+ } ) );
39
+
40
+ jest.mock( '../../lock-unlock', () => ( {
41
+ unlock: jest.fn( () => ( {
42
+ openMediaEditorModalKey: 'openMediaEditorModal',
43
+ } ) ),
44
+ } ) );
45
+
46
+ function createRegistry( {
47
+ getEditedEntityRecord = () => false,
48
+ getEntityRecord = () => undefined,
49
+ resolveGetEntityRecord = getEntityRecord,
50
+ } = {} ) {
51
+ const actions = {
52
+ invalidateResolution: jest.fn(),
53
+ };
54
+ return {
55
+ select: jest.fn( () => ( {
56
+ getEditedEntityRecord,
57
+ getEntityRecord,
58
+ } ) ),
59
+ dispatch: jest.fn( () => actions ),
60
+ resolveSelect: jest.fn( () => ( {
61
+ getEntityRecord: resolveGetEntityRecord,
62
+ } ) ),
63
+ actions,
64
+ };
65
+ }
66
+
67
+ function createDeferred() {
68
+ let resolve;
69
+ const promise = new Promise( ( _resolve ) => {
70
+ resolve = _resolve;
71
+ } );
72
+ return { promise, resolve };
73
+ }
74
+
75
+ function mockMediaEditorModalSetting( openMediaEditorModal ) {
76
+ useSelect.mockImplementation( ( mapSelect ) =>
77
+ mapSelect( () => ( {
78
+ getSettings: () => ( {
79
+ [ mockOpenMediaEditorModalKey ]: openMediaEditorModal,
80
+ } ),
81
+ } ) )
82
+ );
83
+ }
84
+
85
+ async function runModalUpdate( {
86
+ attributes,
87
+ registryOptions = {},
88
+ updatePayload = { id: attributes.id, url: 'updated.jpg' },
89
+ } ) {
90
+ const registry = createRegistry( registryOptions );
91
+ useRegistry.mockReturnValue( registry );
92
+ const setAttributes = jest.fn();
93
+ const openMediaEditorModal = jest.fn();
94
+ mockMediaEditorModalSetting( openMediaEditorModal );
95
+ const { result } = renderHook( () =>
96
+ useOpenImageMediaEditorModal( { attributes, setAttributes } )
97
+ );
98
+ await act( async () => {
99
+ await result.current();
100
+ } );
101
+ await act( async () => {
102
+ await openMediaEditorModal.mock.calls[ 0 ][ 0 ].onUpdate(
103
+ updatePayload
104
+ );
105
+ } );
106
+ return { setAttributes, registry, openMediaEditorModal };
107
+ }
108
+
109
+ describe( 'useOpenImageMediaEditorModal', () => {
110
+ beforeEach( () => {
111
+ jest.clearAllMocks();
112
+ } );
113
+
114
+ it( 'resolves fresh attachment metadata when the same attachment id has a stale cache', async () => {
115
+ const originalAttachment = {
116
+ id: 1,
117
+ alt_text: 'Original alt',
118
+ caption: { raw: 'Original caption' },
119
+ };
120
+ const updatedAttachment = {
121
+ id: 1,
122
+ alt_text: 'Updated alt',
123
+ caption: { raw: 'Updated caption' },
124
+ };
125
+ const { setAttributes, registry } = await runModalUpdate( {
126
+ attributes: {
127
+ id: 1,
128
+ url: 'original.jpg',
129
+ alt: 'Original alt',
130
+ caption: 'Original caption',
131
+ },
132
+ registryOptions: {
133
+ getEntityRecord: () => originalAttachment,
134
+ resolveGetEntityRecord: ( kind, name, attachmentId, query ) =>
135
+ query?.context === 'edit'
136
+ ? updatedAttachment
137
+ : originalAttachment,
138
+ },
139
+ } );
140
+
141
+ expect( setAttributes ).toHaveBeenCalledWith( {
142
+ alt: 'Updated alt',
143
+ caption: 'Updated caption',
144
+ } );
145
+ expect( registry.actions.invalidateResolution ).toHaveBeenCalledWith(
146
+ 'getEntityRecord',
147
+ [ 'postType', 'attachment', 1 ]
148
+ );
149
+ expect( registry.actions.invalidateResolution ).toHaveBeenCalledWith(
150
+ 'getEntityRecord',
151
+ [ 'postType', 'attachment', 1, { context: 'edit' } ]
152
+ );
153
+ } );
154
+
155
+ it( 'resolves original raw attachment metadata before opening the modal when it is not cached', async () => {
156
+ const originalAttachment = {
157
+ id: 1,
158
+ alt_text: 'Original alt',
159
+ caption: { raw: 'Original caption' },
160
+ };
161
+ const updatedAttachment = {
162
+ id: 1,
163
+ alt_text: 'Updated alt',
164
+ caption: { raw: 'Updated caption' },
165
+ };
166
+ const resolveGetEntityRecord = jest
167
+ .fn()
168
+ .mockResolvedValueOnce( originalAttachment )
169
+ .mockResolvedValueOnce( updatedAttachment );
170
+ const { setAttributes, openMediaEditorModal } = await runModalUpdate( {
171
+ attributes: {
172
+ id: 1,
173
+ url: 'original.jpg',
174
+ alt: 'Original alt',
175
+ caption: 'Original caption',
176
+ },
177
+ registryOptions: { resolveGetEntityRecord },
178
+ } );
179
+
180
+ expect( resolveGetEntityRecord ).toHaveBeenNthCalledWith(
181
+ 1,
182
+ 'postType',
183
+ 'attachment',
184
+ 1,
185
+ { context: 'edit' }
186
+ );
187
+ expect( openMediaEditorModal ).toHaveBeenCalledWith( {
188
+ id: 1,
189
+ onUpdate: expect.any( Function ),
190
+ } );
191
+ expect( setAttributes ).toHaveBeenCalledWith( {
192
+ alt: 'Updated alt',
193
+ caption: 'Updated caption',
194
+ } );
195
+ } );
196
+
197
+ it( 'resolves original raw attachment metadata before opening the modal when the block has no caption', async () => {
198
+ const originalAttachment = {
199
+ id: 1,
200
+ alt_text: '',
201
+ caption: { raw: 'Existing attachment caption' },
202
+ };
203
+ const updatedAttachment = {
204
+ id: 1,
205
+ alt_text: '',
206
+ caption: { raw: 'Updated attachment caption' },
207
+ };
208
+ const resolveGetEntityRecord = jest
209
+ .fn()
210
+ .mockResolvedValueOnce( originalAttachment )
211
+ .mockResolvedValueOnce( updatedAttachment );
212
+ const { setAttributes, openMediaEditorModal } = await runModalUpdate( {
213
+ attributes: {
214
+ id: 1,
215
+ url: 'original.jpg',
216
+ alt: '',
217
+ caption: undefined,
218
+ },
219
+ registryOptions: { resolveGetEntityRecord },
220
+ } );
221
+
222
+ expect( resolveGetEntityRecord ).toHaveBeenNthCalledWith(
223
+ 1,
224
+ 'postType',
225
+ 'attachment',
226
+ 1,
227
+ { context: 'edit' }
228
+ );
229
+ expect( openMediaEditorModal ).toHaveBeenCalledWith( {
230
+ id: 1,
231
+ onUpdate: expect.any( Function ),
232
+ } );
233
+ expect( setAttributes ).toHaveBeenCalledWith( {
234
+ caption: 'Updated attachment caption',
235
+ } );
236
+ } );
237
+
238
+ it( 'resolves original raw attachment metadata before opening the modal when the cached record has only a rendered caption', async () => {
239
+ const originalAttachment = {
240
+ id: 1,
241
+ alt_text: '',
242
+ caption: { raw: 'Existing attachment caption' },
243
+ };
244
+ const updatedAttachment = {
245
+ id: 1,
246
+ alt_text: '',
247
+ caption: { raw: 'Updated attachment caption' },
248
+ };
249
+ const resolveGetEntityRecord = jest
250
+ .fn()
251
+ .mockResolvedValueOnce( originalAttachment )
252
+ .mockResolvedValueOnce( updatedAttachment );
253
+ const { setAttributes } = await runModalUpdate( {
254
+ attributes: {
255
+ id: 1,
256
+ url: 'original.jpg',
257
+ alt: '',
258
+ caption: undefined,
259
+ },
260
+ registryOptions: {
261
+ getEntityRecord: () => ( {
262
+ id: 1,
263
+ alt_text: '',
264
+ caption: {
265
+ rendered: '<p>Existing attachment caption</p>\n',
266
+ },
267
+ } ),
268
+ resolveGetEntityRecord,
269
+ },
270
+ } );
271
+
272
+ expect( resolveGetEntityRecord ).toHaveBeenNthCalledWith(
273
+ 1,
274
+ 'postType',
275
+ 'attachment',
276
+ 1,
277
+ { context: 'edit' }
278
+ );
279
+ expect( setAttributes ).toHaveBeenCalledWith( {
280
+ caption: 'Updated attachment caption',
281
+ } );
282
+ } );
283
+
284
+ it( 'resolves attachment metadata when a new attachment id is not cached', async () => {
285
+ const originalAttachment = {
286
+ id: 1,
287
+ alt_text: '',
288
+ caption: { raw: '' },
289
+ };
290
+ const updatedAttachment = {
291
+ id: 2,
292
+ alt_text: 'Updated alt',
293
+ caption: { raw: 'Updated caption' },
294
+ };
295
+ const { setAttributes } = await runModalUpdate( {
296
+ attributes: {
297
+ id: 1,
298
+ url: 'original.jpg',
299
+ alt: '',
300
+ caption: '',
301
+ },
302
+ registryOptions: {
303
+ getEntityRecord: ( kind, name, attachmentId ) =>
304
+ attachmentId === 1 ? originalAttachment : undefined,
305
+ resolveGetEntityRecord: ( kind, name, attachmentId ) =>
306
+ attachmentId === 2 ? updatedAttachment : undefined,
307
+ },
308
+ updatePayload: { id: 2, url: 'cropped.jpg' },
309
+ } );
310
+
311
+ expect( setAttributes ).toHaveBeenCalledTimes( 1 );
312
+ expect( setAttributes ).toHaveBeenCalledWith( {
313
+ id: 2,
314
+ url: 'cropped.jpg',
315
+ alt: 'Updated alt',
316
+ caption: 'Updated caption',
317
+ } );
318
+ } );
319
+
320
+ it( 'resolves fresh metadata when the new attachment id has an incomplete cached record', async () => {
321
+ const originalAttachment = {
322
+ id: 1,
323
+ alt_text: '',
324
+ caption: { raw: '' },
325
+ };
326
+ const updatedAttachment = {
327
+ id: 2,
328
+ alt_text: 'Updated alt',
329
+ caption: { raw: 'Updated caption' },
330
+ };
331
+ const { setAttributes } = await runModalUpdate( {
332
+ attributes: {
333
+ id: 1,
334
+ url: 'original.jpg',
335
+ alt: '',
336
+ caption: '',
337
+ },
338
+ registryOptions: {
339
+ getEntityRecord: ( kind, name, attachmentId ) =>
340
+ attachmentId === 1
341
+ ? originalAttachment
342
+ : {
343
+ id: 2,
344
+ alt_text: 'Updated alt',
345
+ caption: { raw: '' },
346
+ },
347
+ resolveGetEntityRecord: ( kind, name, attachmentId ) =>
348
+ attachmentId === 2 ? updatedAttachment : undefined,
349
+ },
350
+ updatePayload: { id: 2, url: 'cropped.jpg' },
351
+ } );
352
+
353
+ expect( setAttributes ).toHaveBeenCalledTimes( 1 );
354
+ expect( setAttributes ).toHaveBeenCalledWith( {
355
+ id: 2,
356
+ url: 'cropped.jpg',
357
+ alt: 'Updated alt',
358
+ caption: 'Updated caption',
359
+ } );
360
+ } );
361
+
362
+ it( 'syncs new raw caption to a block with no caption when the original attachment has one', async () => {
363
+ const { setAttributes } = await runModalUpdate( {
364
+ attributes: {
365
+ id: 1,
366
+ url: 'original.jpg',
367
+ alt: '',
368
+ caption: undefined,
369
+ },
370
+ registryOptions: {
371
+ getEntityRecord: () => ( {
372
+ id: 1,
373
+ alt_text: '',
374
+ caption: { raw: 'Existing caption' },
375
+ } ),
376
+ resolveGetEntityRecord: () => ( {
377
+ id: 1,
378
+ alt_text: '',
379
+ caption: { raw: 'New caption' },
380
+ } ),
381
+ },
382
+ updatePayload: { id: 1, url: 'original.jpg' },
383
+ } );
384
+
385
+ expect( setAttributes ).toHaveBeenCalledWith( {
386
+ caption: 'New caption',
387
+ } );
388
+ } );
389
+
390
+ it( 'syncs metadata from an empty block when the original attachment is not cached', async () => {
391
+ const resolveGetEntityRecord = jest
392
+ .fn()
393
+ .mockResolvedValueOnce( {
394
+ id: 1,
395
+ alt_text: '',
396
+ caption: { raw: '' },
397
+ } )
398
+ .mockResolvedValueOnce( {
399
+ id: 1,
400
+ alt_text: 'Updated alt',
401
+ caption: { raw: 'Updated caption' },
402
+ } );
403
+ const { setAttributes } = await runModalUpdate( {
404
+ attributes: {
405
+ id: 1,
406
+ url: 'original.jpg',
407
+ alt: '',
408
+ caption: '',
409
+ },
410
+ registryOptions: { resolveGetEntityRecord },
411
+ } );
412
+
413
+ expect( setAttributes ).toHaveBeenCalledWith( {
414
+ alt: 'Updated alt',
415
+ caption: 'Updated caption',
416
+ } );
417
+ } );
418
+
419
+ it( 'does not sync a field that was not changed in the modal', async () => {
420
+ const { setAttributes } = await runModalUpdate( {
421
+ attributes: {
422
+ id: 1,
423
+ url: 'original.jpg',
424
+ alt: 'Original alt',
425
+ caption: undefined,
426
+ },
427
+ registryOptions: {
428
+ getEntityRecord: () => ( {
429
+ id: 1,
430
+ alt_text: 'Original alt',
431
+ caption: { raw: 'Existing caption' },
432
+ } ),
433
+ resolveGetEntityRecord: () => ( {
434
+ id: 1,
435
+ alt_text: 'Updated alt',
436
+ caption: { raw: 'Existing caption' },
437
+ } ),
438
+ },
439
+ updatePayload: { id: 1, url: 'original.jpg' },
440
+ } );
441
+
442
+ expect( setAttributes ).toHaveBeenCalledWith( {
443
+ alt: 'Updated alt',
444
+ } );
445
+ } );
446
+
447
+ it( 'does not sync caption when it has never been set on the block and only alt text was changed', async () => {
448
+ const { setAttributes } = await runModalUpdate( {
449
+ attributes: {
450
+ id: 1,
451
+ url: 'original.jpg',
452
+ alt: 'Original alt',
453
+ // Mimics the _RichTextData object set on a block whose
454
+ // caption has never been explicitly edited by the user.
455
+ caption: { toString: () => '' },
456
+ },
457
+ registryOptions: {
458
+ getEntityRecord: () => ( {
459
+ id: 1,
460
+ alt_text: 'Original alt',
461
+ caption: { raw: 'Existing caption' },
462
+ } ),
463
+ resolveGetEntityRecord: () => ( {
464
+ id: 1,
465
+ alt_text: 'Updated alt',
466
+ caption: { raw: 'Existing caption' },
467
+ } ),
468
+ },
469
+ updatePayload: { id: 1, url: 'original.jpg' },
470
+ } );
471
+
472
+ expect( setAttributes ).toHaveBeenCalledWith( {
473
+ alt: 'Updated alt',
474
+ } );
475
+ } );
476
+
477
+ it( 'does not overwrite custom captions when the original attachment is not cached', async () => {
478
+ const { setAttributes } = await runModalUpdate( {
479
+ attributes: {
480
+ id: 1,
481
+ url: 'original.jpg',
482
+ alt: '',
483
+ caption: 'Custom caption',
484
+ },
485
+ registryOptions: {
486
+ resolveGetEntityRecord: () => ( {
487
+ id: 1,
488
+ alt_text: '',
489
+ caption: { raw: 'Updated caption' },
490
+ } ),
491
+ },
492
+ } );
493
+
494
+ expect( setAttributes ).not.toHaveBeenCalled();
495
+ } );
496
+
497
+ it( 'does not sync metadata changed locally while fresh attachment metadata is resolving', async () => {
498
+ const updatedAttachment = {
499
+ id: 1,
500
+ alt_text: 'Attachment alt',
501
+ caption: { raw: 'Attachment caption' },
502
+ };
503
+ const deferredAttachment = createDeferred();
504
+ const registry = createRegistry( {
505
+ getEntityRecord: () => ( {
506
+ id: 1,
507
+ alt_text: '',
508
+ caption: { raw: '' },
509
+ } ),
510
+ resolveGetEntityRecord: () => deferredAttachment.promise,
511
+ } );
512
+ useRegistry.mockReturnValue( registry );
513
+ const setAttributes = jest.fn();
514
+ const openMediaEditorModal = jest.fn();
515
+ mockMediaEditorModalSetting( openMediaEditorModal );
516
+ const { result, rerender } = renderHook(
517
+ ( { attributes } ) =>
518
+ useOpenImageMediaEditorModal( { attributes, setAttributes } ),
519
+ {
520
+ initialProps: {
521
+ attributes: {
522
+ id: 1,
523
+ url: 'original.jpg',
524
+ alt: '',
525
+ caption: '',
526
+ },
527
+ },
528
+ }
529
+ );
530
+
531
+ await act( async () => {
532
+ await result.current();
533
+ } );
534
+ let updatePromise;
535
+ await act( async () => {
536
+ updatePromise = openMediaEditorModal.mock.calls[ 0 ][ 0 ].onUpdate(
537
+ {
538
+ id: 1,
539
+ url: 'updated.jpg',
540
+ }
541
+ );
542
+ } );
543
+ rerender( {
544
+ attributes: {
545
+ id: 1,
546
+ url: 'original.jpg',
547
+ alt: 'Local alt',
548
+ caption: 'Local caption',
549
+ },
550
+ } );
551
+ await act( async () => {
552
+ deferredAttachment.resolve( updatedAttachment );
553
+ await updatePromise;
554
+ } );
555
+
556
+ expect( setAttributes ).not.toHaveBeenCalledWith( {
557
+ alt: 'Attachment alt',
558
+ caption: 'Attachment caption',
559
+ } );
560
+ } );
561
+ } );
562
+
563
+ describe( 'getImageBlockMetadataFromAttachment', () => {
564
+ it( 'normalizes attachment metadata to image block attributes', () => {
565
+ expect(
566
+ getImageBlockMetadataFromAttachment( {
567
+ alt_text: 'Alt text',
568
+ caption: { raw: 'First line\nSecond line' },
569
+ } )
570
+ ).toEqual( {
571
+ alt: 'Alt text',
572
+ caption: 'First line<br>Second line',
573
+ } );
574
+ } );
575
+
576
+ it( 'does not use rendered captions when raw captions are unavailable', () => {
577
+ expect(
578
+ getImageBlockMetadataFromAttachment( {
579
+ alt_text: 'Alt text',
580
+ caption: { rendered: '<p>Rendered caption</p>\n' },
581
+ } )
582
+ ).toEqual( {
583
+ alt: 'Alt text',
584
+ caption: undefined,
585
+ } );
586
+ } );
587
+
588
+ it( 'preserves paragraph markup in raw captions', () => {
589
+ expect(
590
+ getImageBlockMetadataFromAttachment( {
591
+ caption: { raw: '<p>Raw caption</p>' },
592
+ } ).caption
593
+ ).toBe( '<p>Raw caption</p>' );
594
+ } );
595
+
596
+ it( 'does not fall back to rendered captions when raw captions are empty', () => {
597
+ expect(
598
+ getImageBlockMetadataFromAttachment( {
599
+ caption: {
600
+ raw: '',
601
+ rendered: '<p>Rendered caption</p>\n',
602
+ },
603
+ } ).caption
604
+ ).toBe( '' );
605
+ } );
606
+
607
+ it( 'returns an unknown caption when only rendered empty caption markup is available', () => {
608
+ expect(
609
+ getImageBlockMetadataFromAttachment( {
610
+ caption: {
611
+ rendered: '<p class="attachment"><br></p>\n',
612
+ },
613
+ } ).caption
614
+ ).toBe( undefined );
615
+ } );
616
+ } );
617
+
618
+ describe( 'getSyncedImageBlockAttributes', () => {
619
+ it( 'syncs updated attachment metadata when block metadata was not customized', () => {
620
+ expect(
621
+ getSyncedImageBlockAttributes(
622
+ {
623
+ alt: 'Original alt',
624
+ caption: 'Original caption',
625
+ },
626
+ {
627
+ alt_text: 'Original alt',
628
+ caption: { raw: 'Original caption' },
629
+ },
630
+ {
631
+ alt_text: 'Updated alt',
632
+ caption: { raw: 'Updated caption' },
633
+ }
634
+ )
635
+ ).toEqual( {
636
+ alt: 'Updated alt',
637
+ caption: 'Updated caption',
638
+ } );
639
+ } );
640
+
641
+ it( 'does not overwrite custom block alt text', () => {
642
+ expect(
643
+ getSyncedImageBlockAttributes(
644
+ {
645
+ alt: 'Custom alt',
646
+ caption: 'Original caption',
647
+ },
648
+ {
649
+ alt_text: 'Original alt',
650
+ caption: { raw: 'Original caption' },
651
+ },
652
+ {
653
+ alt_text: 'Updated alt',
654
+ caption: { raw: 'Updated caption' },
655
+ }
656
+ )
657
+ ).toEqual( {
658
+ caption: 'Updated caption',
659
+ } );
660
+ } );
661
+
662
+ it( 'does not overwrite custom block captions', () => {
663
+ expect(
664
+ getSyncedImageBlockAttributes(
665
+ {
666
+ alt: 'Original alt',
667
+ caption: 'Custom caption',
668
+ },
669
+ {
670
+ alt_text: 'Original alt',
671
+ caption: { raw: 'Original caption' },
672
+ },
673
+ {
674
+ alt_text: 'Updated alt',
675
+ caption: { raw: 'Updated caption' },
676
+ }
677
+ )
678
+ ).toEqual( {
679
+ alt: 'Updated alt',
680
+ } );
681
+ } );
682
+
683
+ it( 'syncs newly added attachment metadata when original metadata was empty', () => {
684
+ expect(
685
+ getSyncedImageBlockAttributes(
686
+ {},
687
+ {
688
+ alt_text: '',
689
+ caption: { raw: '' },
690
+ },
691
+ {
692
+ alt_text: 'Updated alt',
693
+ caption: { raw: 'Updated\ncaption' },
694
+ }
695
+ )
696
+ ).toEqual( {
697
+ alt: 'Updated alt',
698
+ caption: 'Updated<br>caption',
699
+ } );
700
+ } );
701
+
702
+ it( 'does not sync captions when the original raw attachment caption is unavailable', () => {
703
+ expect(
704
+ getSyncedImageBlockAttributes(
705
+ {},
706
+ {
707
+ caption: {
708
+ rendered: '<p>Original caption</p>\n',
709
+ },
710
+ },
711
+ {
712
+ caption: { raw: 'Updated caption' },
713
+ }
714
+ )
715
+ ).toEqual( {} );
716
+ } );
717
+
718
+ it( 'syncs caption to a block with no caption when the original attachment has one', () => {
719
+ expect(
720
+ getSyncedImageBlockAttributes(
721
+ {
722
+ alt: '',
723
+ caption: '',
724
+ },
725
+ {
726
+ alt_text: '',
727
+ caption: { raw: 'Existing caption' },
728
+ },
729
+ {
730
+ alt_text: '',
731
+ caption: { raw: 'Updated caption' },
732
+ }
733
+ )
734
+ ).toEqual( {
735
+ caption: 'Updated caption',
736
+ } );
737
+ } );
738
+
739
+ it( 'does not sync caption when block has a custom value differing from the original', () => {
740
+ expect(
741
+ getSyncedImageBlockAttributes(
742
+ {
743
+ alt: '',
744
+ caption: 'Custom caption',
745
+ },
746
+ {
747
+ alt_text: '',
748
+ caption: { raw: 'Original caption' },
749
+ },
750
+ {
751
+ alt_text: '',
752
+ caption: { raw: 'Updated caption' },
753
+ }
754
+ )
755
+ ).toEqual( {} );
756
+ } );
757
+
758
+ it( 'clears captions when the updated attachment caption is empty', () => {
759
+ expect(
760
+ getSyncedImageBlockAttributes(
761
+ {
762
+ caption: 'Original caption',
763
+ },
764
+ {
765
+ caption: { raw: 'Original caption' },
766
+ },
767
+ {
768
+ caption: { raw: '' },
769
+ }
770
+ )
771
+ ).toEqual( {
772
+ caption: undefined,
773
+ } );
774
+ } );
775
+
776
+ it( 'does not sync when the original attachment metadata is unknown', () => {
777
+ expect(
778
+ getSyncedImageBlockAttributes(
779
+ {
780
+ alt: '',
781
+ caption: '',
782
+ },
783
+ undefined,
784
+ {
785
+ alt_text: 'Updated alt',
786
+ caption: { raw: 'Updated caption' },
787
+ }
788
+ )
789
+ ).toEqual( {} );
790
+ } );
791
+ } );