Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 113 additions & 6 deletions src/blocks/carousel/__tests__/viewport-edit.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,23 @@

import '@testing-library/jest-dom';
import type { ReactNode } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { createBlock } from '@wordpress/blocks';
import { useDispatch } from '@wordpress/data';
import Edit from '../viewport/edit';
import { EditorCarouselContext } from '../editor-context';

const mockInsertBlock = jest.fn();
const mockBlockEditorState: {
slideClientIds: string[];
selectedBlockClientId: string | null;
} = {
slideClientIds: [],
selectedBlockClientId: null,
};
const mockScrollTo = jest.fn();
const mockSelectedScrollSnap = jest.fn( () => 0 );
const mockSlideRegistry: number[][] = [];

jest.mock( '@wordpress/block-editor', () => ( {
useBlockProps: jest.fn( ( props = {} ) => props ),
Expand Down Expand Up @@ -65,13 +75,29 @@ jest.mock( '@wordpress/data', () => ( {
useDispatch: jest.fn( () => ( { insertBlock: mockInsertBlock } ) ),
useSelect: jest.fn( ( selector ) =>
selector( () => ( {
getBlocks: () => [],
getSelectedBlockClientId: () => null,
getBlocks: () =>
mockBlockEditorState.slideClientIds.map( ( clientId ) => ( { clientId } ) ),
getSelectedBlockClientId: () => mockBlockEditorState.selectedBlockClientId,
getBlockParents: () => [],
} ) ),
),
} ) );

jest.mock( 'embla-carousel', () => ( {
__esModule: true,
default: jest.fn( () => ( {
canScrollPrev: jest.fn( () => false ),
canScrollNext: jest.fn( () => false ),
scrollTo: mockScrollTo,
selectedScrollSnap: mockSelectedScrollSnap,
internalEngine: () => ( { slideRegistry: mockSlideRegistry } ),
on: jest.fn(),
off: jest.fn(),
destroy: jest.fn(),
reInit: jest.fn(),
} ) ),
} ) );

jest.mock( '@wordpress/blocks', () => ( {
createBlock: jest.fn( ( name: string, attributes = {}, innerBlocks = [] ) => ( {
name,
Expand All @@ -88,7 +114,10 @@ jest.mock( '../hooks/useCarouselObservers', () => ( {
useCarouselObservers: jest.fn(),
} ) );

const renderViewportEdit = () =>
const renderViewportEdit = ( {
slideCount = 0,
slidesToScroll = 1,
}: { slideCount?: number; slidesToScroll?: number } = {} ) =>
render(
<EditorCarouselContext.Provider
value={ {
Expand All @@ -103,15 +132,15 @@ const renderViewportEdit = () =>
selectedIndex: 0,
setSelectedIndex: jest.fn(),
scrollSnaps: [],
slideCount: 0,
slideCount,
carouselOptions: {
loop: false,
dragFree: false,
containScroll: 'trimSnaps',
axis: 'x',
align: 'start',
direction: 'ltr',
slidesToScroll: 1,
slidesToScroll,
},
} }
>
Expand Down Expand Up @@ -197,3 +226,81 @@ describe( 'Carousel Viewport Edit', () => {
);
} );
} );

describe( 'Carousel Viewport Edit - selection scroll mapping', () => {
// `requestAnimationFrame` is faked manually (not via jest fake timers) so the
// selection-scroll effect's callback runs on a deterministic, single flush.
let rafCallbacks: FrameRequestCallback[];
let rafSpy: jest.SpyInstance;
let cafSpy: jest.SpyInstance;

beforeEach( () => {
mockBlockEditorState.slideClientIds = [];
mockBlockEditorState.selectedBlockClientId = null;
mockSlideRegistry.length = 0;
mockScrollTo.mockClear();
mockSelectedScrollSnap.mockReset();
mockSelectedScrollSnap.mockReturnValue( 0 );

rafCallbacks = [];
rafSpy = jest
.spyOn( window, 'requestAnimationFrame' )
.mockImplementation( ( callback ) => {
rafCallbacks.push( callback );
return rafCallbacks.length;
} );
cafSpy = jest.spyOn( window, 'cancelAnimationFrame' ).mockImplementation( () => {} );
} );

afterEach( () => {
rafSpy.mockRestore();
cafSpy.mockRestore();
} );

const flushRaf = () => {
const callbacks = rafCallbacks;
rafCallbacks = [];
act( () => {
callbacks.forEach( ( callback ) => callback( 0 ) );
} );
};

it( 'does not scroll away when the selected slide is already in the current snap', () => {
// slidesToScroll: 2, slides 0/1 and 2/3 each share a snap.
mockBlockEditorState.slideClientIds = [ 'slide-0', 'slide-1', 'slide-2', 'slide-3' ];
mockBlockEditorState.selectedBlockClientId = 'slide-1';
mockSlideRegistry.push( [ 0, 1 ], [ 2, 3 ] );
mockSelectedScrollSnap.mockReturnValue( 0 ); // Already showing the [0, 1] snap.

renderViewportEdit( { slideCount: 4, slidesToScroll: 2 } );
flushRaf();

expect( mockScrollTo ).not.toHaveBeenCalled();
} );

it( 'scrolls to the snap containing the selected slide, not the raw slide index', () => {
mockBlockEditorState.slideClientIds = [ 'slide-0', 'slide-1', 'slide-2', 'slide-3' ];
mockBlockEditorState.selectedBlockClientId = 'slide-3';
mockSlideRegistry.push( [ 0, 1 ], [ 2, 3 ] );
mockSelectedScrollSnap.mockReturnValue( 0 ); // Currently on the first snap.

renderViewportEdit( { slideCount: 4, slidesToScroll: 2 } );
flushRaf();

// Slide index 3 must resolve to snap 1 ([2, 3]), never scroll to raw index 3.
expect( mockScrollTo ).toHaveBeenCalledWith( 1 );
expect( mockScrollTo ).not.toHaveBeenCalledWith( 3 );
} );

it( 'still scrolls by slide index when each snap holds a single slide (slidesToScroll: 1)', () => {
mockBlockEditorState.slideClientIds = [ 'slide-0', 'slide-1', 'slide-2' ];
mockBlockEditorState.selectedBlockClientId = 'slide-2';
mockSlideRegistry.push( [ 0 ], [ 1 ], [ 2 ] );
mockSelectedScrollSnap.mockReturnValue( 0 );

renderViewportEdit( { slideCount: 3, slidesToScroll: 1 } );
flushRaf();

expect( mockScrollTo ).toHaveBeenCalledWith( 2 );
} );
} );
12 changes: 10 additions & 2 deletions src/blocks/carousel/viewport/edit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,16 @@ export default function Edit( {
}
const id = requestAnimationFrame( () => {
const api = emblaApiRef.current;
if ( api && api.selectedScrollSnap() !== selectedSlideIndex ) {
api.scrollTo( selectedSlideIndex );
if ( ! api ) {
return;
}
// scrollTo()/selectedScrollSnap() use snap indexes; with slidesToScroll > 1
// one snap holds several slides, so map the slide to its snap first.
const snapIndex = api
Comment thread
sanketio marked this conversation as resolved.
.internalEngine()
.slideRegistry.findIndex( ( slides ) => slides.includes( selectedSlideIndex ) );
Comment on lines +196 to +198

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the useSelect mock always returns no selected block, so the effect exits before reaching the snap mapping.
The real library runs in jsdom, where every size is 0, so it can't produce a grouped registry like [[0, 1], [2, 3]]. Covering this needs a new jest.mock( 'embla-carousel' ) with a stubbed internalEngine().slideRegistry, plus a selection mock that can be set per test.

if ( snapIndex > -1 && api.selectedScrollSnap() !== snapIndex ) {
api.scrollTo( snapIndex );
}
} );
return () => cancelAnimationFrame( id );
Expand Down
Loading