/
githubmirror
/
gutenberg
Обзор
Документация
Войти
/
githubmirror
/
gutenberg
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
trunk
packages/core-data/src/utils/test/crdt.ts
1 233 строки
33 KB
Marco Ciampini
ESLint: Remove legacy import suppressions (#81338)
07 авг 2026, 15:52
Не верифицирован
07 авг 2026, 15:52
8181ec4
Код
Авторство
О чём код?
import { Y } from '@wordpress/sync'; import { describe, expect, it, jest, beforeEach } from '@jest/globals'; /** * Mock getBlockTypes so CRDT merging can identify rich-text attributes. * Also stub __unstableSerializeAndClean so we can assert how it's invoked * (the real implementation returns "" without registered block types, which * isn't useful for asserting closure-capture behavior). */ jest.mock( '@wordpress/blocks', () => { const actual = jest.requireActual( '@wordpress/blocks' ) as Record< string, unknown >; return { ...actual, getBlockTypes: () => [ { name: 'core/paragraph', attributes: { content: { type: 'rich-text' } }, }, { name: 'core/table', attributes: { hasFixedLayout: { type: 'boolean' }, caption: { type: 'rich-text' }, body: { type: 'array', query: { cells: { type: 'array', query: { content: { type: 'rich-text' }, tag: { type: 'string' }, }, }, }, }, }, }, ], // Mocked so tests can control what the Code Editor sync path "parses" // from raw content without needing real block-type registration. parse: jest.fn( () => [] ), __unstableSerializeAndClean: jest.fn( ( blocks: unknown[] ) => `serialized:${ blocks?.length ?? 0 }` ), }; } ); import { parse } from '@wordpress/blocks'; import { RichTextData } from '@wordpress/rich-text'; import { CRDT_RECORD_MAP_KEY } from '../../sync'; import { applyPostChangesToCRDTDoc, defaultCollectionSyncConfig, defaultSyncConfig, getPostChangesFromCRDTDoc, POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE, type PostChanges, type YPostRecord, } from '../crdt'; import type { Block, YBlock, YBlockRecord, YBlocks } from '../crdt-blocks'; import { updateSelectionHistory } from '../crdt-selection'; import { createYMap, getRootMap, type YMapWrap } from '../crdt-utils'; import type { Post } from '../../entity-types'; // Default synced properties matching the base set built in entities.js, // plus 'categories' and 'tags' as example taxonomy rest_base values. const defaultSyncedProperties = new Set< string >( [ 'blocks', 'categories', 'content', 'date', 'excerpt', 'meta', 'slug', 'status', 'tags', 'title', ] ); describe( 'defaultSyncConfig', () => { // Regression test for https://github.com/WordPress/gutenberg/issues/79907: // reporting unchanged properties as changes marks resolved records // (e.g. taxonomy terms) as dirty whenever a synced update arrives. it( 'getChangesFromCRDTDoc returns no changes when the document matches the edited record', () => { const doc = new Y.Doc(); const record = { id: 1, name: 'Uncategorized', slug: 'uncategorized', meta: [], _links: { self: [ { href: 'https://example.com' } ] }, }; defaultSyncConfig.applyChangesToCRDTDoc( doc, record ); // The edited record deep-equals the document contents, but // `Y.Map.toJSON()` returns fresh object instances. const changes = defaultSyncConfig.getChangesFromCRDTDoc( doc, { ...record, meta: [], _links: { self: [ { href: 'https://example.com' } ] }, } ); expect( changes ).toEqual( {} ); doc.destroy(); } ); it( 'getChangesFromCRDTDoc returns only the properties that differ from the edited record', () => { const doc = new Y.Doc(); defaultSyncConfig.applyChangesToCRDTDoc( doc, { id: 1, name: 'Renamed category', slug: 'uncategorized', } ); const changes = defaultSyncConfig.getChangesFromCRDTDoc( doc, { id: 1, name: 'Uncategorized', slug: 'uncategorized', } ); expect( changes ).toEqual( { name: 'Renamed category' } ); doc.destroy(); } ); } ); describe( 'defaultCollectionSyncConfig', () => { it( 'has no-op applyChangesToCRDTDoc', () => { const doc = new Y.Doc(); // Should not throw and return undefined. expect( defaultCollectionSyncConfig.applyChangesToCRDTDoc( doc, { title: 'test', } ) ).toBeUndefined(); doc.destroy(); } ); it( 'has getChangesFromCRDTDoc that returns empty object', () => { const doc = new Y.Doc(); const result = defaultCollectionSyncConfig.getChangesFromCRDTDoc( doc, { title: 'test', } ); expect( result ).toEqual( {} ); doc.destroy(); } ); it( 'shouldSync returns true when objectId is null (collection)', () => { expect( defaultCollectionSyncConfig.shouldSync?.( 'comment', null ) ).toBe( true ); } ); it( 'shouldSync returns false when objectId is provided (individual record)', () => { expect( defaultCollectionSyncConfig.shouldSync?.( 'comment', '123' ) ).toBe( false ); expect( defaultCollectionSyncConfig.shouldSync?.( 'comment', 'foo' ) ).toBe( false ); } ); } ); describe( 'crdt', () => { let doc: Y.Doc; beforeEach( () => { doc = new Y.Doc(); jest.clearAllMocks(); jest.useFakeTimers(); } ); afterEach( () => { jest.runAllTimers(); jest.useRealTimers(); doc.destroy(); } ); describe( 'applyPostChangesToCRDTDoc', () => { let map: YMapWrap< YPostRecord >; beforeEach( () => { map = getRootMap< YPostRecord >( doc, CRDT_RECORD_MAP_KEY ); } ); it( 'applies simple property changes', () => { const changes = { title: 'New Title', } as PostChanges; applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties ); const title = map.get( 'title' ); expect( title ).toBeInstanceOf( Y.Text ); expect( title?.toString() ).toBe( 'New Title' ); } ); it( 'does not sync disallowed properties', () => { const changes = { title: 'New Title', unsyncedProperty: 'value', } as unknown as PostChanges; applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties ); expect( map.has( 'unsyncedProperty' ) ).toBe( false ); expect( map.get( 'title' )?.toString() ).toBe( 'New Title' ); } ); it( 'does not sync function values', () => { const changes = { title: () => 'function value', } as unknown as PostChanges; applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties ); expect( map.has( 'title' ) ).toBe( false ); } ); it( 'handles title with RenderedText format', () => { const changes = { title: { raw: 'Raw Title', rendered: 'Rendered Title' }, }; applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties ); const title = map.get( 'title' ); expect( title ).toBeInstanceOf( Y.Text ); expect( title?.toString() ).toBe( 'Raw Title' ); } ); it( 'skips "Auto Draft" template title when no current value exists', () => { const changes = { title: 'Auto Draft', } as PostChanges; applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties ); const title = map.get( 'title' ); expect( title ).toBeInstanceOf( Y.Text ); expect( title?.toString() ).toBe( '' ); } ); it( 'skips "Auto Draft" template title when current value is empty Y.Text', () => { // First set an empty title (simulates a prior sync that cleared it). applyPostChangesToCRDTDoc( doc, { title: '' } as PostChanges, defaultSyncedProperties ); const title = map.get( 'title' ); expect( title ).toBeInstanceOf( Y.Text ); expect( title?.toString() ).toBe( '' ); // Now sync "Auto Draft" — should still be suppressed. applyPostChangesToCRDTDoc( doc, { title: 'Auto Draft' } as PostChanges, defaultSyncedProperties ); expect( map.get( 'title' )?.toString() ).toBe( '' ); } ); it( 'handles excerpt with RenderedText format', () => { const changes = { excerpt: { protected: false, raw: 'Raw excerpt', rendered: 'Rendered excerpt', }, }; applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties ); const excerpt = map.get( 'excerpt' ); expect( excerpt ).toBeInstanceOf( Y.Text ); expect( excerpt?.toString() ).toBe( 'Raw excerpt' ); } ); it( 'does not sync empty slug', () => { const changes = { slug: '', }; applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties ); expect( map.has( 'slug' ) ).toBe( false ); } ); it( 'syncs non-empty slug', () => { const changes = { slug: 'my-post-slug', }; applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties ); expect( map.get( 'slug' ) ).toBe( 'my-post-slug' ); } ); it( 'merges blocks changes', () => { map.set( 'blocks', new Y.Array< YBlock >() ); const changes = { blocks: [ { name: 'core/paragraph', attributes: { content: 'Test' }, innerBlocks: [], }, ], }; applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties ); expect( ( map.get( 'blocks' ) as YBlocks ).toJSON() ).toEqual( changes.blocks ); } ); it( 'initializes blocks as Y.Array when not present', () => { const changes = { blocks: [], }; applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties ); const blocks = map.get( 'blocks' ); expect( blocks ).toBeInstanceOf( Y.Array ); } ); it( 'sets blocks to undefined when blocks value is undefined and no content is provided', () => { // First, set some blocks. map.set( 'blocks', new Y.Array< YBlock >() ); const changes = { blocks: undefined, }; applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties ); // The key should still exist, but the value should be undefined. expect( map.has( 'blocks' ) ).toBe( true ); expect( map.get( 'blocks' ) ).toBeUndefined(); } ); it( 'parses content into blocks when blocks=undefined is paired with new content', () => { // Pre-populate the Y.Doc with two stable blocks. Simulates the // state after the initial sync: peers share the same blocks Y.Array // with stable clientIds on every YBlock. applyPostChangesToCRDTDoc( doc, { blocks: [ { name: 'core/paragraph', attributes: { content: 'Hello' }, innerBlocks: [], clientId: 'stable-first', }, { name: 'core/paragraph', attributes: { content: 'World' }, innerBlocks: [], clientId: 'stable-second', }, ], } as PostChanges, defaultSyncedProperties ); // The Code Editor flow: dispatch `{ content, blocks: undefined }` // when the user types. The new HTML edits the second paragraph // only. `parse()` is mocked to return blocks with freshly minted // clientIds — the sync layer must not let those overwrite the // stable clientIds already in the Y.Array. ( parse as jest.Mock ).mockReturnValueOnce( [ { name: 'core/paragraph', attributes: { content: 'Hello' }, innerBlocks: [], clientId: 'fresh-first', }, { name: 'core/paragraph', attributes: { content: 'World!' }, innerBlocks: [], clientId: 'fresh-second', }, ] ); applyPostChangesToCRDTDoc( doc, { content: '<!-- wp:paragraph --><p>Hello</p><!-- /wp:paragraph -->' + '<!-- wp:paragraph --><p>World!</p><!-- /wp:paragraph -->', blocks: undefined, } as PostChanges, defaultSyncedProperties ); const yblocks = map.get( 'blocks' ); expect( yblocks ).toBeInstanceOf( Y.Array ); const blocksArray = yblocks as YBlocks; expect( blocksArray.length ).toBe( 2 ); // Both clientIds must be preserved: the unchanged first block via // the left-right diff sweep, the edited second block via the // explicit clientId-skip in the update loop. expect( blocksArray.get( 0 ).get( 'clientId' ) ).toBe( 'stable-first' ); expect( blocksArray.get( 1 ).get( 'clientId' ) ).toBe( 'stable-second' ); // The second block's content reflects the edit. const updatedContent = ( blocksArray .get( 1 ) .get( 'attributes' ) as unknown as YMapWrap< YBlockRecord > ).get( 'content' ) as Y.Text; expect( updatedContent.toString() ).toBe( 'World!' ); } ); it( 'syncs content as Y.Text', () => { const changes = { content: 'Hello, world!', } as PostChanges; applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties ); const content = map.get( 'content' ); expect( content ).toBeInstanceOf( Y.Text ); expect( content?.toString() ).toBe( 'Hello, world!' ); } ); it( 'syncs content with RenderedText format', () => { const changes = { content: { raw: '<!-- wp:paragraph --><p>Hello</p><!-- /wp:paragraph -->', rendered: '<p>Hello</p>', }, } as PostChanges; applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties ); const content = map.get( 'content' ); expect( content ).toBeInstanceOf( Y.Text ); expect( content?.toString() ).toBe( '<!-- wp:paragraph --><p>Hello</p><!-- /wp:paragraph -->' ); } ); it( 'updates existing Y.Text title in place via mergeRichTextUpdate', () => { // First apply to create the Y.Text. applyPostChangesToCRDTDoc( doc, { title: 'Old Title' } as PostChanges, defaultSyncedProperties ); const titleRef = map.get( 'title' ); // Apply again — should update in place, not replace. applyPostChangesToCRDTDoc( doc, { title: 'New Title' } as PostChanges, defaultSyncedProperties ); expect( map.get( 'title' ) ).toBe( titleRef ); expect( map.get( 'title' )?.toString() ).toBe( 'New Title' ); } ); it( 'updates existing Y.Text content in place via mergeRichTextUpdate', () => { // First apply to create the Y.Text. applyPostChangesToCRDTDoc( doc, { content: 'Old content' } as PostChanges, defaultSyncedProperties ); const contentRef = map.get( 'content' ); // Apply again — should update in place, not replace. applyPostChangesToCRDTDoc( doc, { content: 'New content' } as PostChanges, defaultSyncedProperties ); expect( map.get( 'content' ) ).toBe( contentRef ); expect( map.get( 'content' )?.toString() ).toBe( 'New content' ); } ); it( 'updates existing Y.Text excerpt in place via mergeRichTextUpdate', () => { // First apply to create the Y.Text. applyPostChangesToCRDTDoc( doc, { excerpt: 'Old excerpt' } as PostChanges, defaultSyncedProperties ); const excerptRef = map.get( 'excerpt' ); // Apply again — should update in place, not replace. applyPostChangesToCRDTDoc( doc, { excerpt: 'New excerpt' } as PostChanges, defaultSyncedProperties ); expect( map.get( 'excerpt' ) ).toBe( excerptRef ); expect( map.get( 'excerpt' )?.toString() ).toBe( 'New excerpt' ); } ); it( 'syncs meta fields', () => { const changes = { meta: { some_meta: 'new value', }, }; const metaMap = createYMap(); metaMap.set( 'some_meta', 'old value' ); map.set( 'meta', metaMap ); applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties ); expect( metaMap.get( 'some_meta' ) ).toBe( 'new value' ); } ); it( 'syncs non-single meta fields', () => { const changes = { meta: { some_meta: [ 'value', 'value 2' ], }, }; const metaMap = createYMap(); metaMap.set( 'some_meta', 'old value' ); map.set( 'meta', metaMap ); applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties ); expect( metaMap.get( 'some_meta' ) ).toStrictEqual( [ 'value', 'value 2', ] ); } ); it( 'initializes meta as Y.Map when not present', () => { const changes = { meta: { custom_field: 'value', }, }; applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties ); const metaMap = map.get( 'meta' ); expect( metaMap ).toBeInstanceOf( Y.Map ); expect( metaMap?.get( 'custom_field' ) ).toBe( 'value' ); } ); it( 'skips function-valued content in changes', () => { const changes = { content: ( { blocks: blocksForSerialization = [], }: { blocks: Block[]; } ) => blocksForSerialization .map( ( b ) => b.attributes.content ) .join( '' ), } as unknown as PostChanges; applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties ); expect( map.has( 'content' ) ).toBe( false ); } ); it( 'syncs taxonomy rest_base values included in syncedProperties', () => { const changes = { categories: [ 1, 2, 3 ], genre: [ 10, 20 ], // should be ignored tags: [ 4, 5 ], } as unknown as PostChanges; applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties ); expect( map.get( 'categories' ) ).toEqual( [ 1, 2, 3 ] ); expect( map.get( 'genre' ) ).toBeUndefined(); expect( map.get( 'tags' ) ).toEqual( [ 4, 5 ] ); const customSyncedProperties = new Set( [ ...defaultSyncedProperties, 'genre', // now included ] ); applyPostChangesToCRDTDoc( doc, changes, customSyncedProperties ); expect( map.get( 'categories' ) ).toEqual( [ 1, 2, 3 ] ); expect( map.get( 'genre' ) ).toEqual( [ 10, 20 ] ); expect( map.get( 'tags' ) ).toEqual( [ 4, 5 ] ); } ); } ); describe( 'getPostChangesFromCRDTDoc', () => { let map: YMapWrap< YPostRecord >; beforeEach( () => { map = getRootMap< YPostRecord >( doc, CRDT_RECORD_MAP_KEY ); map.set( 'title', new Y.Text( 'CRDT Title' ) ); map.set( 'status', 'draft' ); map.set( 'date', '2025-01-01' ); } ); it( 'returns changes when values differ from record', () => { const editedRecord = { title: 'Old Title', status: 'draft', } as unknown as Post; const changes = getPostChangesFromCRDTDoc( doc, editedRecord, defaultSyncedProperties ); expect( changes.title ).toBe( 'CRDT Title' ); } ); it( 'filters out disallowed properties', () => { map.set( 'title', new Y.Text( 'Test title' ) ); map.set( 'unsyncedProp', 'value' ); const editedRecord = {} as Post; const changes = getPostChangesFromCRDTDoc( doc, editedRecord, defaultSyncedProperties ); expect( changes ).not.toHaveProperty( 'unsyncedProp' ); expect( changes.title ).toBe( 'Test title' ); } ); it( 'does not sync auto-draft status', () => { map.set( 'status', 'auto-draft' ); const editedRecord = { status: 'draft', } as unknown as Post; const changes = getPostChangesFromCRDTDoc( doc, editedRecord, defaultSyncedProperties ); expect( changes ).not.toHaveProperty( 'status' ); } ); it( 'does not overwrite null floating date', () => { map.set( 'status', 'draft' ); map.set( 'date', '' ); const editedRecord = { status: 'draft', date: null, modified: '2025-01-01', } as unknown as Post; const changesWithEmptyDate = getPostChangesFromCRDTDoc( doc, editedRecord, defaultSyncedProperties ); expect( changesWithEmptyDate ).not.toHaveProperty( 'date' ); map.set( 'date', '2025-01-02' ); const changesWithDefinedDate = getPostChangesFromCRDTDoc( doc, editedRecord, defaultSyncedProperties ); expect( changesWithDefinedDate ).not.toHaveProperty( 'date' ); } ); it( 'does not overwrite defined floating date', () => { map.set( 'status', 'draft' ); map.set( 'date', '' ); const editedRecord = { status: 'draft', date: '2025-01-01', // matches modified modified: '2025-01-01', } as unknown as Post; const changesWithEmptyDate = getPostChangesFromCRDTDoc( doc, editedRecord, defaultSyncedProperties ); expect( changesWithEmptyDate ).not.toHaveProperty( 'date' ); map.set( 'date', '2025-01-02' ); const changesWithDefinedDate = getPostChangesFromCRDTDoc( doc, editedRecord, defaultSyncedProperties ); expect( changesWithDefinedDate ).not.toHaveProperty( 'date' ); } ); it( 'includes blocks in changes', () => { map.set( 'blocks', new Y.Array< YBlock >() ); const editedRecord = { blocks: [], } as unknown as Post; const changes = getPostChangesFromCRDTDoc( doc, editedRecord, defaultSyncedProperties ); expect( changes ).toHaveProperty( 'blocks' ); } ); it( 'returns rich-text block attributes as RichTextData, not strings', () => { // Simulate User A writing a paragraph block into the CRDT doc. addBlockToDoc( map, 'block-1', 'Hello world' ); // Simulate User B reading the CRDT doc with no local blocks. const editedRecord = { blocks: [] } as unknown as Post; const changes = getPostChangesFromCRDTDoc( doc, editedRecord, defaultSyncedProperties ); const block = ( changes.blocks as any[] )?.[ 0 ]; expect( block ).toBeDefined(); expect( block.attributes.content ).toBeInstanceOf( RichTextData ); expect( block.attributes.content.text ).toBe( 'Hello world' ); } ); it( 'returns nested rich-text in array attributes as RichTextData', () => { // Add a table block to the CRDT doc with nested cell content // stored as plain strings. let blocks = map.get( 'blocks' ); if ( ! ( blocks instanceof Y.Array ) ) { blocks = new Y.Array< YBlock >(); map.set( 'blocks', blocks ); } const tableBlock = createYMap< YBlockRecord >(); tableBlock.set( 'name', 'core/table' ); tableBlock.set( 'clientId', 'table-1' ); const attrs = new Y.Map(); attrs.set( 'body', [ { cells: [ { content: '<strong>Cell</strong>', tag: 'td' }, { content: 'Plain', tag: 'td' }, ], }, ] ); tableBlock.set( 'attributes', attrs ); tableBlock.set( 'innerBlocks', new Y.Array() ); ( blocks as YBlocks ).push( [ tableBlock ] ); const editedRecord = { blocks: [] } as unknown as Post; const changes = getPostChangesFromCRDTDoc( doc, editedRecord, defaultSyncedProperties ); const block = ( changes.blocks as any[] )?.[ 0 ]; expect( block ).toBeDefined(); const cell = block.attributes.body[ 0 ].cells[ 0 ]; expect( cell.content ).toBeInstanceOf( RichTextData ); expect( ( cell.content as RichTextData ).toHTMLString() ).toBe( '<strong>Cell</strong>' ); } ); it( 'includes undefined blocks in changes', () => { map.set( 'blocks', undefined ); const editedRecord = { blocks: [ { name: 'core/paragraph', attributes: { content: 'Test' }, innerBlocks: [], }, ], } as unknown as Post; const changes = getPostChangesFromCRDTDoc( doc, editedRecord, defaultSyncedProperties ); expect( changes ).toHaveProperty( 'blocks' ); expect( changes.blocks ).toBeUndefined(); } ); it( 'detects content changes from string value', () => { map.set( 'content', new Y.Text( 'New content' ) ); const editedRecord = { content: 'Old content', } as unknown as Post; const changes = getPostChangesFromCRDTDoc( doc, editedRecord, defaultSyncedProperties ); expect( changes.content ).toBe( 'New content' ); } ); it( 'detects content changes from RenderedText value', () => { map.set( 'content', new Y.Text( 'New content' ) ); const editedRecord = { content: { raw: 'Old content', rendered: 'Old content' }, } as unknown as Post; const changes = getPostChangesFromCRDTDoc( doc, editedRecord, defaultSyncedProperties ); expect( changes.content ).toBe( 'New content' ); } ); it( 'excludes content when unchanged from RenderedText value', () => { map.set( 'content', new Y.Text( 'Same content' ) ); const editedRecord = { content: { raw: 'Same content', rendered: 'Same content' }, } as unknown as Post; const changes = getPostChangesFromCRDTDoc( doc, editedRecord, defaultSyncedProperties ); expect( changes ).not.toHaveProperty( 'content' ); } ); it( 'includes meta in changes', () => { const metaMap = createYMap(); metaMap.set( 'public_meta', 'new value' ); map.set( 'meta', metaMap ); const editedRecord = { meta: { public_meta: 'old value', }, } as unknown as Post; const changes = getPostChangesFromCRDTDoc( doc, editedRecord, defaultSyncedProperties ); expect( changes.meta ).toEqual( { public_meta: 'new value', // from CRDT } ); } ); it( 'includes non-single meta in changes', () => { const metaMap = createYMap(); metaMap.set( 'public_meta', [ 'value', 'value 2' ] ); map.set( 'meta', metaMap ); const editedRecord = { meta: { public_meta: 'value', }, } as unknown as Post; const changes = getPostChangesFromCRDTDoc( doc, editedRecord, defaultSyncedProperties ); expect( changes.meta ).toEqual( { public_meta: [ 'value', 'value 2' ], // from CRDT } ); } ); it( 'excludes orphaned meta keys not present on the edited record', () => { // If post meta is registered, saved (landing in a CRDT doc), // then unregistered, it can permanently mark the record dirty. // Orphaned values should not show up as a change. const metaMap = createYMap(); metaMap.set( 'registered_meta', 'value' ); metaMap.set( 'orphaned_meta', 'stale value' ); map.set( 'meta', metaMap ); const editedRecord = { meta: { registered_meta: 'value', }, } as unknown as Post; const changes = getPostChangesFromCRDTDoc( doc, editedRecord, defaultSyncedProperties ); expect( changes ).not.toHaveProperty( 'meta' ); } ); it( 'excludes disallowed meta keys in changes', () => { const metaMap = createYMap(); metaMap.set( 'public_meta', 'new value' ); metaMap.set( POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE, 'exclude me' ); map.set( 'meta', metaMap ); const editedRecord = { meta: { public_meta: 'old value', }, } as unknown as Post; const changes = getPostChangesFromCRDTDoc( doc, editedRecord, defaultSyncedProperties ); expect( changes.meta ).toEqual( { public_meta: 'new value', // from CRDT } ); expect( changes.meta ).not.toHaveProperty( POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ); } ); it( 'returns taxonomy rest_base changes when in syncedProperties', () => { map.set( 'categories', [ 1, 2 ] ); map.set( 'genre', [ 10, 20 ] ); map.set( 'tags', [ 3 ] ); const editedRecord = { categories: [ 1 ], genre: [ 10 ], // should be ignored tags: [], } as unknown as Post; const changes = getPostChangesFromCRDTDoc( doc, editedRecord, defaultSyncedProperties ) as Record< string, unknown >; expect( changes.categories ).toEqual( [ 1, 2 ] ); expect( changes.genre ).toBeUndefined(); expect( changes.tags ).toEqual( [ 3 ] ); const customSyncedProperties = new Set( [ ...defaultSyncedProperties, 'genre', // now included ] ); const changes2 = getPostChangesFromCRDTDoc( doc, editedRecord, customSyncedProperties ) as Record< string, unknown >; expect( changes2.categories ).toEqual( [ 1, 2 ] ); expect( changes2.genre ).toEqual( [ 10, 20 ] ); expect( changes2.tags ).toEqual( [ 3 ] ); } ); describe( 'selection recalculation', () => { it( 'includes recalculated selection when text is inserted before cursor', () => { const ytext = addBlockToDoc( map, 'block-1', 'Hello world' ); // Record a selection at offset 5 (cursor between "Hello" and " world"). updateSelectionHistory( doc, { selectionStart: { clientId: 'block-1', attributeKey: 'content', offset: 5, }, selectionEnd: { clientId: 'block-1', attributeKey: 'content', offset: 5, }, } ); // Simulate remote insertion: insert "XXX" at position 0. ytext.insert( 0, 'XXX' ); const editedRecord = { title: 'CRDT Title', status: 'draft', blocks: [], } as unknown as Post; const changes = getPostChangesFromCRDTDoc( doc, editedRecord, defaultSyncedProperties ); expect( changes.selection ).toBeDefined(); expect( changes.selection?.selectionStart.offset ).toBe( 8 ); // 5 + 3 expect( changes.selection?.selectionStart.clientId ).toBe( 'block-1' ); expect( changes.selection?.selectionStart.attributeKey ).toBe( 'content' ); expect( changes.selection?.selectionEnd.offset ).toBe( 8 ); } ); it( 'includes recalculated selection when text is deleted before cursor', () => { const ytext = addBlockToDoc( map, 'block-1', 'Hello world' ); // Record a selection at offset 8 (cursor between "Hello wo" and "rld"). updateSelectionHistory( doc, { selectionStart: { clientId: 'block-1', attributeKey: 'content', offset: 8, }, selectionEnd: { clientId: 'block-1', attributeKey: 'content', offset: 8, }, } ); // Simulate remote deletion: delete "Hello" (5 chars at position 0). ytext.delete( 0, 5 ); const editedRecord = { title: 'CRDT Title', status: 'draft', blocks: [], } as unknown as Post; const changes = getPostChangesFromCRDTDoc( doc, editedRecord, defaultSyncedProperties ); expect( changes.selection ).toBeDefined(); expect( changes.selection?.selectionStart.offset ).toBe( 3 ); // 8 - 5 } ); it( 'does not include selection when selection history is empty', () => { addBlockToDoc( map, 'block-1', 'Hello world' ); const editedRecord = { title: 'CRDT Title', status: 'draft', blocks: [], } as unknown as Post; const changes = getPostChangesFromCRDTDoc( doc, editedRecord, defaultSyncedProperties ); expect( changes.selection ).toBeUndefined(); } ); } ); it( 'injects a closure-based content function when blocks changed but content did not', () => { addBlockToDoc( map, 'block-1', 'Hello world' ); const editedRecord = { title: 'CRDT Title', status: 'draft', content: { raw: 'Same content', rendered: 'Same content' }, blocks: [], } as unknown as Post; const changes = getPostChangesFromCRDTDoc( doc, editedRecord, defaultSyncedProperties ); // Blocks changed, content didn't, so a lazy content function is injected. expect( changes.blocks ).toBeDefined(); expect( typeof changes.content ).toBe( 'function' ); } ); it( 'injected content function captures the synced blocks and ignores its caller-supplied argument', () => { addBlockToDoc( map, 'block-1', 'Hello world' ); const editedRecord = { title: 'CRDT Title', status: 'draft', content: { raw: 'Same content', rendered: 'Same content' }, blocks: [], } as unknown as Post; const changes = getPostChangesFromCRDTDoc( doc, editedRecord, defaultSyncedProperties ); // The injected function takes no parameters and serializes the // captured (synced) blocks. This is what makes getEditedPostContent // keep working after the Code Editor clears `record.blocks` to force // a re-parse: the closure already has the right blocks on hand. // // The mocked __unstableSerializeAndClean returns "serialized:<n>" // where n is the length of the blocks it was called with. The // captured blocks have one entry, so both calls below should yield // "serialized:1" (proving the closure ignores its argument and // uses the captured blocks instead). const contentFn = changes.content as ( args?: { blocks: Block[]; } ) => string; expect( contentFn() ).toBe( 'serialized:1' ); expect( contentFn( { blocks: [] } ) ).toBe( 'serialized:1' ); } ); it( 'does not inject a content function when content also changed in the doc', () => { addBlockToDoc( map, 'block-1', 'Hello world' ); map.set( 'content', new Y.Text( 'New content' ) ); const editedRecord = { title: 'CRDT Title', status: 'draft', content: { raw: 'Old content', rendered: 'Old content' }, blocks: [], } as unknown as Post; const changes = getPostChangesFromCRDTDoc( doc, editedRecord, defaultSyncedProperties ); // Content changed directly, so it should be a string, not a function. expect( changes.blocks ).toBeDefined(); expect( typeof changes.content ).toBe( 'string' ); expect( changes.content ).toBe( 'New content' ); } ); it( 'does not inject a content function when blocks did not change', () => { map.set( 'content', new Y.Text( 'Same content' ) ); const editedRecord = { title: 'CRDT Title', status: 'draft', content: { raw: 'Same content', rendered: 'Same content' }, } as unknown as Post; const changes = getPostChangesFromCRDTDoc( doc, editedRecord, defaultSyncedProperties ); expect( changes.blocks ).toBeUndefined(); expect( changes.content ).toBeUndefined(); } ); } ); } ); /** * Helper to create a block with a Y.Text content attribute * in the CRDT document. * * @param map * @param clientId Block client ID. * @param content Initial text content. * @param name Block name (default: 'core/paragraph'). */ function addBlockToDoc( map: YMapWrap< YPostRecord >, clientId: string, content: string, name = 'core/paragraph' ): Y.Text { let blocks = map.get( 'blocks' ); if ( ! ( blocks instanceof Y.Array ) ) { blocks = new Y.Array< YBlock >(); map.set( 'blocks', blocks ); } const block = createYMap< YBlockRecord >(); block.set( 'name', name ); block.set( 'clientId', clientId ); const attrs = new Y.Map(); const ytext = new Y.Text( content ); attrs.set( 'content', ytext ); block.set( 'attributes', attrs ); block.set( 'innerBlocks', new Y.Array() ); ( blocks as YBlocks ).push( [ block ] ); return ytext; }