HEX
Server: LiteSpeed
System: Linux server342.web-hosting.com 4.18.0-553.124.4.lve.el8.x86_64 #1 SMP Fri May 15 13:02:13 UTC 2026 x86_64
User: ksonpoau (1099)
PHP: 8.2.31
Disabled: NONE
Upload Files
File: //home/ksonpoau/www/wp-content/plugins/extendify/tests/unit/QuickEdit/lib/rich-text-color.test.js
// rich-text-color drives the text-color + highlight buttons inside the
// inline editor. Tests pin three contracts:
//   1. RICHTEXT_ATTR_BY_BLOCK map (the block-name → attr-key contract
//      that BlockTextEditor's content patch reads).
//   2. captureDomRichTextSelection's DOM walk (clientId + char offsets
//      from a window.getSelection range).
//   3. applyColorFormat's inline-style merge — including the non-obvious
//      `background-color: transparent` override that suppresses the
//      <mark> default yellow when only text color is set.

jest.mock('@wordpress/rich-text', () => {
	const create = jest.fn(({ html }) => ({
		text: html.replace(/<[^>]+>/g, ''),
		formats: [],
		replacements: [],
	}));
	const applyFormat = jest.fn((value, format) => ({
		...value,
		appliedFormat: format,
	}));
	const removeFormat = jest.fn((value, type) => ({
		...value,
		removedFormatType: type,
	}));
	const toHTMLString = jest.fn(({ value }) =>
		value.appliedFormat
			? `<mark style="${value.appliedFormat.attributes.style}">${value.text}</mark>`
			: value.text,
	);
	return { create, applyFormat, removeFormat, toHTMLString };
});

jest.mock('@wordpress/data', () => {
	const blocks = new Map();
	const select = jest.fn(() => ({
		getBlock: (clientId) => blocks.get(clientId),
		getSelectedBlockClientId: () => null,
		getBlockOrder: () => Array.from(blocks.keys()),
	}));
	return { select, __setBlock: (id, block) => blocks.set(id, block) };
});

let rtc;

beforeEach(() => {
	jest.resetModules();
	jest.clearAllMocks();
	document.body.innerHTML = '';
	rtc = require('@quick-edit/lib/rich-text-color');
});

describe('RICHTEXT_ATTR_BY_BLOCK', () => {
	it('maps each text-bearing block to its RichText attribute key', () => {
		expect(rtc.RICHTEXT_ATTR_BY_BLOCK).toEqual({
			'core/paragraph': 'content',
			'core/heading': 'content',
			'core/verse': 'content',
			'core/button': 'text',
			'core/pullquote': 'value',
			'core/code': 'content',
			'core/preformatted': 'content',
		});
	});
});

describe('captureDomRichTextSelection — null cases', () => {
	it('returns null when there is no selection', () => {
		const sel = jest.spyOn(window, 'getSelection').mockReturnValue(null);
		expect(rtc.captureDomRichTextSelection()).toBeNull();
		sel.mockRestore();
	});

	it('returns null when the range is outside a [contenteditable=true]', () => {
		const block = document.createElement('div');
		block.dataset.block = 'block-1';
		const span = document.createElement('span');
		span.textContent = 'plain';
		block.appendChild(span);
		document.body.appendChild(block);

		const range = document.createRange();
		range.setStart(span.firstChild, 0);
		range.setEnd(span.firstChild, 5);
		const sel = jest.spyOn(window, 'getSelection').mockReturnValue({
			rangeCount: 1,
			getRangeAt: () => range,
		});
		expect(rtc.captureDomRichTextSelection()).toBeNull();
		sel.mockRestore();
	});

	it('returns null when the contenteditable has no [data-block] ancestor', () => {
		const editable = document.createElement('div');
		editable.setAttribute('contenteditable', 'true');
		editable.textContent = 'hello world';
		document.body.appendChild(editable);

		const range = document.createRange();
		range.setStart(editable.firstChild, 0);
		range.setEnd(editable.firstChild, 5);
		const sel = jest.spyOn(window, 'getSelection').mockReturnValue({
			rangeCount: 1,
			getRangeAt: () => range,
		});
		expect(rtc.captureDomRichTextSelection()).toBeNull();
		sel.mockRestore();
	});
});

