UNPKG

@wordpress/core-data

Version:
1,186 lines (1,015 loc) 28.9 kB
/** * External dependencies */ import { Y } from '@wordpress/sync'; import { dispatch, select, subscribe, resolveSelect } from '@wordpress/data'; /** * Internal dependencies */ import { PostEditorAwareness } from '../post-editor-awareness'; import { SelectionType } from '../../utils/crdt-user-selections'; import type { SelectionNone, SelectionCursor, SelectionWholeBlock, } from '../../types'; import { CRDT_RECORD_MAP_KEY } from '../../sync'; import type { CollaboratorInfo } from '../types'; // Mock WordPress dependencies jest.mock( '@wordpress/data', () => ( { dispatch: jest.fn(), select: jest.fn(), subscribe: jest.fn(), resolveSelect: jest.fn(), } ) ); jest.mock( '@wordpress/block-editor', () => ( { store: 'core/block-editor', } ) ); // Mock window.navigator.userAgent const mockUserAgent = ( userAgent: string ) => { Object.defineProperty( window.navigator, 'userAgent', { value: userAgent, configurable: true, } ); }; const mockAvatarUrls = { '24': 'https://example.com/avatar-24.png', '48': 'https://example.com/avatar-48.png', '96': 'https://example.com/avatar-96.png', }; const createMockUser = () => ( { id: 1, name: 'Test User', slug: 'test-user', avatar_urls: mockAvatarUrls, } ); type MockBlock = { clientId: string; name?: string; innerBlocks: MockBlock[]; }; interface MockBlockEditorOverrides { blocks?: MockBlock[]; getBlocks?: jest.Mock; getBlockName?: string; getSelectionStart?: jest.Mock; getSelectionEnd?: jest.Mock; } /** * Mock the block-editor store selectors returned by `select( blockEditorStore )`. * * Only the fields that vary between tests need to be passed — everything else * gets sensible defaults. Pass `blocks` for the common case (static return * value) or `getBlocks` when you need `mockImplementation` (e.g. template mode). * * Returns `{ getBlocks }` so callers can assert on it (e.g. `toHaveBeenCalledWith`). * * @param overrides - Optional selector overrides. */ function mockBlockEditorStore( overrides: MockBlockEditorOverrides = {} ) { const defaultBlocks = [ { clientId: 'block-1', name: 'core/paragraph', innerBlocks: [], }, ]; const getBlocks = overrides.getBlocks ?? jest.fn().mockReturnValue( overrides.blocks ?? defaultBlocks ); ( select as jest.Mock ).mockReturnValue( { getSelectionStart: overrides.getSelectionStart ?? jest.fn().mockReturnValue( {} ), getSelectionEnd: overrides.getSelectionEnd ?? jest.fn().mockReturnValue( {} ), getSelectedBlocksInitialCaretPosition: jest .fn() .mockReturnValue( null ), getBlockIndex: jest.fn().mockReturnValue( 0 ), getBlockRootClientId: jest.fn().mockReturnValue( '' ), getBlockName: jest .fn() .mockReturnValue( overrides.getBlockName ?? 'core/paragraph' ), getBlocks, } ); return { getBlocks }; } /** * Helper to create a single Yjs block with optional text content and inner blocks. * @param clientId * @param name * @param options * @param options.textContent * @param options.innerBlocks */ function createYBlock( clientId: string, name: string, { textContent, innerBlocks = [], }: { textContent?: string; innerBlocks?: Y.Map< any >[] } = {} ): Y.Map< any > { const block = new Y.Map(); block.set( 'clientId', clientId ); block.set( 'name', name ); const attrs = new Y.Map(); if ( textContent !== undefined ) { attrs.set( 'content', new Y.Text( textContent ) ); } block.set( 'attributes', attrs ); const inner = new Y.Array(); if ( innerBlocks.length ) { inner.push( innerBlocks ); } block.set( 'innerBlocks', inner ); return block; } /** * Helper function to create a Y.Doc with blocks structure for testing * @param blocks */ function createTestDocWithBlocks( blocks?: Y.Map< any >[] ) { const ydoc = new Y.Doc(); const documentMap = ydoc.getMap( CRDT_RECORD_MAP_KEY ); const yBlocks = new Y.Array(); documentMap.set( 'blocks', yBlocks ); if ( blocks ) { yBlocks.push( blocks ); } else { // Default: single block with content const block = createYBlock( 'block-1', 'core/paragraph', { textContent: 'Hello world', } ); yBlocks.push( [ block ] ); } return ydoc; } describe( 'PostEditorAwareness', () => { let doc: Y.Doc; let subscribeCallback: ( () => void ) | null = null; let mockEditEntityRecord: jest.Mock; beforeEach( () => { jest.useFakeTimers(); doc = createTestDocWithBlocks(); mockUserAgent( 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' ); jest.spyOn( Date, 'now' ).mockReturnValue( 1704067200000 ); mockBlockEditorStore(); // Mock subscribe to capture the callback ( subscribe as jest.Mock ).mockImplementation( ( callback ) => { subscribeCallback = callback; return jest.fn(); // unsubscribe } ); // Mock dispatch mockEditEntityRecord = jest.fn(); ( dispatch as jest.Mock ).mockReturnValue( { editEntityRecord: mockEditEntityRecord, } ); // Mock resolveSelect for getCurrentUser ( resolveSelect as jest.Mock ).mockReturnValue( { getCurrentUser: jest.fn().mockResolvedValue( createMockUser() ), } ); } ); afterEach( () => { jest.useRealTimers(); jest.restoreAllMocks(); subscribeCallback = null; doc.destroy(); } ); describe( 'construction', () => { test( 'should create instance with Y.Doc and entity info', () => { const awareness = new PostEditorAwareness( doc, 'postType', 'post', 123 ); expect( awareness ).toBeInstanceOf( PostEditorAwareness ); } ); test( 'should have correct clientID from doc', () => { const awareness = new PostEditorAwareness( doc, 'postType', 'post', 123 ); expect( awareness.clientID ).toBe( doc.clientID ); } ); } ); describe( 'setUp', () => { test( 'should be idempotent', () => { const awareness = new PostEditorAwareness( doc, 'postType', 'post', 123 ); awareness.setUp(); awareness.setUp(); // Subscribe should only be called once expect( subscribe ).toHaveBeenCalledTimes( 1 ); } ); test( 'should subscribe to selection changes', () => { const awareness = new PostEditorAwareness( doc, 'postType', 'post', 123 ); awareness.setUp(); expect( subscribe ).toHaveBeenCalled(); expect( subscribeCallback ).not.toBeNull(); } ); } ); describe( 'selection change handling', () => { test( 'should not trigger update when selection has not changed', () => { const awareness = new PostEditorAwareness( doc, 'postType', 'post', 123 ); awareness.setUp(); // Trigger subscribe callback with same selection subscribeCallback?.(); // Should not call editEntityRecord for unchanged selection expect( mockEditEntityRecord ).not.toHaveBeenCalled(); } ); test( 'should trigger update when selection changes', () => { const mockGetSelectionStart = jest .fn() .mockReturnValueOnce( {} ) .mockReturnValueOnce( { clientId: 'block-1', attributeKey: 'content', offset: 5, } ); const mockGetSelectionEnd = jest .fn() .mockReturnValueOnce( {} ) .mockReturnValueOnce( { clientId: 'block-1', attributeKey: 'content', offset: 5, } ); mockBlockEditorStore( { getSelectionStart: mockGetSelectionStart, getSelectionEnd: mockGetSelectionEnd, } ); const awareness = new PostEditorAwareness( doc, 'postType', 'post', 123 ); awareness.setUp(); // Trigger subscribe callback with new selection subscribeCallback?.(); // Should call editEntityRecord expect( mockEditEntityRecord ).toHaveBeenCalledWith( 'postType', 'post', 123, expect.objectContaining( { selection: expect.any( Object ), } ), expect.objectContaining( { undoIgnore: true, } ) ); } ); test( 'should debounce local cursor updates', () => { const mockGetSelectionStart = jest .fn() .mockReturnValueOnce( {} ) .mockReturnValueOnce( { clientId: 'block-1', attributeKey: 'content', offset: 5, } ); const mockGetSelectionEnd = jest .fn() .mockReturnValueOnce( {} ) .mockReturnValueOnce( { clientId: 'block-1', attributeKey: 'content', offset: 5, } ); mockBlockEditorStore( { getSelectionStart: mockGetSelectionStart, getSelectionEnd: mockGetSelectionEnd, } ); const awareness = new PostEditorAwareness( doc, 'postType', 'post', 123 ); awareness.setUp(); // Trigger selection change subscribeCallback?.(); // Advance timers past debounce jest.advanceTimersByTime( 10 ); // Should have processed the debounced update expect( mockEditEntityRecord ).toHaveBeenCalled(); } ); } ); describe( 'areEditorStatesEqual', () => { test( 'should return true when both states are undefined', () => { const awareness = new PostEditorAwareness( doc, 'postType', 'post', 123 ); // Access the protected method via testing // We can test this indirectly through setLocalStateField behavior awareness.setUp(); // Set editorState with a selection const selectionState: SelectionNone = { type: SelectionType.None, }; awareness.setLocalStateField( 'editorState', { selection: selectionState, } ); // Subscribe to track updates const callback = jest.fn(); awareness.onStateChange( callback ); // Emit change event awareness.emit( 'change', [ { added: [], updated: [ awareness.clientID ], removed: [], }, ] ); callback.mockClear(); // Set same state again - should not trigger unnecessary updates awareness.setLocalStateField( 'editorState', { selection: selectionState, } ); // Emit change event again awareness.emit( 'change', [ { added: [], updated: [ awareness.clientID ], removed: [], }, ] ); // Callback should not be called for equal editor states expect( callback ).not.toHaveBeenCalled(); } ); } ); describe( 'convertSelectionStateToAbsolute', () => { test( 'should return nulls when relative position cannot be resolved', () => { const awareness = new PostEditorAwareness( doc, 'postType', 'post', 123 ); // Create a Y.Doc for creating a relative position, then destroy it // This creates a relative position that cannot be resolved in the awareness doc const tempDoc = new Y.Doc(); const tempText = tempDoc.getText( 'temp' ); tempText.insert( 0, 'Hello' ); const relativePosition = Y.createRelativePositionFromTypeIndex( tempText, 2 ); tempDoc.destroy(); const selection: SelectionCursor = { type: SelectionType.Cursor, cursorPosition: { relativePosition, absoluteOffset: 2, }, }; const result = awareness.convertSelectionStateToAbsolute( selection ); // Should return nulls when the relative position's type cannot be found expect( result.textIndex ).toBeNull(); expect( result.localClientId ).toBeNull(); } ); test( 'should return text index and block client ID for valid cursor selection', () => { const awareness = new PostEditorAwareness( doc, 'postType', 'post', 123 ); // Get the Y.Text from the doc const documentMap = doc.getMap( CRDT_RECORD_MAP_KEY ); const blocks = documentMap.get( 'blocks' ) as Y.Array< Y.Map< any > >; const block = blocks.get( 0 ); const attrs = block.get( 'attributes' ) as Y.Map< Y.Text >; const yText = attrs.get( 'content' ); // Create a relative position const relativePosition = Y.createRelativePositionFromTypeIndex( yText as Y.Text, 5 ); const selection: SelectionCursor = { type: SelectionType.Cursor, cursorPosition: { relativePosition, absoluteOffset: 5, }, }; const result = awareness.convertSelectionStateToAbsolute( selection ); expect( result.textIndex ).toBe( 5 ); expect( result.localClientId ).toBe( 'block-1' ); } ); test( 'should resolve WholeBlock selection to block client ID', () => { const awareness = new PostEditorAwareness( doc, 'postType', 'post', 123 ); // Get the blocks array from the doc const documentMap = doc.getMap( CRDT_RECORD_MAP_KEY ); const blocks = documentMap.get( 'blocks' ) as Y.Array< Y.Map< any > >; // Create a block relative position const blockPosition = Y.createRelativePositionFromTypeIndex( blocks, 0 ); const selection: SelectionWholeBlock = { type: SelectionType.WholeBlock, blockPosition, }; const result = awareness.convertSelectionStateToAbsolute( selection ); expect( result.textIndex ).toBeNull(); expect( result.localClientId ).toBe( 'block-1' ); } ); } ); describe( 'getDebugData', () => { test( 'should return debug data object', async () => { const awareness = new PostEditorAwareness( doc, 'postType', 'post', 123 ); awareness.setUp(); // Wait for async setup await Promise.resolve(); // Emit a change to populate seenStates awareness.emit( 'change', [ { added: [], updated: [ awareness.clientID ], removed: [], }, ] ); const debugData = awareness.getDebugData(); expect( debugData ).toHaveProperty( 'doc' ); expect( debugData ).toHaveProperty( 'clients' ); expect( debugData ).toHaveProperty( 'collaboratorMap' ); } ); test( 'should include document data', async () => { const awareness = new PostEditorAwareness( doc, 'postType', 'post', 123 ); awareness.setUp(); await Promise.resolve(); const debugData = awareness.getDebugData(); expect( debugData.doc ).toHaveProperty( CRDT_RECORD_MAP_KEY ); } ); test( 'should include client items', async () => { const awareness = new PostEditorAwareness( doc, 'postType', 'post', 123 ); awareness.setUp(); await Promise.resolve(); const debugData = awareness.getDebugData(); expect( debugData.clients ).toBeDefined(); expect( typeof debugData.clients ).toBe( 'object' ); } ); } ); describe( 'equalityFieldChecks', () => { test( 'should include collaboratorInfo check from baseEqualityFieldChecks', async () => { const awareness = new PostEditorAwareness( doc, 'postType', 'post', 123 ); awareness.setUp(); await Promise.resolve(); // Set collaboratorInfo and verify equality check works const collaboratorInfo: CollaboratorInfo = { id: 1, name: 'Test', slug: 'test', avatar_urls: mockAvatarUrls, browserType: 'Chrome', enteredAt: 1704067200000, }; awareness.setLocalStateField( 'collaboratorInfo', collaboratorInfo ); const storedInfo = awareness.getLocalStateField( 'collaboratorInfo' ); expect( storedInfo ).toEqual( collaboratorInfo ); } ); test( 'should include editorState check', async () => { const awareness = new PostEditorAwareness( doc, 'postType', 'post', 123 ); awareness.setUp(); await Promise.resolve(); const editorState = { selection: { type: SelectionType.None, } as SelectionNone, }; awareness.setLocalStateField( 'editorState', editorState ); const storedState = awareness.getLocalStateField( 'editorState' ); expect( storedState ).toEqual( editorState ); } ); } ); describe( 'state subscription', () => { test( 'should notify subscribers on editorState change', async () => { const awareness = new PostEditorAwareness( doc, 'postType', 'post', 123 ); const callback = jest.fn(); awareness.onStateChange( callback ); awareness.setUp(); await Promise.resolve(); // Set initial state to trigger callback awareness.setLocalStateField( 'editorState', { selection: { type: SelectionType.None }, } ); // Emit change event awareness.emit( 'change', [ { added: [], updated: [ awareness.clientID ], removed: [], }, ] ); expect( callback ).toHaveBeenCalled(); } ); } ); describe( 'convertSelectionStateToAbsolute with nested blocks', () => { test( 'should resolve cursor in second root block (path [1])', () => { const nestedDoc = createTestDocWithBlocks( [ createYBlock( 'yjs-block-0', 'core/paragraph', { textContent: 'First', } ), createYBlock( 'yjs-block-1', 'core/paragraph', { textContent: 'Second', } ), createYBlock( 'yjs-block-2', 'core/paragraph', { textContent: 'Third', } ), ] ); mockBlockEditorStore( { blocks: [ { clientId: 'local-0', innerBlocks: [] }, { clientId: 'local-1', innerBlocks: [] }, { clientId: 'local-2', innerBlocks: [] }, ], } ); const awareness = new PostEditorAwareness( nestedDoc, 'postType', 'post', 123 ); // Create a cursor in the third block's text const documentMap = nestedDoc.getMap( CRDT_RECORD_MAP_KEY ); const blocks = documentMap.get( 'blocks' ) as Y.Array< Y.Map< any > >; const block2 = blocks.get( 2 ); const attrs2 = block2.get( 'attributes' ) as Y.Map< Y.Text >; const yText2 = attrs2.get( 'content' ) as Y.Text; const relativePosition = Y.createRelativePositionFromTypeIndex( yText2, 2 ); const selection: SelectionCursor = { type: SelectionType.Cursor, cursorPosition: { relativePosition, absoluteOffset: 2, }, }; const result = awareness.convertSelectionStateToAbsolute( selection ); expect( result.textIndex ).toBe( 2 ); expect( result.localClientId ).toBe( 'local-2' ); nestedDoc.destroy(); } ); test( 'should resolve cursor in a nested inner block (path [0, 1])', () => { const innerParagraph0 = createYBlock( 'yjs-inner-0', 'core/paragraph', { textContent: 'Inner zero' } ); const innerParagraph1 = createYBlock( 'yjs-inner-1', 'core/paragraph', { textContent: 'Inner one' } ); const outerColumn = createYBlock( 'yjs-outer', 'core/column', { innerBlocks: [ innerParagraph0, innerParagraph1 ], } ); const nestedDoc = createTestDocWithBlocks( [ outerColumn ] ); mockBlockEditorStore( { blocks: [ { clientId: 'local-outer', innerBlocks: [ { clientId: 'local-inner-0', innerBlocks: [] }, { clientId: 'local-inner-1', innerBlocks: [] }, ], }, ], } ); const awareness = new PostEditorAwareness( nestedDoc, 'postType', 'post', 123 ); // Create cursor in the second inner paragraph const documentMap = nestedDoc.getMap( CRDT_RECORD_MAP_KEY ); const blocks = documentMap.get( 'blocks' ) as Y.Array< Y.Map< any > >; const outer = blocks.get( 0 ); const innerBlocks = outer.get( 'innerBlocks' ) as Y.Array< Y.Map< any > >; const innerBlock1 = innerBlocks.get( 1 ); const innerAttrs = innerBlock1.get( 'attributes' ) as Y.Map< Y.Text >; const yText = innerAttrs.get( 'content' ) as Y.Text; const relativePosition = Y.createRelativePositionFromTypeIndex( yText, 5 ); const selection: SelectionCursor = { type: SelectionType.Cursor, cursorPosition: { relativePosition, absoluteOffset: 5, }, }; const result = awareness.convertSelectionStateToAbsolute( selection ); expect( result.textIndex ).toBe( 5 ); expect( result.localClientId ).toBe( 'local-inner-1' ); nestedDoc.destroy(); } ); test( 'should resolve WholeBlock for a nested image block', () => { const innerImage = createYBlock( 'yjs-img', 'core/image' ); const outerColumn = createYBlock( 'yjs-col', 'core/column', { innerBlocks: [ innerImage ], } ); const nestedDoc = createTestDocWithBlocks( [ outerColumn ] ); mockBlockEditorStore( { blocks: [ { clientId: 'local-col', innerBlocks: [ { clientId: 'local-img', innerBlocks: [] }, ], }, ], } ); const awareness = new PostEditorAwareness( nestedDoc, 'postType', 'post', 123 ); // Create a WholeBlock relative position for the inner image const documentMap = nestedDoc.getMap( CRDT_RECORD_MAP_KEY ); const blocks = documentMap.get( 'blocks' ) as Y.Array< Y.Map< any > >; const outer = blocks.get( 0 ); const innerBlocks = outer.get( 'innerBlocks' ) as Y.Array< Y.Map< any > >; const blockPosition = Y.createRelativePositionFromTypeIndex( innerBlocks, 0 ); const selection: SelectionWholeBlock = { type: SelectionType.WholeBlock, blockPosition, }; const result = awareness.convertSelectionStateToAbsolute( selection ); expect( result.textIndex ).toBeNull(); expect( result.localClientId ).toBe( 'local-img' ); nestedDoc.destroy(); } ); test( 'should resolve a deeply nested block (path [1, 0, 1])', () => { const deepParagraph0 = createYBlock( 'yjs-deep-0', 'core/paragraph', { textContent: 'Deep zero' } ); const deepParagraph1 = createYBlock( 'yjs-deep-1', 'core/paragraph', { textContent: 'Deep one content' } ); const midColumn = createYBlock( 'yjs-mid', 'core/column', { innerBlocks: [ deepParagraph0, deepParagraph1 ], } ); const outerColumns0 = createYBlock( 'yjs-outer-0', 'core/columns' ); const outerColumns1 = createYBlock( 'yjs-outer-1', 'core/columns', { innerBlocks: [ midColumn ], } ); const nestedDoc = createTestDocWithBlocks( [ outerColumns0, outerColumns1, ] ); mockBlockEditorStore( { blocks: [ { clientId: 'local-outer-0', innerBlocks: [] }, { clientId: 'local-outer-1', innerBlocks: [ { clientId: 'local-mid', innerBlocks: [ { clientId: 'local-deep-0', innerBlocks: [], }, { clientId: 'local-deep-1', innerBlocks: [], }, ], }, ], }, ], } ); const awareness = new PostEditorAwareness( nestedDoc, 'postType', 'post', 123 ); // Create cursor in the deeply nested second paragraph const documentMap = nestedDoc.getMap( CRDT_RECORD_MAP_KEY ); const blocks = documentMap.get( 'blocks' ) as Y.Array< Y.Map< any > >; const outer1 = blocks.get( 1 ); const outer1Inner = outer1.get( 'innerBlocks' ) as Y.Array< Y.Map< any > >; const mid = outer1Inner.get( 0 ); const midInner = mid.get( 'innerBlocks' ) as Y.Array< Y.Map< any > >; const deep1 = midInner.get( 1 ); const deep1Attrs = deep1.get( 'attributes' ) as Y.Map< Y.Text >; const yText = deep1Attrs.get( 'content' ) as Y.Text; const relativePosition = Y.createRelativePositionFromTypeIndex( yText, 7 ); const selection: SelectionCursor = { type: SelectionType.Cursor, cursorPosition: { relativePosition, absoluteOffset: 7, }, }; const result = awareness.convertSelectionStateToAbsolute( selection ); expect( result.textIndex ).toBe( 7 ); expect( result.localClientId ).toBe( 'local-deep-1' ); nestedDoc.destroy(); } ); } ); describe( 'template mode (core/post-content handling)', () => { test( 'should resolve cursor when getBlocks returns template tree with core/post-content', () => { // Yjs doc has only the post content blocks (no template wrapper) const templateDoc = createTestDocWithBlocks( [ createYBlock( 'yjs-para-0', 'core/paragraph', { textContent: 'Post paragraph 1', } ), createYBlock( 'yjs-para-1', 'core/paragraph', { textContent: 'Post paragraph 2', } ), ] ); // In template mode, getBlocks() returns the full template tree. // The Yjs paths are relative to post content, so the receiver needs // to find core/post-content and navigate from there. const postContentClientId = 'local-post-content'; const mockGetBlocks = jest .fn() .mockImplementation( ( rootClientId?: string ) => { if ( rootClientId === postContentClientId ) { // Controlled inner blocks of core/post-content return [ { clientId: 'local-para-0', name: 'core/paragraph', innerBlocks: [], }, { clientId: 'local-para-1', name: 'core/paragraph', innerBlocks: [], }, ]; } // Full template tree return [ { clientId: 'local-header', name: 'core/template-part', innerBlocks: [], }, { clientId: 'local-group', name: 'core/group', innerBlocks: [ { clientId: postContentClientId, name: 'core/post-content', innerBlocks: [], // empty because they're controlled inner blocks }, ], }, { clientId: 'local-footer', name: 'core/template-part', innerBlocks: [], }, ]; } ); mockBlockEditorStore( { getBlocks: mockGetBlocks } ); const awareness = new PostEditorAwareness( templateDoc, 'postType', 'post', 123 ); // Create cursor in the second post content paragraph const documentMap = templateDoc.getMap( CRDT_RECORD_MAP_KEY ); const blocks = documentMap.get( 'blocks' ) as Y.Array< Y.Map< any > >; const block1 = blocks.get( 1 ); const attrs = block1.get( 'attributes' ) as Y.Map< Y.Text >; const yText = attrs.get( 'content' ) as Y.Text; const relativePosition = Y.createRelativePositionFromTypeIndex( yText, 4 ); const selection: SelectionCursor = { type: SelectionType.Cursor, cursorPosition: { relativePosition, absoluteOffset: 4, }, }; const result = awareness.convertSelectionStateToAbsolute( selection ); expect( result.textIndex ).toBe( 4 ); // Should resolve to the post-content inner block, not a template block expect( result.localClientId ).toBe( 'local-para-1' ); // Verify getBlocks was called with the post-content clientId expect( mockGetBlocks ).toHaveBeenCalledWith( postContentClientId ); templateDoc.destroy(); } ); test( 'should resolve WholeBlock in template mode', () => { const templateDoc = createTestDocWithBlocks( [ createYBlock( 'yjs-img', 'core/image' ), ] ); const postContentClientId = 'local-post-content'; const mockGetBlocks = jest .fn() .mockImplementation( ( rootClientId?: string ) => { if ( rootClientId === postContentClientId ) { return [ { clientId: 'local-img', name: 'core/image', innerBlocks: [], }, ]; } return [ { clientId: 'local-group', name: 'core/group', innerBlocks: [ { clientId: postContentClientId, name: 'core/post-content', innerBlocks: [], }, ], }, ]; } ); mockBlockEditorStore( { getBlocks: mockGetBlocks, getBlockName: 'core/image', } ); const awareness = new PostEditorAwareness( templateDoc, 'postType', 'post', 123 ); const documentMap = templateDoc.getMap( CRDT_RECORD_MAP_KEY ); const blocks = documentMap.get( 'blocks' ) as Y.Array< Y.Map< any > >; const blockPosition = Y.createRelativePositionFromTypeIndex( blocks, 0 ); const selection: SelectionWholeBlock = { type: SelectionType.WholeBlock, blockPosition, }; const result = awareness.convertSelectionStateToAbsolute( selection ); expect( result.textIndex ).toBeNull(); expect( result.localClientId ).toBe( 'local-img' ); templateDoc.destroy(); } ); test( 'should fall back to root blocks when no core/post-content exists', () => { // Normal mode (no template) — should use root blocks directly const normalDoc = createTestDocWithBlocks( [ createYBlock( 'yjs-para', 'core/paragraph', { textContent: 'Normal mode', } ), ] ); mockBlockEditorStore( { blocks: [ { clientId: 'local-para', innerBlocks: [] } ], } ); const awareness = new PostEditorAwareness( normalDoc, 'postType', 'post', 123 ); const documentMap = normalDoc.getMap( CRDT_RECORD_MAP_KEY ); const blocks = documentMap.get( 'blocks' ) as Y.Array< Y.Map< any > >; const block = blocks.get( 0 ); const attrs = block.get( 'attributes' ) as Y.Map< Y.Text >; const yText = attrs.get( 'content' ) as Y.Text; const relativePosition = Y.createRelativePositionFromTypeIndex( yText, 3 ); const selection: SelectionCursor = { type: SelectionType.Cursor, cursorPosition: { relativePosition, absoluteOffset: 3, }, }; const result = awareness.convertSelectionStateToAbsolute( selection ); expect( result.textIndex ).toBe( 3 ); expect( result.localClientId ).toBe( 'local-para' ); normalDoc.destroy(); } ); } ); } );