Skip to content
Open
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
614 changes: 612 additions & 2 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"@wordpress/data": "^10.10.0",
"@wordpress/dom-ready": "^4.37.0",
"@wordpress/element": "6.38.0",
"@wordpress/hooks": "4.41.0",
"@wordpress/i18n": "^6.10.0",
"@wordpress/icons": "11.5.0",
"@wordpress/interactivity": "6.37.0",
Expand Down
180 changes: 180 additions & 0 deletions src/blocks/carousel/__tests__/edit.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/**
* Unit tests for the carousel editor setup flow.
*
* @package
*/

import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import Edit from '../edit';
import type { CarouselAttributes } from '../types';

let mockBlockCount = 0;

jest.mock( '@wordpress/block-editor', () => ( {
useBlockProps: jest.fn( ( props = {} ) => props ),
useInnerBlocksProps: jest.fn( ( props = {} ) => props ),
InspectorControls: jest.fn( ( { children } ) => children ),
InspectorAdvancedControls: jest.fn( ( { children } ) => children ),
BlockControls: jest.fn( ( { children } ) => children ),
} ) );

jest.mock( '@wordpress/components', () => {
const React = jest.requireActual( 'react' );

const Button = ( { children, onClick, className, ...rest }: any ) => (
<button type="button" className={ className } onClick={ onClick } { ...rest }>
{ children }
</button>
);

const Passthrough = ( { children }: any ) => <>{ children }</>;

return {
PanelBody: Passthrough,
ToggleControl: jest.fn( () => null ),
SelectControl: jest.fn( () => null ),
FormTokenField: jest.fn( () => null ),
BaseControl: Passthrough,
TextControl: jest.fn( () => null ),
RangeControl: jest.fn( () => null ),
Placeholder: ( { children, instructions, className }: any ) => (
<div className={ className }>
<p>{ instructions }</p>
{ children }
</div>
),
Button,
ToolbarButton: Button,
};
} );

jest.mock( '@wordpress/data', () => ( {
useDispatch: jest.fn( () => ( {
replaceInnerBlocks: jest.fn(),
insertBlock: jest.fn(),
} ) ),
useSelect: jest.fn( ( selector: any ) =>
selector( ( storeName: string ) => {
if ( storeName === 'core/block-editor' ) {
return {
getBlockCount: () => mockBlockCount,
getBlocks: () => [],
};
}

if ( storeName === 'core/blocks' ) {
return {
getBlockTypes: () => [],
};
}

return {};
} ),
),
} ) );

jest.mock( '@wordpress/icons', () => ( {
plus: 'plus',
columns: { name: 'columns' },
image: { name: 'image' },
layout: { name: 'layout' },
gallery: { name: 'gallery' },
post: { name: 'post' },
} ) );

jest.mock( '@wordpress/blocks', () => ( {
createBlock: jest.fn( ( name: string, attributes = {}, innerBlocks = [] ) => ( {
name,
attributes,
innerBlocks,
} ) ),
} ) );

jest.mock( '../components/TemplatePicker', () => ( {
__esModule: true,
default: ( { onBack }: { onBack: () => void } ) => (
<div>
<button type="button" onClick={ onBack }>
Back
</button>
</div>
),
} ) );

const createAttributes = (): CarouselAttributes => ( {
loop: false,
dragFree: false,
carouselAlign: 'start',
containScroll: 'trimSnaps',
direction: 'ltr',
axis: 'x',
height: '',
allowedSlideBlocks: [],
autoplay: false,
autoplayDelay: 1000,
autoplayStopOnInteraction: true,
autoplayStopOnMouseEnter: false,
ariaLabel: 'Carousel',
slidesToScroll: '1',
slideGap: 0,
} );