describe('captureDomRichTextSelection — happy path', () => {
	it('returns clientId + char offsets relative to the contenteditable', () => {
		const block = document.createElement('div');
		block.dataset.block = 'block-1';
		const editable = document.createElement('p');
		editable.setAttribute('contenteditable', 'true');
		editable.textContent = 'hello world';
		block.appendChild(editable);
		document.body.appendChild(block);

		const range = document.createRange();
		range.setStart(editable.firstChild, 0);
		range.setEnd(editable.firstChild, 5);
		const sel = jest.spyOn(window, 'getSelection').mockReturnValue({
			rangeCount: 1,
			getRangeAt: () => range,
		});

		expect(rtc.captureDomRichTextSelection()).toEqual({
			clientId: 'block-1',
			startOffset: 0,
			endOffset: 5,
		});
		sel.mockRestore();
	});
});

describe('applyColorFormat — selection resolution', () => {
	it('does nothing when there is no selection snapshot', () => {
		const dispatch = {
			updateBlockAttributes: jest.fn(),
			selectionChange: jest.fn(),
		};
		rtc.applyColorFormat(null, 'text', '#f00', dispatch);
		expect(dispatch.updateBlockAttributes).not.toHaveBeenCalled();
	});

	it('does nothing when the snapshot has no usable range', () => {
		const dispatch = {
			updateBlockAttributes: jest.fn(),
			selectionChange: jest.fn(),
		};
		rtc.applyColorFormat({ sel: {} }, 'text', '#f00', dispatch);
		expect(dispatch.updateBlockAttributes).not.toHaveBeenCalled();
	});

	it('does nothing when the snap has no range AND no block can be resolved from the editor', () => {
		// snap.sel has no usable start.attributeKey + no snap.dom range. The
		// fallback then tries the block-editor data store, which our mock
		// returns no blocks for. resolveSelectionFromSnap returns null.
		const dispatch = {
			updateBlockAttributes: jest.fn(),
			selectionChange: jest.fn(),
		};
		rtc.applyColorFormat(
			{ sel: { start: {}, end: {} } },
			'text',
			'#f00',
			dispatch,
		);
		expect(dispatch.updateBlockAttributes).not.toHaveBeenCalled();
	});

	it('falls through to whole-block on a collapsed selection when start/end share a clientId+attrKey', () => {
		// Pinning the (somewhat surprising) characterization: a collapsed
		// selection is NOT a no-op — it falls through to the whole-block
		// branch and applies the format across the entire RichText content.
		const dispatch = {
			updateBlockAttributes: jest.fn(),
			selectionChange: jest.fn(),
		};
		rtc.applyColorFormat(
			{
				sel: {
					start: { clientId: 'b-1', attributeKey: 'content', offset: 3 },
					end: { clientId: 'b-1', attributeKey: 'content', offset: 3 },
					block: {
						name: 'core/paragraph',
						attributes: { content: 'hello world' },
					},
				},
			},
			'text',
			'#f00',
			dispatch,
		);
		expect(dispatch.updateBlockAttributes).toHaveBeenCalledWith('b-1', {
			content: expect.any(String),
		});
	});
});

