/
githubmirror
/
gutenberg
Обзор
Документация
Войти
/
githubmirror
/
gutenberg
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
trunk
packages/editor/src/components/collab-sidebar/test/utils.js
1 055 строк
30 KB
Marco Ciampini
ESLint: Replace strict config with bulk suppressions (#81248)
07 авг 2026, 14:34
Не верифицирован
07 авг 2026, 14:34
7f43eaf
Код
Авторство
О чём код?
import { RichTextData, create, applyFormat, registerFormatType, unregisterFormatType, store as richTextStore, } from '@wordpress/rich-text'; import { select } from '@wordpress/data'; import { findNoteRange, findNoteInBlock, applyNoteFormat, removeNoteFormat, getNoteIdsFromMetadata, addNoteIdToMetadata, removeNoteIdFromMetadata, calculateNotePositions, pickPrimaryNote, BLOCK_LEVEL_NOTE_START, getInlineMarkerStart, getNoteMarkerSelector, } from '../utils'; import { noteFormat } from '../format'; function makeRect( top ) { return { top }; } describe( 'getNoteIdsFromMetadata', () => { it( 'returns empty array for null metadata', () => { expect( getNoteIdsFromMetadata( null ) ).toEqual( [] ); } ); it( 'returns empty array for undefined metadata', () => { expect( getNoteIdsFromMetadata( undefined ) ).toEqual( [] ); } ); it( 'returns empty array for metadata without noteId', () => { expect( getNoteIdsFromMetadata( {} ) ).toEqual( [] ); expect( getNoteIdsFromMetadata( { name: 'test' } ) ).toEqual( [] ); } ); it( 'returns empty array for noteId of 0', () => { expect( getNoteIdsFromMetadata( { noteId: 0 } ) ).toEqual( [] ); } ); it( 'returns empty array for noteId of empty string', () => { expect( getNoteIdsFromMetadata( { noteId: '' } ) ).toEqual( [] ); } ); it( 'returns empty array for noteId of false', () => { expect( getNoteIdsFromMetadata( { noteId: false } ) ).toEqual( [] ); } ); it( 'returns array from scalar noteId (legacy format)', () => { expect( getNoteIdsFromMetadata( { noteId: 42 } ) ).toEqual( [ 42 ] ); } ); it( 'coerces a string-typed legacy noteId to a number', () => { expect( getNoteIdsFromMetadata( { noteId: '42' } ) ).toEqual( [ 42 ] ); } ); it( 'drops non-numeric and non-positive ids', () => { expect( getNoteIdsFromMetadata( { noteId: [ 1, 'abc', -3, 2 ] } ) ).toEqual( [ 1, 2 ] ); } ); it( 'returns array from array noteId', () => { expect( getNoteIdsFromMetadata( { noteId: [ 1, 2, 3 ] } ) ).toEqual( [ 1, 2, 3, ] ); } ); it( 'filters out falsy values from array', () => { expect( getNoteIdsFromMetadata( { noteId: [ 1, null, 2, undefined, 3 ] } ) ).toEqual( [ 1, 2, 3 ] ); } ); it( 'filters out zero and empty string from array', () => { expect( getNoteIdsFromMetadata( { noteId: [ 0, '', 1, false, 2 ] } ) ).toEqual( [ 1, 2 ] ); } ); it( 'returns empty array when all array values are falsy', () => { expect( getNoteIdsFromMetadata( { noteId: [ null, undefined, 0, '' ] } ) ).toEqual( [] ); } ); it( 'deduplicates repeated ids while preserving first occurrence order', () => { expect( getNoteIdsFromMetadata( { noteId: [ 1, 1, 1 ] } ) ).toEqual( [ 1, ] ); expect( getNoteIdsFromMetadata( { noteId: [ 1, 2, 1, 3, 2 ] } ) ).toEqual( [ 1, 2, 3 ] ); } ); it( 'deduplicates across numeric and string-typed duplicates', () => { expect( getNoteIdsFromMetadata( { noteId: [ 1, '1', 2, '2' ] } ) ).toEqual( [ 1, 2 ] ); } ); } ); describe( 'addNoteIdToMetadata', () => { it( 'creates array for first note on empty metadata', () => { const result = addNoteIdToMetadata( {}, 42 ); expect( result.noteId ).toEqual( [ 42 ] ); } ); it( 'creates array for first note on null metadata', () => { const result = addNoteIdToMetadata( null, 42 ); expect( result.noteId ).toEqual( [ 42 ] ); } ); it( 'creates array for first note on undefined metadata', () => { const result = addNoteIdToMetadata( undefined, 42 ); expect( result.noteId ).toEqual( [ 42 ] ); } ); it( 'converts scalar noteId to array and appends new id', () => { const result = addNoteIdToMetadata( { noteId: 1 }, 2 ); expect( result.noteId ).toEqual( [ 1, 2 ] ); } ); it( 'appends to existing array', () => { const result = addNoteIdToMetadata( { noteId: [ 1, 2 ] }, 3 ); expect( result.noteId ).toEqual( [ 1, 2, 3 ] ); } ); it( 'prevents duplicates', () => { const result = addNoteIdToMetadata( { noteId: [ 1, 2 ] }, 1 ); expect( result ).toEqual( { noteId: [ 1, 2 ] } ); } ); it( 'preserves other metadata properties', () => { const result = addNoteIdToMetadata( { noteId: 1, name: 'test' }, 2 ); expect( result ).toEqual( { noteId: [ 1, 2 ], name: 'test' } ); } ); it( 'returns original metadata object when duplicate is added', () => { const metadata = { noteId: [ 1, 2 ] }; const result = addNoteIdToMetadata( metadata, 1 ); expect( result ).toBe( metadata ); } ); it( 'handles adding to metadata with other properties but no noteId', () => { const result = addNoteIdToMetadata( { name: 'test' }, 5 ); expect( result ).toEqual( { name: 'test', noteId: [ 5 ] } ); } ); it( 'dedupes a numeric id against a string-typed legacy id', () => { const metadata = { noteId: '5' }; const result = addNoteIdToMetadata( metadata, 5 ); expect( result ).toBe( metadata ); } ); it( 'dedupes a string id against a numeric id already in the array', () => { const result = addNoteIdToMetadata( { noteId: [ 1, 2 ] }, '2' ); expect( result ).toEqual( { noteId: [ 1, 2 ] } ); } ); } ); describe( 'removeNoteIdFromMetadata', () => { it( 'removes noteId from array', () => { const result = removeNoteIdFromMetadata( { noteId: [ 1, 2, 3 ] }, 2 ); expect( result.noteId ).toEqual( [ 1, 3 ] ); } ); it( 'returns undefined noteId when array becomes empty', () => { const result = removeNoteIdFromMetadata( { noteId: [ 1 ] }, 1 ); expect( result.noteId ).toBeUndefined(); } ); it( 'handles removing from scalar noteId (legacy format)', () => { const result = removeNoteIdFromMetadata( { noteId: 42 }, 42 ); expect( result.noteId ).toBeUndefined(); } ); it( 'handles removing non-existent id', () => { const result = removeNoteIdFromMetadata( { noteId: [ 1, 2 ] }, 99 ); expect( result.noteId ).toEqual( [ 1, 2 ] ); } ); it( 'handles empty metadata', () => { const result = removeNoteIdFromMetadata( {}, 1 ); expect( result.noteId ).toBeUndefined(); } ); it( 'preserves other metadata properties', () => { const result = removeNoteIdFromMetadata( { noteId: [ 1, 2 ], name: 'test' }, 1 ); expect( result ).toEqual( { noteId: [ 2 ], name: 'test' } ); } ); it( 'handles null metadata', () => { const result = removeNoteIdFromMetadata( null, 1 ); expect( result.noteId ).toBeUndefined(); } ); it( 'handles undefined metadata', () => { const result = removeNoteIdFromMetadata( undefined, 1 ); expect( result.noteId ).toBeUndefined(); } ); it( 'removes last note and cleans up noteId to undefined', () => { const result = removeNoteIdFromMetadata( { noteId: [ 42 ], name: 'test' }, 42 ); expect( result ).toEqual( { name: 'test', noteId: undefined } ); } ); it( 'removes a numeric id when stored as a string-typed legacy scalar', () => { const result = removeNoteIdFromMetadata( { noteId: '42' }, 42 ); expect( result.noteId ).toBeUndefined(); } ); it( 'removes a string id when stored as a number in the array', () => { const result = removeNoteIdFromMetadata( { noteId: [ 1, 2, 3 ] }, '2' ); expect( result.noteId ).toEqual( [ 1, 3 ] ); } ); } ); describe( 'note id order preservation', () => { // The collab sidebar relies on insertion order: the first id in the // metadata array is treated as the first (block-aligned) note, with // subsequent notes stacking below. These tests pin that contract. // See https://github.com/WordPress/gutenberg/issues/75145#issuecomment-4361104794 it( 'preserves insertion order across multiple sequential adds', () => { let metadata = {}; metadata = addNoteIdToMetadata( metadata, 5 ); metadata = addNoteIdToMetadata( metadata, 3 ); metadata = addNoteIdToMetadata( metadata, 7 ); metadata = addNoteIdToMetadata( metadata, 1 ); expect( metadata.noteId ).toEqual( [ 5, 3, 7, 1 ] ); } ); it( 'does not sort or reorder ids when adding', () => { // A naive implementation might sort numerically; this confirms it // preserves the order the user added notes in. const result = addNoteIdToMetadata( { noteId: [ 10, 2, 30 ] }, 4 ); expect( result.noteId ).toEqual( [ 10, 2, 30, 4 ] ); } ); it( 'keeps the first id first after appending more notes', () => { let metadata = addNoteIdToMetadata( {}, 42 ); metadata = addNoteIdToMetadata( metadata, 99 ); metadata = addNoteIdToMetadata( metadata, 7 ); const ids = getNoteIdsFromMetadata( metadata ); expect( ids[ 0 ] ).toBe( 42 ); } ); it( 'preserves order of remaining ids after removing one from the middle', () => { const result = removeNoteIdFromMetadata( { noteId: [ 1, 2, 3, 4, 5 ] }, 3 ); expect( result.noteId ).toEqual( [ 1, 2, 4, 5 ] ); } ); it( 'preserves remaining ids in order after removing the first id', () => { const result = removeNoteIdFromMetadata( { noteId: [ 1, 2, 3, 4 ] }, 1 ); expect( result.noteId ).toEqual( [ 2, 3, 4 ] ); } ); it( 'preserves remaining ids in order after removing the last id', () => { const result = removeNoteIdFromMetadata( { noteId: [ 1, 2, 3, 4 ] }, 4 ); expect( result.noteId ).toEqual( [ 1, 2, 3 ] ); } ); it( 'preserves order across an interleaved sequence of adds and removes', () => { let metadata = {}; metadata = addNoteIdToMetadata( metadata, 10 ); metadata = addNoteIdToMetadata( metadata, 20 ); metadata = addNoteIdToMetadata( metadata, 30 ); metadata = removeNoteIdFromMetadata( metadata, 20 ); metadata = addNoteIdToMetadata( metadata, 40 ); expect( metadata.noteId ).toEqual( [ 10, 30, 40 ] ); } ); it( 'preserves array order through a getNoteIdsFromMetadata round-trip', () => { const ids = [ 9, 4, 7, 2, 11 ]; expect( getNoteIdsFromMetadata( { noteId: ids } ) ).toEqual( ids ); } ); it( 'keeps the legacy scalar id as the first id when migrating to an array', () => { // When a legacy single-note post gains a second note, the original // note must remain the block-aligned (first) note. const result = addNoteIdToMetadata( { noteId: 42 }, 99 ); expect( result.noteId ).toEqual( [ 42, 99 ] ); expect( result.noteId[ 0 ] ).toBe( 42 ); } ); } ); describe( 'pickPrimaryNote', () => { it( 'returns null for an empty list', () => { expect( pickPrimaryNote( [] ) ).toBeNull(); } ); it( 'returns the first unresolved thread when one exists', () => { const threads = [ { id: 1, status: 'approved' }, { id: 2, status: 'hold' }, { id: 3, status: 'hold' }, ]; expect( pickPrimaryNote( threads ) ).toBe( threads[ 1 ] ); } ); it( 'falls back to the first thread when none are unresolved', () => { const threads = [ { id: 1, status: 'approved' }, { id: 2, status: 'approved' }, ]; expect( pickPrimaryNote( threads ) ).toBe( threads[ 0 ] ); } ); } ); describe( 'calculateNotePositions', () => { it( 'returns empty positions when the anchor thread has no blockRect', () => { const { positions } = calculateNotePositions( { threads: [ { id: 1 } ], selectedNoteId: undefined, blockRects: {}, heights: {}, scrollTop: 0, } ); expect( positions ).toEqual( {} ); } ); it( 'falls back to the first thread as anchor when none is selected', () => { const threads = [ { id: 1 }, { id: 2 }, { id: 3 } ]; const blockRects = { 1: makeRect( 100 ), 2: makeRect( 300 ), 3: makeRect( 500 ), }; const heights = { 1: 50, 2: 50, 3: 50 }; const { positions } = calculateNotePositions( { threads, selectedNoteId: undefined, blockRects, heights, scrollTop: 0, } ); // 1: 100 - 16 = 84 // 2: 300 - 16 = 284 // 3: 500 - 16 = 484 expect( positions ).toEqual( { 1: 84, 2: 284, 3: 484 } ); } ); it( 'pushes an overlapping thread above the anchor upward', () => { const threads = [ { id: 1 }, { id: 2 } ]; const blockRects = { 1: makeRect( 150 ), 2: makeRect( 180 ), }; const heights = { 1: 60, 2: 50 }; const { positions } = calculateNotePositions( { threads, selectedNoteId: 2, blockRects, heights, scrollTop: 0, } ); // 2 (anchor): 180 - 16 = 164 // 1 (upward): 164 - 60 - 20 = 84 expect( positions ).toEqual( { 1: 84, 2: 164 } ); } ); it( 'cascades downward offsets through consecutive overlapping threads', () => { const threads = [ { id: 1 }, { id: 2 }, { id: 3 } ]; const blockRects = { 1: makeRect( 100 ), 2: makeRect( 110 ), 3: makeRect( 120 ), }; const heights = { 1: 80, 2: 80, 3: 80 }; const { positions } = calculateNotePositions( { threads, selectedNoteId: 1, blockRects, heights, scrollTop: 0, } ); // 1 (anchor): 100 - 16 = 84 // 2 (downward): 84 + 80 + 20 = 184 // 3 (downward): 184 + 80 + 20 = 284 expect( positions ).toEqual( { 1: 84, 2: 184, 3: 284 } ); } ); it( 'omits threads that have no blockRect', () => { const threads = [ { id: 1 }, { id: 2 }, { id: 3 } ]; const blockRects = { 1: makeRect( 100 ), 3: makeRect( 500 ), }; const heights = { 1: 50, 3: 50 }; const { positions } = calculateNotePositions( { threads, selectedNoteId: 1, blockRects, heights, scrollTop: 0, } ); // 1: 100 - 16 = 84 // 3: 500 - 16 = 484 expect( positions ).toEqual( { 1: 84, 3: 484 } ); } ); it( 'allows upward cascade to produce negative positions', () => { const threads = [ { id: 1 }, { id: 2 }, { id: 3 }, { id: 4 } ]; const blockRects = { 1: makeRect( 150 ), 2: makeRect( 200 ), 3: makeRect( 250 ), 4: makeRect( 300 ), }; const heights = { 1: 90, 2: 90, 3: 90, 4: 230 }; const { positions } = calculateNotePositions( { threads, selectedNoteId: 4, blockRects, heights, scrollTop: 0, } ); // 4 (anchor): 300 - 16 = 284 // 3 (upward): 284 - 90 - 20 = 174 // 2 (upward): 174 - 90 - 20 = 64 // 1 (upward): 64 - 90 - 20 = -46 expect( positions ).toEqual( { 1: -46, 2: 64, 3: 174, 4: 284 } ); } ); it( 'adds scrollTop to the final positions', () => { const threads = [ { id: 1 }, { id: 2 } ]; const blockRects = { 1: makeRect( 100 ), 2: makeRect( 300 ), }; const heights = { 1: 50, 2: 50 }; const { positions } = calculateNotePositions( { threads, selectedNoteId: 1, blockRects, heights, scrollTop: 500, } ); // 1: 100 + 500 - 16 = 584 // 2: 300 + 500 - 16 = 784 expect( positions ).toEqual( { 1: 584, 2: 784 } ); } ); it( 'stacks two threads that share a block with the first as anchor', () => { const threads = [ { id: 1 }, { id: 2 } ]; const blockRects = { 1: makeRect( 200 ), 2: makeRect( 200 ), }; const heights = { 1: 50, 2: 50 }; const { positions } = calculateNotePositions( { threads, selectedNoteId: 1, blockRects, heights, scrollTop: 0, } ); // 1 (anchor): 200 - 16 = 184 // 2 (downward): (184 + 50) - 200 + 20 = 54 → 200 + 54 = 254 expect( positions ).toEqual( { 1: 184, 2: 254 } ); } ); it( 'stacks two threads that share a block with the second as anchor', () => { const threads = [ { id: 1 }, { id: 2 } ]; const blockRects = { 1: makeRect( 200 ), 2: makeRect( 200 ), }; const heights = { 1: 50, 2: 50 }; const { positions } = calculateNotePositions( { threads, selectedNoteId: 2, blockRects, heights, scrollTop: 0, } ); // 2 (anchor): 200 - 16 = 184 // 1 (upward): 184 - 200 - 50 - 20 = -86 → 200 + (-86) = 114 expect( positions ).toEqual( { 1: 114, 2: 184 } ); } ); it( 'stacks three threads sharing a block with middle as anchor', () => { const threads = [ { id: 1 }, { id: 2 }, { id: 3 } ]; const blockRects = { 1: makeRect( 200 ), 2: makeRect( 200 ), 3: makeRect( 200 ), }; const heights = { 1: 50, 2: 50, 3: 50 }; const { positions } = calculateNotePositions( { threads, selectedNoteId: 2, blockRects, heights, scrollTop: 0, } ); // 2 (anchor): 200 - 16 = 184 // 3 (downward): (184 + 50) - 200 + 20 = 54 → 254 // 1 (upward): 184 - 200 - 50 - 20 = -86 → 114 expect( positions ).toEqual( { 1: 114, 2: 184, 3: 254 } ); } ); it( 'never displaces a thread above its own anchor when the pending note sorts out of order', () => { /* * The pending "new" note is spliced in after its block's existing * threads, but it anchors to the live selection, which can sit above * a marker that precedes it in the list. Ordering by measured top * keeps the sweep's assumption true; without it note 1 is pushed to * 164 - 136px above the marker it points at. */ const threads = [ { id: 1 }, { id: 'new' } ]; const blockRects = { 1: makeRect( 300 ), new: makeRect( 250 ), }; const heights = { 1: 50, new: 80 }; const { positions } = calculateNotePositions( { threads, selectedNoteId: 'new', blockRects, heights, scrollTop: 0, } ); // new (anchor): 250 - 16 = 234 // 1 (downward): (234 + 80) - 300 + 20 = 34 → 334 expect( positions ).toEqual( { 1: 334, new: 234 } ); expect( positions[ 1 ] ).toBeGreaterThan( blockRects[ 1 ].top - 50 ); } ); } ); describe( 'findNoteRange', () => { const FORMAT_NAME = 'core/note'; const isRegistered = () => !! select( richTextStore ).getFormatType( FORMAT_NAME ); beforeAll( () => { if ( ! isRegistered() ) { registerFormatType( FORMAT_NAME, { title: 'Note', tagName: 'span', className: 'wp-note', attributes: { 'data-id': 'data-id' }, edit: () => null, } ); } } ); afterAll( () => { if ( isRegistered() ) { unregisterFormatType( FORMAT_NAME ); } } ); it( 'returns null for null/undefined input', () => { expect( findNoteRange( null, 7 ) ).toBeNull(); expect( findNoteRange( undefined, 7 ) ).toBeNull(); } ); it( 'returns null when no marker is present', () => { const value = RichTextData.fromHTMLString( 'hello world' ); expect( findNoteRange( value, 7 ) ).toBeNull(); } ); it( 'returns range for a marker matching the note id (RichTextData)', () => { const value = RichTextData.fromHTMLString( 'hello <span class="wp-note" data-id="7">marked</span> world' ); expect( findNoteRange( value, 7 ) ).toEqual( { start: 6, end: 12, } ); } ); it( 'returns range for a marker matching the note id (string)', () => { const html = 'hello <span class="wp-note" data-id="7">marked</span> world'; expect( findNoteRange( html, 7 ) ).toEqual( { start: 6, end: 12 } ); } ); it( 'returns null when the marker id does not match', () => { const value = RichTextData.fromHTMLString( '<span class="wp-note" data-id="3">x</span>' ); expect( findNoteRange( value, 7 ) ).toBeNull(); } ); it( 'coerces ids to strings so numeric vs string ids match', () => { const value = RichTextData.fromHTMLString( '<span class="wp-note" data-id="7">x</span>' ); expect( findNoteRange( value, '7' ) ).toEqual( { start: 0, end: 1 } ); } ); it( 'returns null when noteId itself is null/undefined', () => { const value = RichTextData.fromHTMLString( '<span class="wp-note" data-id="7">x</span>' ); expect( findNoteRange( value, null ) ).toBeNull(); expect( findNoteRange( value, undefined ) ).toBeNull(); } ); } ); describe( 'findNoteInBlock', () => { const FORMAT_NAME = 'core/note'; const isRegistered = () => !! select( richTextStore ).getFormatType( FORMAT_NAME ); beforeAll( () => { if ( ! isRegistered() ) { registerFormatType( FORMAT_NAME, { title: 'Note', tagName: 'span', className: 'wp-note', attributes: { 'data-id': 'data-id' }, edit: () => null, } ); } } ); afterAll( () => { if ( isRegistered() ) { unregisterFormatType( FORMAT_NAME ); } } ); it( 'returns null when attributes are null/undefined', () => { expect( findNoteInBlock( null, 7 ) ).toBeNull(); expect( findNoteInBlock( undefined, 7 ) ).toBeNull(); } ); it( 'returns null when no attribute carries a matching marker', () => { const attributes = { content: RichTextData.fromHTMLString( 'hello world' ), align: 'left', }; expect( findNoteInBlock( attributes, 7 ) ).toBeNull(); } ); it( 'returns the attribute key and range of the matching marker', () => { const attributes = { content: RichTextData.fromHTMLString( 'hello <span class="wp-note" data-id="7">marked</span> world' ), }; expect( findNoteInBlock( attributes, 7 ) ).toEqual( { attributeKey: 'content', start: 6, end: 12, } ); } ); it( 'discovers the marker in a non-primary rich-text attribute', () => { const attributes = { content: RichTextData.fromHTMLString( 'no marker here' ), caption: RichTextData.fromHTMLString( '<span class="wp-note" data-id="9">cap</span>' ), }; expect( findNoteInBlock( attributes, 9 ) ).toEqual( { attributeKey: 'caption', start: 0, end: 3, } ); } ); it( 'ignores non-rich-text attribute values without throwing', () => { const attributes = { level: 2, anchor: '', content: RichTextData.fromHTMLString( '<span class="wp-note" data-id="3">x</span>' ), }; expect( findNoteInBlock( attributes, 3 ) ).toEqual( { attributeKey: 'content', start: 0, end: 1, } ); } ); it( 'coerces ids to strings so numeric vs string ids match', () => { const attributes = { content: RichTextData.fromHTMLString( '<span class="wp-note" data-id="7">x</span>' ), }; expect( findNoteInBlock( attributes, '7' ) ).toEqual( { attributeKey: 'content', start: 0, end: 1, } ); } ); } ); describe( 'applyNoteFormat', () => { const FORMAT_NAME = 'core/note'; const isRegistered = () => !! select( richTextStore ).getFormatType( FORMAT_NAME ); beforeAll( () => { if ( ! isRegistered() ) { registerFormatType( FORMAT_NAME, { title: 'Note', tagName: 'mark', className: 'wp-note', attributes: { 'data-id': 'data-id' }, edit: () => null, } ); } if ( ! select( richTextStore ).getFormatType( 'core/bold' ) ) { registerFormatType( 'core/bold', { title: 'Bold', tagName: 'strong', className: null, edit: () => null, } ); } } ); afterAll( () => { if ( isRegistered() ) { unregisterFormatType( FORMAT_NAME ); } if ( select( richTextStore ).getFormatType( 'core/bold' ) ) { unregisterFormatType( 'core/bold' ); } } ); const note = ( id ) => ( { type: FORMAT_NAME, attributes: { 'data-id': String( id ) }, } ); // Apply a sequence of [ id, start, end ] notes, then round-trip through HTML // to a normalised value (matching how wrapInlineNote stores the result). const applyAll = ( html, ops ) => { let record = create( { html } ); for ( const [ id, start, end ] of ops ) { record = applyNoteFormat( record, note( id ), start, end ); } return RichTextData.fromHTMLString( new RichTextData( record ).toHTMLString() ); }; it( 'adds a single marker over plain text', () => { const value = applyAll( 'the quick brown fox', [ [ 7, 4, 9 ] ] ); expect( value.toHTMLString() ).toBe( 'the <mark data-id="7" class="wp-note">quick</mark> brown fox' ); } ); it( 'nests a contained note inside its parent (outer applied first)', () => { const value = applyAll( 'the quick brown fox jumps over', [ [ 2, 0, 29 ], [ 1, 16, 22 ], ] ); expect( value.toHTMLString() ).toBe( '<mark data-id="2" class="wp-note">the quick brown <mark data-id="1" class="wp-note">fox ju</mark>mps ove</mark>r' ); } ); it( 'nests the same regardless of application order (inner applied first)', () => { const value = applyAll( 'the quick brown fox jumps over', [ [ 1, 16, 22 ], [ 2, 0, 29 ], ] ); expect( value.toHTMLString() ).toBe( '<mark data-id="2" class="wp-note">the quick brown <mark data-id="1" class="wp-note">fox ju</mark>mps ove</mark>r' ); } ); it( 'keeps both notes when a larger note covers an existing one (no clobber)', () => { const value = applyAll( 'the quick brown fox jumps over', [ [ 1, 16, 22 ], [ 2, 0, 29 ], ] ); expect( findNoteRange( value, 1 ) ).toEqual( { start: 16, end: 22 } ); expect( findNoteRange( value, 2 ) ).toEqual( { start: 0, end: 29 } ); } ); it( 'preserves both full ranges across a partial (crossing) overlap', () => { const value = applyAll( 'the quick brown fox jumps over', [ [ 1, 0, 18 ], [ 2, 10, 30 ], ] ); expect( findNoteRange( value, 1 ) ).toEqual( { start: 0, end: 18 } ); expect( findNoteRange( value, 2 ) ).toEqual( { start: 10, end: 30 } ); } ); it( 'keeps disjoint notes separate', () => { const value = applyAll( 'the quick brown fox jumps over', [ [ 1, 0, 9 ], [ 2, 16, 25 ], ] ); expect( value.toHTMLString() ).toBe( '<mark data-id="1" class="wp-note">the quick</mark> brown <mark data-id="2" class="wp-note">fox jumps</mark> over' ); } ); it( 'preserves the note range when wrapping already-formatted text', () => { let record = create( { html: 'the quick brown fox jumps over' } ); record = applyFormat( record, { type: 'core/bold' }, 4, 9 ); record = applyNoteFormat( record, note( 1 ), 0, 18 ); const value = RichTextData.fromHTMLString( new RichTextData( record ).toHTMLString() ); expect( findNoteRange( value, 1 ) ).toEqual( { start: 0, end: 18 } ); } ); } ); describe( 'getInlineMarkerStart', () => { // `findNoteInBlock` (which getInlineMarkerStart delegates to) walks rich-text // values and needs the `core/note` format registered to recognize the marker // elements the tests construct below. const FORMAT_NAME = 'core/note'; const isRegistered = () => !! select( richTextStore ).getFormatType( FORMAT_NAME ); beforeAll( () => { if ( ! isRegistered() ) { registerFormatType( FORMAT_NAME, { title: 'Note', tagName: 'mark', className: 'wp-note', attributes: { 'data-id': 'data-id' }, edit: () => null, } ); } } ); afterAll( () => { if ( isRegistered() ) { unregisterFormatType( FORMAT_NAME ); } } ); it( 'returns the block-level sentinel when the block carries no marker for the note', () => { const attributes = { content: RichTextData.fromHTMLString( 'hello world' ), }; const thread = { id: 1 }; expect( getInlineMarkerStart( thread, attributes ) ).toBe( BLOCK_LEVEL_NOTE_START ); } ); it( 'returns the marker start offset when the block carries a matching marker', () => { const attributes = { content: RichTextData.fromHTMLString( 'hello <mark class="wp-note" data-id="7">marked</mark> world' ), }; expect( getInlineMarkerStart( { id: 7 }, attributes ) ).toBe( 6 ); } ); it( 'discovers the marker in a non-primary rich-text attribute', () => { const attributes = { content: RichTextData.fromHTMLString( 'no marker here' ), caption: RichTextData.fromHTMLString( 'xx <mark class="wp-note" data-id="7">y</mark>' ), }; expect( getInlineMarkerStart( { id: 7 }, attributes ) ).toBe( 3 ); } ); it( 'returns the block-level sentinel when block attributes are empty', () => { expect( getInlineMarkerStart( { id: 7 }, {} ) ).toBe( BLOCK_LEVEL_NOTE_START ); } ); it( 'returns the block-level sentinel when block attributes are null', () => { expect( getInlineMarkerStart( { id: 7 }, null ) ).toBe( BLOCK_LEVEL_NOTE_START ); } ); it( 'is order-stable when used as a sort key: block-level first, then by start offset, then by id', () => { const attributes = { content: RichTextData.fromHTMLString( 'a <mark class="wp-note" data-id="2">x</mark> b <mark class="wp-note" data-id="3">y</mark> c <mark class="wp-note" data-id="1">z</mark>' ), }; const threads = [ // Block-level note (no marker) — should sort first. { id: 99 }, // Inline notes — should sort by marker offset, then id. { id: 1 }, { id: 2 }, { id: 3 }, ]; const sorted = [ ...threads ].sort( ( a, b ) => { const aStart = getInlineMarkerStart( a, attributes ); const bStart = getInlineMarkerStart( b, attributes ); if ( aStart !== bStart ) { return aStart - bStart; } return a.id - b.id; } ); expect( sorted.map( ( t ) => t.id ) ).toEqual( [ 99, 2, 3, 1 ] ); } ); } ); describe( 'removeNoteFormat', () => { // `create`/`findNoteRange` parse marker elements through the registered // `core/note` format, so register it for the fixtures below. const FORMAT_NAME = 'core/note'; const isRegistered = () => !! select( richTextStore ).getFormatType( FORMAT_NAME ); beforeAll( () => { if ( ! isRegistered() ) { registerFormatType( FORMAT_NAME, { title: 'Note', tagName: 'mark', className: 'wp-note', attributes: { 'data-id': 'data-id' }, edit: () => null, } ); } } ); afterAll( () => { if ( isRegistered() ) { unregisterFormatType( FORMAT_NAME ); } } ); it( 'returns null when the value is not rich text', () => { expect( removeNoteFormat( 'plain string', 1 ) ).toBeNull(); expect( removeNoteFormat( undefined, 1 ) ).toBeNull(); } ); it( 'returns null when no marker matches the note id', () => { const value = RichTextData.fromHTMLString( 'the <mark class="wp-note" data-id="2">quick</mark> brown fox' ); expect( removeNoteFormat( value, 1 ) ).toBeNull(); } ); it( 'removes the marker while leaving the text intact', () => { const value = RichTextData.fromHTMLString( 'the <mark class="wp-note" data-id="7">quick</mark> brown fox' ); expect( removeNoteFormat( value, 7 ).toHTMLString() ).toBe( 'the quick brown fox' ); } ); it( 'removes only the targeted note, leaving a co-located note intact', () => { // Note 2 wraps the whole string; note 1 nests inside it. const value = RichTextData.fromHTMLString( '<mark data-id="2" class="wp-note">the quick brown <mark data-id="1" class="wp-note">fox ju</mark>mps ove</mark>r' ); const result = removeNoteFormat( value, 1 ); expect( findNoteRange( result, 1 ) ).toBeNull(); expect( findNoteRange( result, 2 ) ).toEqual( { start: 0, end: 29 } ); } ); it( 'matches numeric and string note ids', () => { const html = 'a <mark class="wp-note" data-id="5">b</mark> c'; expect( removeNoteFormat( RichTextData.fromHTMLString( html ), 5 ).toHTMLString() ).toBe( 'a b c' ); expect( removeNoteFormat( RichTextData.fromHTMLString( html ), '5' ).toHTMLString() ).toBe( 'a b c' ); } ); } ); describe( 'getNoteMarkerSelector', () => { it( 'targets the note marker by id', () => { expect( getNoteMarkerSelector( 12 ) ).toBe( 'mark.wp-note[data-id="12"]' ); expect( getNoteMarkerSelector( '12' ) ).toBe( 'mark.wp-note[data-id="12"]' ); } ); it( 'escapes quotes and backslashes', () => { expect( getNoteMarkerSelector( '1"2\\3' ) ).toBe( 'mark.wp-note[data-id="1\\"2\\\\3"]' ); } ); } ); describe( 'getNoteMarkerSelector / noteFormat', () => { it( 'matches the shape the note format actually serializes', () => { // The selector is hand-written rather than derived, so lock it to the // format definition: a change to tagName/className/data-id there must // fail here rather than silently degrade every thread to block-top // alignment via getAnchorRects' fallback. const marker = document.createElement( noteFormat.tagName ); marker.className = noteFormat.className; marker.setAttribute( noteFormat.attributes[ 'data-id' ], '7' ); expect( marker.matches( getNoteMarkerSelector( 7 ) ) ).toBe( true ); } ); } );