describe( 'Carousel Edit setup flow', () => {
beforeEach( () => {
mockBlockCount = 0;
} );

it( 'restores focus to first slide-count button when going back from templates', async () => {
render(
<Edit
attributes={ createAttributes() }
setAttributes={ jest.fn() }
clientId="client-1"
/>,
);

fireEvent.click( screen.getByRole( 'button', { name: '2 Slides' } ) );
const backButton = screen.getByRole( 'button', { name: 'Back' } );
backButton.focus();
fireEvent.click( backButton );

await waitFor( () => {
expect( screen.getByRole( 'button', { name: '1 Slide' } ) ).toHaveFocus();
} );
} );

it( 'does not throw when completing setup in an environment without document', () => {
const originalDocumentDescriptor = Object.getOwnPropertyDescriptor( globalThis, 'document' );

const { rerender } = render(
<Edit
attributes={ createAttributes() }
setAttributes={ jest.fn() }
clientId="client-2"
/>,
);

mockBlockCount = 1;

if ( originalDocumentDescriptor?.configurable ) {
Object.defineProperty( globalThis, 'document', {
value: undefined,
configurable: true,
} );
}

expect( () => {
rerender(
<Edit
attributes={ createAttributes() }
setAttributes={ jest.fn() }
clientId="client-2"
/>,
);
} ).not.toThrow();

if ( originalDocumentDescriptor?.configurable ) {
Object.defineProperty( globalThis, 'document', originalDocumentDescriptor );
}
} );
} );
202 changes: 202 additions & 0 deletions src/blocks/carousel/__tests__/templates.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
/**
* Unit tests for slide template definitions and the template registry.
*
* Verifies:
* - All default templates have the required shape
* - Template inner blocks produce valid BlockInstance arrays
* - Query Loop template is flagged correctly
* - The `rtcamp.carouselKit.slideTemplates` filter hook is applied
*
* @package
*/

/// <reference types="jest" />

import { applyFilters } from '@wordpress/hooks';
import { getSlideTemplates, type SlideTemplate } from '../templates';

/* ── Mocks ────────────────────────────────────────────────────────────────── */

// Provide a minimal createBlock mock that returns a plain object.
jest.mock( '@wordpress/blocks', () => ( {
createBlock: jest.fn( ( name: string, attrs = {}, inner = [] ) => ( {
name,
attributes: attrs,
innerBlocks: inner,
clientId: `mock-${ name }-${ Math.random().toString( 36 ).slice( 2, 8 ) }`,
} ) ),
} ) );

jest.mock( '@wordpress/hooks', () => ( {
applyFilters: jest.fn( ( _hookName: string, value: unknown ) => value ),
} ) );

jest.mock( '@wordpress/i18n', () => ( {
__: jest.fn( ( str: string ) => str ),
} ) );

const mockedApplyFilters = jest.mocked( applyFilters );
let consoleWarnSpy: jest.SpiedFunction< typeof console.warn >;

/* ── Tests ────────────────────────────────────────────────────────────────── */