describe('applyColorFormat — write path', () => {
	const makeSnap = (block = {}) => ({
		sel: {
			start: { clientId: 'b-1', attributeKey: 'content', offset: 0 },
			end: { clientId: 'b-1', attributeKey: 'content', offset: 5 },
			block: {
				name: 'core/paragraph',
				attributes: { content: 'hello world' },
				...block,
			},
		},
	});

	it('writes a text-color format with the requested color and updates the block attribute', () => {
		const dispatch = {
			updateBlockAttributes: jest.fn(),
			selectionChange: jest.fn(),
		};
		rtc.applyColorFormat(makeSnap(), 'text', '#f00', dispatch);

		const rt = require('@wordpress/rich-text');
		const [_value, format] = rt.applyFormat.mock.calls[0];
		expect(format.type).toBe('core/text-color');
		// The non-obvious bit: setting only text color forces background-color: transparent
		// so the wrapping <mark>'s default yellow doesn't show through.
		expect(format.attributes.style).toContain('color:#f00');
		expect(format.attributes.style).toContain('background-color:transparent');

		expect(dispatch.updateBlockAttributes).toHaveBeenCalledWith('b-1', {
			content: expect.any(String),
		});
	});

	it('writes background-color without forcing transparent fallback', () => {
		const dispatch = {
			updateBlockAttributes: jest.fn(),
			selectionChange: jest.fn(),
		};
		rtc.applyColorFormat(makeSnap(), 'background', '#0f0', dispatch);

		const rt = require('@wordpress/rich-text');
		const [, format] = rt.applyFormat.mock.calls[0];
		expect(format.attributes.style).toBe('background-color:#0f0');
	});

	it('removes the text-color format when color is null', () => {
		const dispatch = {
			updateBlockAttributes: jest.fn(),
			selectionChange: jest.fn(),
		};
		rtc.applyColorFormat(makeSnap(), 'text', null, dispatch);

		const rt = require('@wordpress/rich-text');
		expect(rt.removeFormat).toHaveBeenCalledWith(
			expect.anything(),
			'core/text-color',
			0,
			5,
		);
	});

	it('schedules a selectionChange via requestAnimationFrame', () => {
		const rafSpy = jest
			.spyOn(window, 'requestAnimationFrame')
			.mockImplementation((cb) => cb());

		const dispatch = {
			updateBlockAttributes: jest.fn(),
			selectionChange: jest.fn(),
		};
		rtc.applyColorFormat(makeSnap(), 'text', '#f00', dispatch);

		expect(dispatch.selectionChange).toHaveBeenCalledWith(
			'b-1',
			'content',
			0,
			5,
		);
		rafSpy.mockRestore();
	});

	it('clamps the selection offsets to the actual text length', () => {
		const dispatch = {
			updateBlockAttributes: jest.fn(),
			selectionChange: jest.fn(),
		};
		// snap requests 0–500 but the text is only 11 chars
		rtc.applyColorFormat(
			{
				sel: {
					start: { clientId: 'b-1', attributeKey: 'content', offset: 0 },
					end: { clientId: 'b-1', attributeKey: 'content', offset: 500 },
					block: {
						name: 'core/paragraph',
						attributes: { content: 'hello world' },
					},
				},
			},
			'text',
			'#f00',
			dispatch,
		);

		const rt = require('@wordpress/rich-text');
		const [, , startOffset, endOffset] = rt.applyFormat.mock.calls[0];
		expect(startOffset).toBe(0);
		expect(endOffset).toBe(11);
	});
});

