UNPKG

@wordpress/block-library

Version:
1,242 lines (1,155 loc) 31.6 kB
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { act, renderHook } from '@testing-library/react'; import { useRegistry, useSelect } from '@wordpress/data'; import { getImageBlockMetadataFromAttachment, getNewAttachmentImageBlockAttributes, getSyncedImageBlockAttributes, useOpenImageMediaEditorModal, } from '../use-open-image-media-editor-modal'; const mockOpenMediaEditorModalKey = 'openMediaEditorModal'; vi.mock( import( '@wordpress/core-data' ), () => ( { store: {}, } ) ); vi.mock( import( '@wordpress/data' ), () => ( { useRegistry: vi.fn(), useSelect: vi.fn(), } ) ); vi.mock( import( '@wordpress/block-editor' ), () => ( { privateApis: {}, store: {}, } ) ); vi.mock( import( '../../lock-unlock' ), () => ( { unlock: vi.fn( () => ( { openMediaEditorModalKey: 'openMediaEditorModal', } ) ), } ) ); function createRegistry( { getEditedEntityRecord = () => false, resolveGetEntityRecord = () => undefined, } = {} ) { const actions = { invalidateResolution: vi.fn(), }; return { select: vi.fn( () => ( { getEditedEntityRecord, } ) ), dispatch: vi.fn( () => actions ), resolveSelect: vi.fn( () => ( { getEntityRecord: resolveGetEntityRecord, } ) ), actions, }; } function createDeferred() { let resolve; const promise = new Promise( ( _resolve ) => { resolve = _resolve; } ); return { promise, resolve }; } function mockMediaEditorModalSetting( openMediaEditorModal ) { useSelect.mockImplementation( ( mapSelect ) => mapSelect( () => ( { getSettings: () => ( { [ mockOpenMediaEditorModalKey ]: openMediaEditorModal, } ), } ) ) ); } async function runModalUpdate( { attributes, registryOptions = {}, updatePayload = { id: attributes.id, url: 'updated.jpg' }, hookOptions = {}, } ) { const registry = createRegistry( registryOptions ); useRegistry.mockReturnValue( registry ); const setAttributes = vi.fn(); const openMediaEditorModal = vi.fn(); mockMediaEditorModalSetting( openMediaEditorModal ); const { result } = renderHook( () => useOpenImageMediaEditorModal( { attributes, setAttributes, ...hookOptions, } ) ); await act( async () => { await result.current(); } ); await act( async () => { await openMediaEditorModal.mock.calls[ 0 ][ 0 ].onUpdate( updatePayload ); } ); return { setAttributes, registry, openMediaEditorModal }; } // The attachment as it was before the edit. Its metadata matches the blocks // used below, so the metadata sync never contributes attributes and the // assertions stay about the ones derived from the edited attachment. const ORIGINAL_ATTACHMENT = { id: 1, alt_text: '', caption: { raw: '' }, }; // Editing media saves to a new attachment, with its own sub-sizes, file and // attachment page. const CROPPED_ATTACHMENT = { id: 2, alt_text: '', caption: { raw: '' }, source_url: 'cropped.jpg', link: 'https://example.com/cropped/', media_details: { sizes: { medium: { source_url: 'cropped-300x200.jpg' }, full: { source_url: 'cropped.jpg' }, }, }, }; // What the media editor reports after a crop, rotate or flip: it saved to a // new attachment, and reports that attachment's full-size file. const CROP_UPDATE = { id: CROPPED_ATTACHMENT.id, url: CROPPED_ATTACHMENT.source_url, }; /** * Registry options for an edit that saved to a new attachment: the pre-edit * record is cached, and the edited one is only reachable by resolving it. * * @param {Object} options Options. * @param {boolean} options.hasOriginal Whether the pre-edit attachment is * known at all; without it there is no * metadata baseline. */ function croppedAttachmentRecords( { hasOriginal = true } = {} ) { return { getEditedEntityRecord: ( kind, name, attachmentId ) => hasOriginal && attachmentId === ORIGINAL_ATTACHMENT.id ? ORIGINAL_ATTACHMENT : undefined, resolveGetEntityRecord: ( kind, name, attachmentId ) => attachmentId === CROPPED_ATTACHMENT.id ? CROPPED_ATTACHMENT : undefined, }; } describe( 'useOpenImageMediaEditorModal', () => { beforeEach( () => { vi.clearAllMocks(); } ); it( 'notifies onUrlChange when the update switches to a new attachment URL', async () => { const onUrlChange = vi.fn(); await runModalUpdate( { attributes: { id: 1, url: 'original.jpg', alt: '', caption: '' }, updatePayload: { id: 2, url: 'updated.jpg' }, hookOptions: { onUrlChange }, } ); expect( onUrlChange ).toHaveBeenCalledTimes( 1 ); expect( onUrlChange ).toHaveBeenCalledWith( 'updated.jpg' ); } ); it( 'does not notify onUrlChange for a same-attachment update', async () => { const onUrlChange = vi.fn(); await runModalUpdate( { attributes: { id: 1, url: 'original.jpg', alt: '', caption: '' }, updatePayload: { id: 1, url: 'original.jpg' }, hookOptions: { onUrlChange }, } ); expect( onUrlChange ).not.toHaveBeenCalled(); } ); it( 'does not notify onUrlChange when the update carries no URL', async () => { const onUrlChange = vi.fn(); await runModalUpdate( { attributes: { id: 1, url: 'original.jpg', alt: '', caption: '' }, updatePayload: { id: 2 }, hookOptions: { onUrlChange }, } ); expect( onUrlChange ).not.toHaveBeenCalled(); } ); it( 'returns no opener when the media editor modal setting is unavailable', () => { useRegistry.mockReturnValue( createRegistry() ); mockMediaEditorModalSetting( undefined ); const { result } = renderHook( () => useOpenImageMediaEditorModal( { attributes: { id: 1, url: 'original.jpg', alt: '', caption: '', }, setAttributes: vi.fn(), } ) ); expect( result.current ).toBeUndefined(); } ); it( 'passes an onClose handler for returning focus when the media editor closes', async () => { const cropButton = document.createElement( 'button' ); const otherButton = document.createElement( 'button' ); document.body.append( cropButton, otherButton ); const registry = createRegistry(); useRegistry.mockReturnValue( registry ); const setAttributes = vi.fn(); const openMediaEditorModal = vi.fn(); mockMediaEditorModalSetting( openMediaEditorModal ); const onClose = () => cropButton.focus(); const { result } = renderHook( () => useOpenImageMediaEditorModal( { attributes: { id: 1, url: 'original.jpg', alt: '', caption: '', }, setAttributes, onClose, } ) ); try { await act( async () => { await result.current(); } ); otherButton.focus(); expect( otherButton ).toHaveFocus(); openMediaEditorModal.mock.calls[ 0 ][ 0 ].onClose(); expect( cropButton ).toHaveFocus(); } finally { cropButton.remove(); otherButton.remove(); } } ); it( 'resolves fresh attachment metadata when the same attachment id has a stale cache', async () => { const originalAttachment = { id: 1, alt_text: 'Original alt', caption: { raw: 'Original caption' }, }; const updatedAttachment = { id: 1, alt_text: 'Updated alt', caption: { raw: 'Updated caption' }, }; const { setAttributes, registry } = await runModalUpdate( { attributes: { id: 1, url: 'original.jpg', alt: 'Original alt', caption: 'Original caption', }, registryOptions: { getEditedEntityRecord: () => originalAttachment, resolveGetEntityRecord: () => updatedAttachment, }, } ); expect( setAttributes ).toHaveBeenCalledWith( { alt: 'Updated alt', caption: 'Updated caption', } ); // A single, query-less resolution is invalidated: the hook reads, // resolves, and invalidates the attachment through the entity's default // (edit) context rather than an explicitly-keyed query. expect( registry.actions.invalidateResolution ).toHaveBeenCalledTimes( 1 ); expect( registry.actions.invalidateResolution ).toHaveBeenCalledWith( 'getEntityRecord', [ 'postType', 'attachment', 1 ] ); } ); it( 'resolves original raw attachment metadata before opening the modal when it is not cached', async () => { const originalAttachment = { id: 1, alt_text: 'Original alt', caption: { raw: 'Original caption' }, }; const updatedAttachment = { id: 1, alt_text: 'Updated alt', caption: { raw: 'Updated caption' }, }; const resolveGetEntityRecord = vi .fn() .mockResolvedValueOnce( originalAttachment ) .mockResolvedValueOnce( updatedAttachment ); const { setAttributes, openMediaEditorModal } = await runModalUpdate( { attributes: { id: 1, url: 'original.jpg', alt: 'Original alt', caption: 'Original caption', }, registryOptions: { resolveGetEntityRecord }, } ); expect( resolveGetEntityRecord ).toHaveBeenNthCalledWith( 1, 'postType', 'attachment', 1 ); expect( openMediaEditorModal ).toHaveBeenCalledWith( { id: 1, onUpdate: expect.any( Function ), onClose: undefined, } ); expect( setAttributes ).toHaveBeenCalledWith( { alt: 'Updated alt', caption: 'Updated caption', } ); } ); it( 'resolves original raw attachment metadata before opening the modal when the block has no caption', async () => { const originalAttachment = { id: 1, alt_text: '', caption: { raw: 'Existing attachment caption' }, }; const updatedAttachment = { id: 1, alt_text: '', caption: { raw: 'Updated attachment caption' }, }; const resolveGetEntityRecord = vi .fn() .mockResolvedValueOnce( originalAttachment ) .mockResolvedValueOnce( updatedAttachment ); const { setAttributes, openMediaEditorModal } = await runModalUpdate( { attributes: { id: 1, url: 'original.jpg', alt: '', caption: undefined, }, registryOptions: { resolveGetEntityRecord }, } ); expect( resolveGetEntityRecord ).toHaveBeenNthCalledWith( 1, 'postType', 'attachment', 1 ); expect( openMediaEditorModal ).toHaveBeenCalledWith( { id: 1, onUpdate: expect.any( Function ), onClose: undefined, } ); expect( setAttributes ).toHaveBeenCalledWith( { caption: 'Updated attachment caption', } ); } ); it( 'resolves original raw attachment metadata before opening the modal when the cached record has only a rendered caption', async () => { const originalAttachment = { id: 1, alt_text: '', caption: { raw: 'Existing attachment caption' }, }; const updatedAttachment = { id: 1, alt_text: '', caption: { raw: 'Updated attachment caption' }, }; const resolveGetEntityRecord = vi .fn() .mockResolvedValueOnce( originalAttachment ) .mockResolvedValueOnce( updatedAttachment ); const { setAttributes } = await runModalUpdate( { attributes: { id: 1, url: 'original.jpg', alt: '', caption: undefined, }, registryOptions: { getEditedEntityRecord: () => ( { id: 1, alt_text: '', caption: { rendered: '<p>Existing attachment caption</p>\n', }, } ), resolveGetEntityRecord, }, } ); expect( resolveGetEntityRecord ).toHaveBeenNthCalledWith( 1, 'postType', 'attachment', 1 ); expect( setAttributes ).toHaveBeenCalledWith( { caption: 'Updated attachment caption', } ); } ); it( 'resolves attachment metadata when a new attachment id is not cached', async () => { const originalAttachment = { id: 1, alt_text: '', caption: { raw: '' }, }; const updatedAttachment = { id: 2, alt_text: 'Updated alt', caption: { raw: 'Updated caption' }, }; const { setAttributes } = await runModalUpdate( { attributes: { id: 1, url: 'original.jpg', alt: '', caption: '', }, registryOptions: { getEditedEntityRecord: ( kind, name, attachmentId ) => attachmentId === 1 ? originalAttachment : undefined, resolveGetEntityRecord: ( kind, name, attachmentId ) => attachmentId === 2 ? updatedAttachment : undefined, }, updatePayload: { id: 2, url: 'cropped.jpg' }, } ); expect( setAttributes ).toHaveBeenCalledTimes( 1 ); expect( setAttributes ).toHaveBeenCalledWith( { id: 2, url: 'cropped.jpg', alt: 'Updated alt', caption: 'Updated caption', } ); } ); it( 'keeps the selected image size when the edit created a new attachment', async () => { const onUrlChange = vi.fn(); const { setAttributes } = await runModalUpdate( { attributes: { id: 1, url: 'original-300x200.jpg', alt: '', caption: '', sizeSlug: 'medium', }, registryOptions: croppedAttachmentRecords(), updatePayload: CROP_UPDATE, hookOptions: { onUrlChange }, } ); // Everything lands in one update, so the block never renders against // half-updated settings. expect( setAttributes ).toHaveBeenCalledTimes( 1 ); expect( setAttributes ).toHaveBeenCalledWith( { id: 2, url: 'cropped-300x200.jpg', } ); // The loading state settles against the file the block will render, // not the full-size file the media editor reported. expect( onUrlChange ).toHaveBeenLastCalledWith( 'cropped-300x200.jpg' ); } ); it( 'falls back to the saved record when the refetch fails', async () => { const { setAttributes } = await runModalUpdate( { attributes: { id: 1, url: 'original-300x200.jpg', alt: '', caption: '', sizeSlug: 'medium', href: 'original.jpg', linkDestination: 'media', }, registryOptions: { // The media editor put the saved record in the store, so it // is cached even though refetching it fails. getEditedEntityRecord: ( kind, name, attachmentId ) => attachmentId === ORIGINAL_ATTACHMENT.id ? ORIGINAL_ATTACHMENT : CROPPED_ATTACHMENT, resolveGetEntityRecord: ( kind, name, attachmentId ) => { if ( attachmentId === CROPPED_ATTACHMENT.id ) { throw new Error( 'Network error' ); } return undefined; }, }, updatePayload: CROP_UPDATE, } ); // The size and link still follow the edited image, rather than the // block half-updating to the new file at its old settings. expect( setAttributes ).toHaveBeenCalledTimes( 1 ); expect( setAttributes ).toHaveBeenCalledWith( { id: 2, url: 'cropped-300x200.jpg', href: 'cropped.jpg', } ); } ); it( 'derives the size of the edited image without a metadata baseline', async () => { const { setAttributes, registry } = await runModalUpdate( { // Custom metadata leaves the block with no fallback baseline, and // the original attachment is unknown, so no metadata is synced. attributes: { id: 1, url: 'original-300x200.jpg', alt: 'Custom alt', caption: 'Custom caption', sizeSlug: 'medium', }, registryOptions: croppedAttachmentRecords( { hasOriginal: false } ), updatePayload: CROP_UPDATE, } ); expect( setAttributes ).toHaveBeenCalledTimes( 1 ); expect( setAttributes ).toHaveBeenCalledWith( { id: 2, url: 'cropped-300x200.jpg', } ); // The size is derived from a freshly resolved record for the edited // attachment, not a stale cached one. expect( registry.actions.invalidateResolution ).toHaveBeenCalledWith( 'getEntityRecord', [ 'postType', 'attachment', 2 ] ); } ); it( 'points a media file link at the edited image', async () => { const { setAttributes } = await runModalUpdate( { attributes: { id: 1, url: 'original.jpg', alt: '', caption: '', href: 'original.jpg', linkDestination: 'media', }, registryOptions: croppedAttachmentRecords(), updatePayload: CROP_UPDATE, } ); expect( setAttributes ).toHaveBeenCalledTimes( 1 ); expect( setAttributes ).toHaveBeenCalledWith( { id: 2, url: 'cropped.jpg', href: 'cropped.jpg', } ); } ); it( 'updates back to the previous attachment from the original modal callback', async () => { const originalAttachment = { id: 1, alt_text: '', caption: { raw: '' }, }; const croppedAttachment = { id: 2, alt_text: '', caption: { raw: '' }, }; const deferredAttachment = createDeferred(); const registry = createRegistry( { getEditedEntityRecord: ( kind, name, attachmentId ) => attachmentId === 1 ? originalAttachment : undefined, resolveGetEntityRecord: ( kind, name, attachmentId ) => attachmentId === 2 ? deferredAttachment.promise : undefined, } ); useRegistry.mockReturnValue( registry ); const setAttributes = vi.fn(); const openMediaEditorModal = vi.fn(); mockMediaEditorModalSetting( openMediaEditorModal ); const { result } = renderHook( ( { attributes } ) => useOpenImageMediaEditorModal( { attributes, setAttributes } ), { initialProps: { attributes: { id: 1, url: 'original.jpg', alt: '', caption: '', }, }, } ); await act( async () => { await result.current(); } ); const onUpdate = openMediaEditorModal.mock.calls[ 0 ][ 0 ].onUpdate; let updatePromise; await act( async () => { updatePromise = onUpdate( { id: 2, url: 'cropped.jpg' } ); } ); await act( async () => { await onUpdate( { id: 1, url: 'original.jpg' } ); } ); await act( async () => { deferredAttachment.resolve( croppedAttachment ); await updatePromise; } ); expect( setAttributes ).toHaveBeenCalledTimes( 1 ); expect( setAttributes ).toHaveBeenCalledWith( { id: 1, url: 'original.jpg', } ); } ); it( 'resolves fresh metadata when the new attachment id has an incomplete cached record', async () => { const originalAttachment = { id: 1, alt_text: '', caption: { raw: '' }, }; const updatedAttachment = { id: 2, alt_text: 'Updated alt', caption: { raw: 'Updated caption' }, }; const { setAttributes } = await runModalUpdate( { attributes: { id: 1, url: 'original.jpg', alt: '', caption: '', }, registryOptions: { getEditedEntityRecord: ( kind, name, attachmentId ) => attachmentId === 1 ? originalAttachment : { id: 2, alt_text: 'Updated alt', caption: { raw: '' }, }, resolveGetEntityRecord: ( kind, name, attachmentId ) => attachmentId === 2 ? updatedAttachment : undefined, }, updatePayload: { id: 2, url: 'cropped.jpg' }, } ); expect( setAttributes ).toHaveBeenCalledTimes( 1 ); expect( setAttributes ).toHaveBeenCalledWith( { id: 2, url: 'cropped.jpg', alt: 'Updated alt', caption: 'Updated caption', } ); } ); it( 'syncs new raw caption to a block with no caption when the original attachment has one', async () => { const { setAttributes } = await runModalUpdate( { attributes: { id: 1, url: 'original.jpg', alt: '', caption: undefined, }, registryOptions: { getEditedEntityRecord: () => ( { id: 1, alt_text: '', caption: { raw: 'Existing caption' }, } ), resolveGetEntityRecord: () => ( { id: 1, alt_text: '', caption: { raw: 'New caption' }, } ), }, updatePayload: { id: 1, url: 'original.jpg' }, } ); expect( setAttributes ).toHaveBeenCalledWith( { caption: 'New caption', } ); } ); it( 'syncs metadata from an empty block when the original attachment is not cached', async () => { const resolveGetEntityRecord = vi .fn() .mockResolvedValueOnce( { id: 1, alt_text: '', caption: { raw: '' }, } ) .mockResolvedValueOnce( { id: 1, alt_text: 'Updated alt', caption: { raw: 'Updated caption' }, } ); const { setAttributes } = await runModalUpdate( { attributes: { id: 1, url: 'original.jpg', alt: '', caption: '', }, registryOptions: { resolveGetEntityRecord }, } ); expect( setAttributes ).toHaveBeenCalledWith( { alt: 'Updated alt', caption: 'Updated caption', } ); } ); it( 'does not sync a field that was not changed in the modal', async () => { const { setAttributes } = await runModalUpdate( { attributes: { id: 1, url: 'original.jpg', alt: 'Original alt', caption: undefined, }, registryOptions: { getEditedEntityRecord: () => ( { id: 1, alt_text: 'Original alt', caption: { raw: 'Existing caption' }, } ), resolveGetEntityRecord: () => ( { id: 1, alt_text: 'Updated alt', caption: { raw: 'Existing caption' }, } ), }, updatePayload: { id: 1, url: 'original.jpg' }, } ); expect( setAttributes ).toHaveBeenCalledWith( { alt: 'Updated alt', } ); } ); it( 'does not sync caption when it has never been set on the block and only alt text was changed', async () => { const { setAttributes } = await runModalUpdate( { attributes: { id: 1, url: 'original.jpg', alt: 'Original alt', // Mimics the _RichTextData object set on a block whose // caption has never been explicitly edited by the user. caption: { toString: () => '' }, }, registryOptions: { getEditedEntityRecord: () => ( { id: 1, alt_text: 'Original alt', caption: { raw: 'Existing caption' }, } ), resolveGetEntityRecord: () => ( { id: 1, alt_text: 'Updated alt', caption: { raw: 'Existing caption' }, } ), }, updatePayload: { id: 1, url: 'original.jpg' }, } ); expect( setAttributes ).toHaveBeenCalledWith( { alt: 'Updated alt', } ); } ); it( 'does not overwrite custom captions when the original attachment is not cached', async () => { const { setAttributes } = await runModalUpdate( { attributes: { id: 1, url: 'original.jpg', alt: '', caption: 'Custom caption', }, registryOptions: { resolveGetEntityRecord: () => ( { id: 1, alt_text: '', caption: { raw: 'Updated caption' }, } ), }, } ); expect( setAttributes ).not.toHaveBeenCalled(); } ); it( 'does not sync metadata changed locally while fresh attachment metadata is resolving', async () => { const updatedAttachment = { id: 1, alt_text: 'Attachment alt', caption: { raw: 'Attachment caption' }, }; const deferredAttachment = createDeferred(); const registry = createRegistry( { getEditedEntityRecord: () => ( { id: 1, alt_text: '', caption: { raw: '' }, } ), resolveGetEntityRecord: () => deferredAttachment.promise, } ); useRegistry.mockReturnValue( registry ); const setAttributes = vi.fn(); const openMediaEditorModal = vi.fn(); mockMediaEditorModalSetting( openMediaEditorModal ); const { result, rerender } = renderHook( ( { attributes } ) => useOpenImageMediaEditorModal( { attributes, setAttributes } ), { initialProps: { attributes: { id: 1, url: 'original.jpg', alt: '', caption: '', }, }, } ); await act( async () => { await result.current(); } ); let updatePromise; await act( async () => { updatePromise = openMediaEditorModal.mock.calls[ 0 ][ 0 ].onUpdate( { id: 1, url: 'updated.jpg', } ); } ); rerender( { attributes: { id: 1, url: 'original.jpg', alt: 'Local alt', caption: 'Local caption', }, } ); await act( async () => { deferredAttachment.resolve( updatedAttachment ); await updatePromise; } ); expect( setAttributes ).not.toHaveBeenCalledWith( { alt: 'Attachment alt', caption: 'Attachment caption', } ); } ); } ); describe( 'getImageBlockMetadataFromAttachment', () => { it( 'normalizes attachment metadata to image block attributes', () => { expect( getImageBlockMetadataFromAttachment( { alt_text: 'Alt text', caption: { raw: 'First line\nSecond line' }, } ) ).toEqual( { alt: 'Alt text', caption: 'First line<br>Second line', } ); } ); it( 'does not use rendered captions when raw captions are unavailable', () => { expect( getImageBlockMetadataFromAttachment( { alt_text: 'Alt text', caption: { rendered: '<p>Rendered caption</p>\n' }, } ) ).toEqual( { alt: 'Alt text', caption: undefined, } ); } ); it( 'preserves paragraph markup in raw captions', () => { expect( getImageBlockMetadataFromAttachment( { caption: { raw: '<p>Raw caption</p>' }, } ).caption ).toBe( '<p>Raw caption</p>' ); } ); it( 'does not fall back to rendered captions when raw captions are empty', () => { expect( getImageBlockMetadataFromAttachment( { caption: { raw: '', rendered: '<p>Rendered caption</p>\n', }, } ).caption ).toBe( '' ); } ); it( 'returns an unknown caption when only rendered empty caption markup is available', () => { expect( getImageBlockMetadataFromAttachment( { caption: { rendered: '<p class="attachment"><br></p>\n', }, } ).caption ).toBe( undefined ); } ); } ); describe( 'getSyncedImageBlockAttributes', () => { it( 'syncs updated attachment metadata when block metadata was not customized', () => { expect( getSyncedImageBlockAttributes( { alt: 'Original alt', caption: 'Original caption', }, { alt_text: 'Original alt', caption: { raw: 'Original caption' }, }, { alt_text: 'Updated alt', caption: { raw: 'Updated caption' }, } ) ).toEqual( { alt: 'Updated alt', caption: 'Updated caption', } ); } ); it( 'does not overwrite custom block alt text', () => { expect( getSyncedImageBlockAttributes( { alt: 'Custom alt', caption: 'Original caption', }, { alt_text: 'Original alt', caption: { raw: 'Original caption' }, }, { alt_text: 'Updated alt', caption: { raw: 'Updated caption' }, } ) ).toEqual( { caption: 'Updated caption', } ); } ); it( 'does not overwrite custom block captions', () => { expect( getSyncedImageBlockAttributes( { alt: 'Original alt', caption: 'Custom caption', }, { alt_text: 'Original alt', caption: { raw: 'Original caption' }, }, { alt_text: 'Updated alt', caption: { raw: 'Updated caption' }, } ) ).toEqual( { alt: 'Updated alt', } ); } ); it( 'syncs newly added attachment metadata when original metadata was empty', () => { expect( getSyncedImageBlockAttributes( {}, { alt_text: '', caption: { raw: '' }, }, { alt_text: 'Updated alt', caption: { raw: 'Updated\ncaption' }, } ) ).toEqual( { alt: 'Updated alt', caption: 'Updated<br>caption', } ); } ); it( 'does not sync captions when the original raw attachment caption is unavailable', () => { expect( getSyncedImageBlockAttributes( {}, { caption: { rendered: '<p>Original caption</p>\n', }, }, { caption: { raw: 'Updated caption' }, } ) ).toEqual( {} ); } ); it( 'syncs caption to a block with no caption when the original attachment has one', () => { expect( getSyncedImageBlockAttributes( { alt: '', caption: '', }, { alt_text: '', caption: { raw: 'Existing caption' }, }, { alt_text: '', caption: { raw: 'Updated caption' }, } ) ).toEqual( { caption: 'Updated caption', } ); } ); it( 'does not sync caption when block has a custom value differing from the original', () => { expect( getSyncedImageBlockAttributes( { alt: '', caption: 'Custom caption', }, { alt_text: '', caption: { raw: 'Original caption' }, }, { alt_text: '', caption: { raw: 'Updated caption' }, } ) ).toEqual( {} ); } ); it( 'clears captions when the updated attachment caption is empty', () => { expect( getSyncedImageBlockAttributes( { caption: 'Original caption', }, { caption: { raw: 'Original caption' }, }, { caption: { raw: '' }, } ) ).toEqual( { caption: undefined, } ); } ); it( 'does not sync when the original attachment metadata is unknown', () => { expect( getSyncedImageBlockAttributes( { alt: '', caption: '', }, undefined, { alt_text: 'Updated alt', caption: { raw: 'Updated caption' }, } ) ).toEqual( {} ); } ); } ); describe( 'getNewAttachmentImageBlockAttributes', () => { const croppedAttachment = { id: 2, source_url: 'cropped.jpg', link: 'https://example.com/cropped/', media_details: { sizes: { medium: { source_url: 'cropped-300x200.jpg' }, full: { source_url: 'cropped.jpg' }, }, }, }; it( 'points the URL at the selected size on the new attachment', () => { expect( getNewAttachmentImageBlockAttributes( { sizeSlug: 'medium' }, croppedAttachment ) ).toEqual( { url: 'cropped-300x200.jpg' } ); } ); it( 'falls back to full when the new attachment lacks the selected size', () => { expect( getNewAttachmentImageBlockAttributes( { sizeSlug: 'large' }, croppedAttachment ) ).toEqual( { url: 'cropped.jpg', sizeSlug: 'full' } ); } ); it( 'falls back to full when the edited image is too small for any sub-size', () => { // Cropping below the smallest registered size leaves // `media_details.sizes` empty, so the block has to fall back to the // file itself and stop reporting a size it no longer has. expect( getNewAttachmentImageBlockAttributes( { sizeSlug: 'medium' }, { id: 2, source_url: 'cropped-103x45.jpg', media_details: { width: 103, height: 45, sizes: {} }, } ) ).toEqual( { url: 'cropped-103x45.jpg', sizeSlug: 'full', } ); } ); it( 'makes no change when no full-size URL is available', () => { expect( getNewAttachmentImageBlockAttributes( { sizeSlug: 'large' }, { id: 2, media_details: { sizes: { medium: {} } } } ) ).toEqual( {} ); } ); it( 'makes no change for the full size or an unset size', () => { expect( getNewAttachmentImageBlockAttributes( { sizeSlug: 'full' }, croppedAttachment ) ).toEqual( {} ); expect( getNewAttachmentImageBlockAttributes( {}, croppedAttachment ) ).toEqual( {} ); } ); it( 'makes no change when the attachment sizes are unknown', () => { expect( getNewAttachmentImageBlockAttributes( { sizeSlug: 'medium' }, { id: 2 } ) ).toEqual( {} ); } ); it( 'makes no change when the attachment record is unknown', () => { expect( getNewAttachmentImageBlockAttributes( { sizeSlug: 'medium' }, undefined ) ).toBeUndefined(); } ); it( 'points a media file link at the new attachment file', () => { expect( getNewAttachmentImageBlockAttributes( { sizeSlug: 'medium', linkDestination: 'media' }, croppedAttachment ) ).toEqual( { url: 'cropped-300x200.jpg', href: 'cropped.jpg', } ); } ); it( 'points an attachment page link at the new attachment page', () => { expect( getNewAttachmentImageBlockAttributes( { linkDestination: 'attachment' }, croppedAttachment ) ).toEqual( { href: 'https://example.com/cropped/' } ); } ); it( 'leaves custom and absent links alone', () => { expect( getNewAttachmentImageBlockAttributes( { linkDestination: 'custom' }, croppedAttachment ) ).toEqual( {} ); expect( getNewAttachmentImageBlockAttributes( { linkDestination: 'none' }, croppedAttachment ) ).toEqual( {} ); expect( getNewAttachmentImageBlockAttributes( {}, croppedAttachment ) ).toEqual( {} ); } ); it( 'keeps the existing link when the new attachment record lacks it', () => { expect( getNewAttachmentImageBlockAttributes( { linkDestination: 'media' }, { id: 2 } ) ).toEqual( {} ); expect( getNewAttachmentImageBlockAttributes( { linkDestination: 'attachment' }, { id: 2 } ) ).toEqual( {} ); } ); } );