@wordpress/core-data
Version:
Access to and manipulation of core WordPress entities.
895 lines (779 loc) • 23.5 kB
text/typescript
/**
* External dependencies
*/
import { Y } from '@wordpress/sync';
import { select } from '@wordpress/data';
/**
* Internal dependencies
*/
import {
areSelectionsStatesEqual,
getSelectionState,
SelectionType,
} from '../crdt-user-selections';
import { CRDT_RECORD_MAP_KEY } from '../../sync';
jest.mock( '@wordpress/data', () => ( {
select: jest.fn(),
} ) );
jest.mock( '@wordpress/block-editor', () => ( {
store: 'core/block-editor',
} ) );
import type {
CursorPosition,
SelectionNone,
SelectionCursor,
SelectionInOneBlock,
SelectionInMultipleBlocks,
SelectionWholeBlock,
SelectionState,
WPBlockSelection,
} from '../../types';
// Shared Y.Doc and Y.Map for creating Y.Text instances
const yDoc = new Y.Doc();
const yMap = yDoc.getMap( 'test-map' );
let textCounter = 0;
let blockCounter = 0;
/**
* Helper to create a Y.Text instance attached to a Y.Doc.
* Y.Text objects must be attached to a Y.Doc to create relative positions.
*
* @param yTextValue - The value of the Y.Text instance.
* @param yTextKey - The key of the Y.Text instance.
* @return The Y.Text instance, attached to the Y.Doc.
*/
function createYText( yTextValue: string, yTextKey?: string ): Y.Text {
textCounter++;
const key = yTextKey ?? `test-text-${ textCounter }`;
const yText = new Y.Text( yTextValue );
yMap.set( key, yText );
return yText;
}
/**
* Helper to create a Y.RelativePosition pointing to an index in a Y.Array.
*
* @param index - The index in the Y.Array.
* @return The Y.RelativePosition.
*/
function createBlockPosition( index: number ): Y.RelativePosition {
blockCounter++;
const yArray = new Y.Array();
yMap.set( `test-array-${ blockCounter }`, yArray );
for ( let i = 0; i <= index; i++ ) {
yArray.push( [ new Y.Map() ] );
}
return Y.createRelativePositionFromTypeIndex( yArray, index );
}
/**
* Helper to create a CursorPosition with a relative position.
*
* @param text - The text of the Y.Text instance.
* @param offset - The offset of the Y.Text instance.
* @return The CursorPosition.
*/
function createCursorPosition( text: string, offset: number ): CursorPosition {
const yText = createYText( text );
const relativePosition = Y.createRelativePositionFromTypeIndex(
yText,
offset
);
return {
relativePosition,
absoluteOffset: offset,
};
}
describe( 'areSelectionsStatesEqual', () => {
describe( 'different selection types', () => {
test( 'returns false when comparing different selection types', () => {
const selection1: SelectionNone = { type: SelectionType.None };
const selection2: SelectionCursor = {
type: SelectionType.Cursor,
cursorPosition: createCursorPosition( 'test', 0 ),
};
expect( areSelectionsStatesEqual( selection1, selection2 ) ).toBe(
false
);
} );
} );
describe( 'SelectionType.None', () => {
test( 'returns true when both selections are None', () => {
const selection1: SelectionNone = { type: SelectionType.None };
const selection2: SelectionNone = { type: SelectionType.None };
expect( areSelectionsStatesEqual( selection1, selection2 ) ).toBe(
true
);
} );
} );
describe( 'SelectionType.Cursor', () => {
test( 'returns true when cursor selections are identical', () => {
const cursorPosition = createCursorPosition( 'test text', 5 );
const selection1: SelectionCursor = {
type: SelectionType.Cursor,
cursorPosition,
};
const selection2: SelectionCursor = {
type: SelectionType.Cursor,
cursorPosition,
};
expect( areSelectionsStatesEqual( selection1, selection2 ) ).toBe(
true
);
} );
test( 'returns false when relative position differs', () => {
const selection1: SelectionCursor = {
type: SelectionType.Cursor,
cursorPosition: createCursorPosition( 'test text', 5 ),
};
const selection2: SelectionCursor = {
type: SelectionType.Cursor,
cursorPosition: createCursorPosition( 'test text', 3 ),
};
expect( areSelectionsStatesEqual( selection1, selection2 ) ).toBe(
false
);
} );
test( 'returns false when absolute offset differs', () => {
const yText = createYText( 'test text' );
const relativePosition = Y.createRelativePositionFromTypeIndex(
yText,
5
);
const selection1: SelectionCursor = {
type: SelectionType.Cursor,
cursorPosition: {
relativePosition,
absoluteOffset: 5,
},
};
const selection2: SelectionCursor = {
type: SelectionType.Cursor,
cursorPosition: {
relativePosition,
absoluteOffset: 6,
},
};
expect( areSelectionsStatesEqual( selection1, selection2 ) ).toBe(
false
);
} );
} );
describe( 'SelectionType.SelectionInOneBlock', () => {
test( 'returns true when selections in one block are identical', () => {
const cursorStartPosition = createCursorPosition( 'test text', 0 );
const cursorEndPosition = createCursorPosition( 'test text', 4 );
const selection1: SelectionInOneBlock = {
type: SelectionType.SelectionInOneBlock,
cursorStartPosition,
cursorEndPosition,
};
const selection2: SelectionInOneBlock = {
type: SelectionType.SelectionInOneBlock,
cursorStartPosition,
cursorEndPosition,
};
expect( areSelectionsStatesEqual( selection1, selection2 ) ).toBe(
true
);
} );
test( 'returns false when start position differs', () => {
const selection1: SelectionInOneBlock = {
type: SelectionType.SelectionInOneBlock,
cursorStartPosition: createCursorPosition( 'test text', 0 ),
cursorEndPosition: createCursorPosition( 'test text', 4 ),
};
const selection2: SelectionInOneBlock = {
type: SelectionType.SelectionInOneBlock,
cursorStartPosition: createCursorPosition( 'test text', 1 ),
cursorEndPosition: createCursorPosition( 'test text', 4 ),
};
expect( areSelectionsStatesEqual( selection1, selection2 ) ).toBe(
false
);
} );
test( 'returns false when end position differs', () => {
const selection1: SelectionInOneBlock = {
type: SelectionType.SelectionInOneBlock,
cursorStartPosition: createCursorPosition( 'test text', 0 ),
cursorEndPosition: createCursorPosition( 'test text', 4 ),
};
const selection2: SelectionInOneBlock = {
type: SelectionType.SelectionInOneBlock,
cursorStartPosition: createCursorPosition( 'test text', 0 ),
cursorEndPosition: createCursorPosition( 'test text', 5 ),
};
expect( areSelectionsStatesEqual( selection1, selection2 ) ).toBe(
false
);
} );
test( 'returns false when absolute offset differs in start position', () => {
const yText = createYText( 'test text' );
const relativePosition = Y.createRelativePositionFromTypeIndex(
yText,
0
);
const cursorEndPosition = createCursorPosition( 'test text', 4 );
const selection1: SelectionInOneBlock = {
type: SelectionType.SelectionInOneBlock,
cursorStartPosition: {
relativePosition,
absoluteOffset: 0,
},
cursorEndPosition,
};
const selection2: SelectionInOneBlock = {
type: SelectionType.SelectionInOneBlock,
cursorStartPosition: {
relativePosition,
absoluteOffset: 1,
},
cursorEndPosition,
};
expect( areSelectionsStatesEqual( selection1, selection2 ) ).toBe(
false
);
} );
} );
describe( 'SelectionType.SelectionInMultipleBlocks', () => {
test( 'returns true when selections in multiple blocks are identical', () => {
const cursorStartPosition = createCursorPosition(
'first block',
5
);
const cursorEndPosition = createCursorPosition( 'second block', 3 );
const selection1: SelectionInMultipleBlocks = {
type: SelectionType.SelectionInMultipleBlocks,
cursorStartPosition,
cursorEndPosition,
};
const selection2: SelectionInMultipleBlocks = {
type: SelectionType.SelectionInMultipleBlocks,
cursorStartPosition,
cursorEndPosition,
};
expect( areSelectionsStatesEqual( selection1, selection2 ) ).toBe(
true
);
} );
test( 'returns false when start cursor position differs', () => {
const selection1: SelectionInMultipleBlocks = {
type: SelectionType.SelectionInMultipleBlocks,
cursorStartPosition: createCursorPosition( 'first block', 5 ),
cursorEndPosition: createCursorPosition( 'second block', 3 ),
};
const selection2: SelectionInMultipleBlocks = {
type: SelectionType.SelectionInMultipleBlocks,
cursorStartPosition: createCursorPosition( 'first block', 6 ),
cursorEndPosition: createCursorPosition( 'second block', 3 ),
};
expect( areSelectionsStatesEqual( selection1, selection2 ) ).toBe(
false
);
} );
test( 'returns false when end cursor position differs', () => {
const selection1: SelectionInMultipleBlocks = {
type: SelectionType.SelectionInMultipleBlocks,
cursorStartPosition: createCursorPosition( 'first block', 5 ),
cursorEndPosition: createCursorPosition( 'second block', 3 ),
};
const selection2: SelectionInMultipleBlocks = {
type: SelectionType.SelectionInMultipleBlocks,
cursorStartPosition: createCursorPosition( 'first block', 5 ),
cursorEndPosition: createCursorPosition( 'second block', 4 ),
};
expect( areSelectionsStatesEqual( selection1, selection2 ) ).toBe(
false
);
} );
} );
describe( 'SelectionType.WholeBlock', () => {
test( 'returns true when whole block selections are identical', () => {
const blockPosition = createBlockPosition( 0 );
const selection1: SelectionWholeBlock = {
type: SelectionType.WholeBlock,
blockPosition,
};
const selection2: SelectionWholeBlock = {
type: SelectionType.WholeBlock,
blockPosition,
};
expect( areSelectionsStatesEqual( selection1, selection2 ) ).toBe(
true
);
} );
test( 'returns false when blockPosition differs', () => {
const selection1: SelectionWholeBlock = {
type: SelectionType.WholeBlock,
blockPosition: createBlockPosition( 0 ),
};
const selection2: SelectionWholeBlock = {
type: SelectionType.WholeBlock,
blockPosition: createBlockPosition( 1 ),
};
expect( areSelectionsStatesEqual( selection1, selection2 ) ).toBe(
false
);
} );
} );
describe( 'unknown selection type', () => {
test( 'returns false for unknown selection type', () => {
const selection1: SelectionState = {
type: 'unknown-type' as SelectionType,
} as SelectionState;
const selection2: SelectionNone = { type: SelectionType.None };
expect( areSelectionsStatesEqual( selection1, selection2 ) ).toBe(
false
);
} );
} );
} );
/**
* Helper function to create a Y.Doc with blocks for testing getSelectionState.
*/
function createTestDocWithBlocks() {
const testDoc = new Y.Doc();
const documentMap = testDoc.getMap( CRDT_RECORD_MAP_KEY );
const blocks = new Y.Array();
documentMap.set( 'blocks', blocks );
// Create block 1 with a content attribute
const block1 = new Y.Map();
block1.set( 'clientId', 'block-1' );
const block1Attrs = new Y.Map();
block1Attrs.set( 'content', new Y.Text( 'Hello world' ) );
block1.set( 'attributes', block1Attrs );
block1.set( 'innerBlocks', new Y.Array() );
blocks.push( [ block1 ] );
// Create block 2 with a content attribute
const block2 = new Y.Map();
block2.set( 'clientId', 'block-2' );
const block2Attrs = new Y.Map();
block2Attrs.set( 'content', new Y.Text( 'Second block content' ) );
block2.set( 'attributes', block2Attrs );
block2.set( 'innerBlocks', new Y.Array() );
blocks.push( [ block2 ] );
// Create block 3 with nested inner blocks
const block3 = new Y.Map();
block3.set( 'clientId', 'block-3' );
const block3Attrs = new Y.Map();
block3Attrs.set( 'content', new Y.Text( 'Parent block' ) );
block3.set( 'attributes', block3Attrs );
// Add inner block to block 3
const innerBlocks = new Y.Array();
const innerBlock = new Y.Map();
innerBlock.set( 'clientId', 'inner-block-1' );
const innerBlockAttrs = new Y.Map();
innerBlockAttrs.set( 'content', new Y.Text( 'Inner block content' ) );
innerBlock.set( 'attributes', innerBlockAttrs );
innerBlock.set( 'innerBlocks', new Y.Array() );
innerBlocks.push( [ innerBlock ] );
block3.set( 'innerBlocks', innerBlocks );
blocks.push( [ block3 ] );
return testDoc;
}
describe( 'getSelectionState', () => {
let testDoc: Y.Doc;
const blockIndexMap: Record< string, number > = {
'block-1': 0,
'block-2': 1,
'block-3': 2,
'inner-block-1': 0,
};
const blockParentMap: Record< string, string > = {
'block-1': '',
'block-2': '',
'block-3': '',
'inner-block-1': 'block-3',
};
beforeEach( () => {
testDoc = createTestDocWithBlocks();
( select as jest.Mock ).mockReturnValue( {
getBlockIndex: jest
.fn()
.mockImplementation(
( clientId: string ) => blockIndexMap[ clientId ] ?? -1
),
getBlockRootClientId: jest
.fn()
.mockImplementation(
( clientId: string ) => blockParentMap[ clientId ] ?? ''
),
getBlockName: jest.fn().mockReturnValue( 'core/paragraph' ),
} );
} );
afterEach( () => {
testDoc.destroy();
} );
describe( 'SelectionType.None', () => {
test( 'returns None when selectionStart is empty object', () => {
const selectionStart = {} as WPBlockSelection;
const selectionEnd = {} as WPBlockSelection;
const result = getSelectionState(
selectionStart,
selectionEnd,
testDoc
);
expect( result.type ).toBe( SelectionType.None );
} );
} );
describe( 'SelectionType.WholeBlock', () => {
test( 'returns WholeBlock when same clientId and no offsets', () => {
const selectionStart: WPBlockSelection = {
clientId: 'block-1',
attributeKey: 'content',
offset: undefined,
};
const selectionEnd: WPBlockSelection = {
clientId: 'block-1',
attributeKey: 'content',
offset: undefined,
};
const result = getSelectionState(
selectionStart,
selectionEnd,
testDoc
);
expect( result.type ).toBe( SelectionType.WholeBlock );
expect(
( result as SelectionWholeBlock ).blockPosition
).toBeDefined();
} );
} );
describe( 'SelectionType.Cursor', () => {
test( 'returns Cursor when same clientId and same offset', () => {
const selectionStart: WPBlockSelection = {
clientId: 'block-1',
attributeKey: 'content',
offset: 5,
};
const selectionEnd: WPBlockSelection = {
clientId: 'block-1',
attributeKey: 'content',
offset: 5,
};
const result = getSelectionState(
selectionStart,
selectionEnd,
testDoc
);
expect( result.type ).toBe( SelectionType.Cursor );
expect(
( result as SelectionCursor ).cursorPosition
).toBeDefined();
expect(
( result as SelectionCursor ).cursorPosition.absoluteOffset
).toBe( 5 );
} );
test( 'returns Cursor at start of block (offset 0)', () => {
const selectionStart: WPBlockSelection = {
clientId: 'block-1',
attributeKey: 'content',
offset: 0,
};
const selectionEnd: WPBlockSelection = {
clientId: 'block-1',
attributeKey: 'content',
offset: 0,
};
const result = getSelectionState(
selectionStart,
selectionEnd,
testDoc
);
expect( result.type ).toBe( SelectionType.Cursor );
expect(
( result as SelectionCursor ).cursorPosition.absoluteOffset
).toBe( 0 );
} );
test( 'returns None when block does not exist', () => {
const selectionStart: WPBlockSelection = {
clientId: 'non-existent-block',
attributeKey: 'content',
offset: 5,
};
const selectionEnd: WPBlockSelection = {
clientId: 'non-existent-block',
attributeKey: 'content',
offset: 5,
};
const result = getSelectionState(
selectionStart,
selectionEnd,
testDoc
);
expect( result.type ).toBe( SelectionType.None );
} );
test( 'returns None when attributeKey does not exist on block', () => {
const selectionStart: WPBlockSelection = {
clientId: 'block-1',
attributeKey: 'non-existent-attribute',
offset: 5,
};
const selectionEnd: WPBlockSelection = {
clientId: 'block-1',
attributeKey: 'non-existent-attribute',
offset: 5,
};
const result = getSelectionState(
selectionStart,
selectionEnd,
testDoc
);
expect( result.type ).toBe( SelectionType.None );
} );
test( 'returns None when attributeKey is missing', () => {
const selectionStart: WPBlockSelection = {
clientId: 'block-1',
attributeKey: undefined as unknown as string,
offset: 5,
};
const selectionEnd: WPBlockSelection = {
clientId: 'block-1',
attributeKey: undefined as unknown as string,
offset: 5,
};
const result = getSelectionState(
selectionStart,
selectionEnd,
testDoc
);
expect( result.type ).toBe( SelectionType.None );
} );
} );
describe( 'SelectionType.SelectionInOneBlock', () => {
test( 'returns SelectionInOneBlock when same clientId but different offsets', () => {
const selectionStart: WPBlockSelection = {
clientId: 'block-1',
attributeKey: 'content',
offset: 0,
};
const selectionEnd: WPBlockSelection = {
clientId: 'block-1',
attributeKey: 'content',
offset: 5,
};
const result = getSelectionState(
selectionStart,
selectionEnd,
testDoc
);
expect( result.type ).toBe( SelectionType.SelectionInOneBlock );
expect(
( result as SelectionInOneBlock ).cursorStartPosition
.absoluteOffset
).toBe( 0 );
expect(
( result as SelectionInOneBlock ).cursorEndPosition
.absoluteOffset
).toBe( 5 );
} );
test( 'returns None when start block does not exist', () => {
const selectionStart: WPBlockSelection = {
clientId: 'non-existent-block',
attributeKey: 'content',
offset: 0,
};
const selectionEnd: WPBlockSelection = {
clientId: 'non-existent-block',
attributeKey: 'content',
offset: 5,
};
const result = getSelectionState(
selectionStart,
selectionEnd,
testDoc
);
expect( result.type ).toBe( SelectionType.None );
} );
} );
describe( 'SelectionType.SelectionInMultipleBlocks', () => {
test( 'returns SelectionInMultipleBlocks when different clientIds', () => {
const selectionStart: WPBlockSelection = {
clientId: 'block-1',
attributeKey: 'content',
offset: 5,
};
const selectionEnd: WPBlockSelection = {
clientId: 'block-2',
attributeKey: 'content',
offset: 3,
};
const result = getSelectionState(
selectionStart,
selectionEnd,
testDoc
);
expect( result.type ).toBe(
SelectionType.SelectionInMultipleBlocks
);
expect(
( result as SelectionInMultipleBlocks ).cursorStartPosition
.absoluteOffset
).toBe( 5 );
expect(
( result as SelectionInMultipleBlocks ).cursorEndPosition
.absoluteOffset
).toBe( 3 );
} );
test( 'returns None when start block does not exist in multi-block selection', () => {
const selectionStart: WPBlockSelection = {
clientId: 'non-existent-block',
attributeKey: 'content',
offset: 5,
};
const selectionEnd: WPBlockSelection = {
clientId: 'block-2',
attributeKey: 'content',
offset: 3,
};
const result = getSelectionState(
selectionStart,
selectionEnd,
testDoc
);
expect( result.type ).toBe( SelectionType.None );
} );
test( 'returns None when end block does not exist in multi-block selection', () => {
const selectionStart: WPBlockSelection = {
clientId: 'block-1',
attributeKey: 'content',
offset: 5,
};
const selectionEnd: WPBlockSelection = {
clientId: 'non-existent-block',
attributeKey: 'content',
offset: 3,
};
const result = getSelectionState(
selectionStart,
selectionEnd,
testDoc
);
expect( result.type ).toBe( SelectionType.None );
} );
} );
describe( 'inner blocks', () => {
test( 'returns Cursor for selection in inner block', () => {
const selectionStart: WPBlockSelection = {
clientId: 'inner-block-1',
attributeKey: 'content',
offset: 3,
};
const selectionEnd: WPBlockSelection = {
clientId: 'inner-block-1',
attributeKey: 'content',
offset: 3,
};
const result = getSelectionState(
selectionStart,
selectionEnd,
testDoc
);
expect( result.type ).toBe( SelectionType.Cursor );
} );
test( 'returns SelectionInMultipleBlocks spanning parent and inner block', () => {
const selectionStart: WPBlockSelection = {
clientId: 'block-3',
attributeKey: 'content',
offset: 2,
};
const selectionEnd: WPBlockSelection = {
clientId: 'inner-block-1',
attributeKey: 'content',
offset: 5,
};
const result = getSelectionState(
selectionStart,
selectionEnd,
testDoc
);
expect( result.type ).toBe(
SelectionType.SelectionInMultipleBlocks
);
} );
} );
describe( 'relative position accuracy', () => {
test( 'cursor position survives text insertion before it', () => {
const selectionStart: WPBlockSelection = {
clientId: 'block-1',
attributeKey: 'content',
offset: 5,
};
const selectionEnd: WPBlockSelection = {
clientId: 'block-1',
attributeKey: 'content',
offset: 5,
};
const result = getSelectionState(
selectionStart,
selectionEnd,
testDoc
);
expect( result.type ).toBe( SelectionType.Cursor );
const cursorResult = result as SelectionCursor;
// Insert text before the cursor position
const documentMap = testDoc.getMap( CRDT_RECORD_MAP_KEY );
const blocks = documentMap.get( 'blocks' ) as Y.Array<
Y.Map< unknown >
>;
const block1 = blocks.get( 0 );
const attrs = block1.get( 'attributes' ) as Y.Map< Y.Text >;
const ytext = attrs.get( 'content' );
if ( ! ytext ) {
throw new Error( 'Y.Text not found' );
}
ytext.insert( 0, 'PREFIX ' ); // Insert at beginning
// Convert relative position back to absolute
const absolutePosition =
Y.createAbsolutePositionFromRelativePosition(
cursorResult.cursorPosition.relativePosition,
testDoc
);
// The absolute index should have shifted by 7 (length of 'PREFIX ')
expect( absolutePosition?.index ).toBe( 12 ); // 5 + 7
} );
} );
describe( 'edge cases', () => {
test( 'handles empty blocks array', () => {
const emptyDoc = new Y.Doc();
const documentMap = emptyDoc.getMap( CRDT_RECORD_MAP_KEY );
documentMap.set( 'blocks', new Y.Array() );
const selectionStart: WPBlockSelection = {
clientId: 'block-1',
attributeKey: 'content',
offset: 5,
};
const selectionEnd: WPBlockSelection = {
clientId: 'block-1',
attributeKey: 'content',
offset: 5,
};
const result = getSelectionState(
selectionStart,
selectionEnd,
emptyDoc
);
expect( result.type ).toBe( SelectionType.None );
emptyDoc.destroy();
} );
test( 'handles missing blocks in document', () => {
const docWithoutBlocks = new Y.Doc();
docWithoutBlocks.getMap( CRDT_RECORD_MAP_KEY );
// Note: not setting 'blocks' at all
const selectionStart: WPBlockSelection = {
clientId: 'block-1',
attributeKey: 'content',
offset: 5,
};
const selectionEnd: WPBlockSelection = {
clientId: 'block-1',
attributeKey: 'content',
offset: 5,
};
const result = getSelectionState(
selectionStart,
selectionEnd,
docWithoutBlocks
);
expect( result.type ).toBe( SelectionType.None );
docWithoutBlocks.destroy();
} );
} );
} );