describe( 'Slide Templates', () => {
beforeEach( () => {
consoleWarnSpy = jest.spyOn( console, 'warn' ).mockImplementation( () => undefined );
mockedApplyFilters.mockClear();
mockedApplyFilters.mockImplementation( ( _hookName: string, value: unknown ) => value );
} );

afterEach( () => {
consoleWarnSpy.mockRestore();
} );

describe( 'getSlideTemplates()', () => {
it( 'returns an array of templates', () => {
const templates = getSlideTemplates();
expect( Array.isArray( templates ) ).toBe( true );
expect( templates.length ).toBeGreaterThanOrEqual( 5 );
} );

it( 'applies the rtcamp.carouselKit.slideTemplates filter', () => {
getSlideTemplates();
expect( mockedApplyFilters ).toHaveBeenCalledWith(
'rtcamp.carouselKit.slideTemplates',
expect.any( Array ),
);
} );

it( 'passes a fresh copy of the default templates to filters', () => {
mockedApplyFilters.mockImplementationOnce( ( _hookName: string, value: unknown ) => {
( value as SlideTemplate[] ).push( {
name: 'testimonial',
label: 'Testimonial',
description: 'Quote with author name.',
icon: 'format-quote',
innerBlocks: () => [],
} );
return value;
} );

const mutatedTemplates = getSlideTemplates();
const freshTemplates = getSlideTemplates();

expect( mutatedTemplates.map( ( template ) => template.name ) ).toContain( 'testimonial' );
expect( freshTemplates.map( ( template ) => template.name ) ).not.toContain( 'testimonial' );
} );

it( 'falls back to defaults when a filter returns a non-array value', () => {
mockedApplyFilters.mockImplementationOnce( () => 'invalid' as never );

const templates = getSlideTemplates();

expect( Array.isArray( templates ) ).toBe( true );
expect( templates.length ).toBeGreaterThanOrEqual( 5 );
expect( templates.map( ( template ) => template.name ) ).toContain( 'text' );
expect( consoleWarnSpy ).toHaveBeenCalledWith(
'rtcamp.carouselKit.slideTemplates filter returned a non-array value. Falling back to default slide templates.',
'invalid',
);
} );

it( 'drops duplicate template names returned by filters', () => {
mockedApplyFilters.mockImplementationOnce( ( _hookName: string, value: unknown ) => [
...( value as SlideTemplate[] ),
{
name: 'text',
label: 'Duplicate Text',
description: 'Duplicate entry',
icon: 'format-quote',
innerBlocks: () => [],
},
] );

const templates = getSlideTemplates();
const textTemplates = templates.filter( ( template ) => template.name === 'text' );

expect( textTemplates ).toHaveLength( 1 );
expect( consoleWarnSpy ).toHaveBeenCalledWith(
'rtcamp.carouselKit.slideTemplates: dropping duplicate template name "text".',
expect.objectContaining( { name: 'text', label: 'Duplicate Text' } ),
);
} );
} );

describe( 'Template Shape', () => {
const templates = getSlideTemplates();
const templateCases: Array<[ string, SlideTemplate ]> = templates.map( ( template ) => [
template.name,
template,
] );

it.each<[ string, SlideTemplate ]>( templateCases )(
'template "%s" has required properties',
( _name, template ) => {
expect( typeof template.name ).toBe( 'string' );
expect( template.name.length ).toBeGreaterThan( 0 );
expect( typeof template.label ).toBe( 'string' );
expect( typeof template.description ).toBe( 'string' );
expect( template.icon ).toBeDefined();
expect( template.icon ).not.toBeNull();
expect( [ 'string', 'function', 'object' ] ).toContain(
typeof template.icon,
);
expect( typeof template.innerBlocks ).toBe( 'function' );
},
);

it( 'each template has a unique name', () => {
const names = templates.map( ( t ) => t.name );
expect( new Set( names ).size ).toBe( names.length );
} );
} );

describe( 'Default Templates', () => {
const templates = getSlideTemplates();
const byName = ( name: string ) =>
templates.find( ( t ) => t.name === name )!;

it( 'text template produces a paragraph block', () => {
const blocks = byName( 'text' ).innerBlocks();
expect( blocks ).toHaveLength( 1 );
expect( blocks[ 0 ]!.name ).toBe( 'core/paragraph' );
} );

it( 'image template produces an image block', () => {
const blocks = byName( 'image' ).innerBlocks();
expect( blocks ).toHaveLength( 1 );
expect( blocks[ 0 ]!.name ).toBe( 'core/image' );
} );

it( 'hero template produces a cover with heading, paragraph, and button', () => {
const blocks = byName( 'hero' ).innerBlocks();
expect( blocks ).toHaveLength( 1 );
expect( blocks[ 0 ]!.name ).toBe( 'core/cover' );
const inner = blocks[ 0 ]!.innerBlocks;
expect( inner ).toHaveLength( 3 );
expect( inner[ 0 ]!.name ).toBe( 'core/heading' );
expect( inner[ 1 ]!.name ).toBe( 'core/paragraph' );
expect( inner[ 2 ]!.name ).toBe( 'core/buttons' );
} );

it( 'image-caption template produces an image and a paragraph', () => {
const blocks = byName( 'image-caption' ).innerBlocks();
expect( blocks ).toHaveLength( 2 );
expect( blocks[ 0 ]!.name ).toBe( 'core/image' );
expect( blocks[ 1 ]!.name ).toBe( 'core/paragraph' );
} );

it( 'query-loop template is flagged as isQueryLoop', () => {
const ql = byName( 'query-loop' );
expect( ql.isQueryLoop ).toBe( true );
} );

it( 'non-query-loop templates are not flagged as isQueryLoop', () => {
templates
.filter( ( t ) => t.name !== 'query-loop' )
.forEach( ( t ) => {
expect( t.isQueryLoop ).toBeFalsy();
} );
} );
} );
} );
Loading
Loading