describe('applyColorFormat — per-segment preserves the other property', () => {
	const RED_BG = {
		type: 'core/text-color',
		attributes: { style: 'background-color:#cf2e2e' },
	};
	const RED_TEXT = {
		type: 'core/text-color',
		attributes: { style: 'color:#ff0000;background-color:transparent' },
	};

	const formatsWith = (text, fmt, from, to) =>
		text.split('').map((_, i) => (i >= from && i < to ? [fmt] : undefined));

	const snapForRange = (text, formats, from, to) => ({
		sel: {
			start: { clientId: 'b-1', attributeKey: 'content', offset: from },
			end: { clientId: 'b-1', attributeKey: 'content', offset: to },
			block: {
				name: 'core/paragraph',
				attributes: { content: { text, formats, replacements: [] } },
			},
		},
	});

	const stylesFromApplyFormatCalls = () => {
		const rt = require('@wordpress/rich-text');
		return rt.applyFormat.mock.calls.map(([, format, from, to]) => ({
			from,
			to,
			style: format.attributes.style,
		}));
	};

	it('picking text color on a range with a sub-range bg preserves the bg only on its sub-range', () => {
		// Chris's repro: 'Here is some sample text', bg=red on 'some',
		// then pick text gray on 'is some sample'. Expected — three
		// segments, red preserved only on 'some'.
		const text = 'Here is some sample text';
		const formats = formatsWith(text, RED_BG, 8, 12);

		const dispatch = {
			updateBlockAttributes: jest.fn(),
			selectionChange: jest.fn(),
		};
		rtc.applyColorFormat(
			snapForRange(text, formats, 5, 19),
			'text',
			'#abb8c3',
			dispatch,
		);

		const segs = stylesFromApplyFormatCalls();
		expect(segs).toHaveLength(3);
		expect(segs[0]).toEqual({
			from: 5,
			to: 8,
			style: 'color:#abb8c3;background-color:transparent',
		});
		expect(segs[1]).toEqual({
			from: 8,
			to: 12,
			style: 'color:#abb8c3;background-color:#cf2e2e',
		});
		expect(segs[2]).toEqual({
			from: 12,
			to: 19,
			style: 'color:#abb8c3;background-color:transparent',
		});
	});

	it('picking highlight on a range with a sub-range text color preserves the text color only on its sub-range', () => {
		// Mirror direction: bg=yellow on the whole range, but only the
		// sub-range carries `color:#ff0000`. Picking highlight=yellow
		// should leave the red text color intact only on its sub-range.
		const text = 'Here is some sample text';
		const formats = formatsWith(text, RED_TEXT, 8, 12);

		const dispatch = {
			updateBlockAttributes: jest.fn(),
			selectionChange: jest.fn(),
		};
		rtc.applyColorFormat(
			snapForRange(text, formats, 5, 19),
			'highlight',
			'#ffff00',
			dispatch,
		);

		const segs = stylesFromApplyFormatCalls();
		expect(segs).toHaveLength(3);
		expect(segs[0]).toEqual({
			from: 5,
			to: 8,
			style: 'background-color:#ffff00',
		});
		expect(segs[1]).toEqual({
			from: 8,
			to: 12,
			style: 'color:#ff0000;background-color:#ffff00',
		});
		expect(segs[2]).toEqual({
			from: 12,
			to: 19,
			style: 'background-color:#ffff00',
		});
	});

	it('picks across a uniform-bg range collapse to ONE segment carrying both props', () => {
		// Whole range has bg=yellow; pick text=red. One segment carrying
		// both. This is the case the prior uniformity-based fix happened
		// to handle correctly; the per-segment shape still produces it
		// because the runs collapse when the other prop is uniform.
		const yellow = {
			type: 'core/text-color',
			attributes: { style: 'background-color:#ffff00' },
		};
		const text = 'hello';
		const formats = formatsWith(text, yellow, 0, text.length);

		const dispatch = {
			updateBlockAttributes: jest.fn(),
			selectionChange: jest.fn(),
		};
		rtc.applyColorFormat(
			snapForRange(text, formats, 0, text.length),
			'text',
			'#ff0000',
			dispatch,
		);

		const segs = stylesFromApplyFormatCalls();
		expect(segs).toHaveLength(1);
		expect(segs[0].style).toContain('color:#ff0000');
		expect(segs[0].style).toContain('background-color:#ffff00');
	});

	it('picks on a plain range with no pre-existing formats writes ONE segment with the transparent override', () => {
		const text = 'hello';
		const formats = text.split('').map(() => undefined);

		const dispatch = {
			updateBlockAttributes: jest.fn(),
			selectionChange: jest.fn(),
		};
		rtc.applyColorFormat(
			snapForRange(text, formats, 0, text.length),
			'text',
			'#ff0000',
			dispatch,
		);

		const segs = stylesFromApplyFormatCalls();
		expect(segs).toHaveLength(1);
		expect(segs[0].style).toBe('color:#ff0000;background-color:transparent');
	});
});