import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import {
decodeRichText, decodeRichTextDocument, encodeRichText, MAX_RICH_TEXT_CHARACTERS,
MAX_RICH_TEXT_ENCODED_CHARACTERS, MAX_RICH_TEXT_SEGMENT_CHARACTERS, MAX_RICH_TEXT_SEGMENTS,
mergeRichTextVariableCatalog, RICH_TEXT_CLIPBOARD_MIME, RICH_TEXT_PREFIX, RichTextEditor, richTextIsValid, richTextPlainText,
type RichTextVariableDefinition,
} from './rich-text-editor';
function pointAtOffset(root: HTMLElement, requestedOffset: number): { node: Node; offset: number } {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let remaining = requestedOffset;
let last: Text | undefined;
for (let current = walker.nextNode() as Text | null; current; current = walker.nextNode() as Text | null) {
last = current;
if (remaining <= current.data.length) return { node: current, offset: remaining };
remaining -= current.data.length;
}
return last ? { node: last, offset: last.data.length } : { node: root, offset: 0 };
}
function selectEditorText(root: HTMLElement, startOffset: number, endOffset: number): void {
const start = pointAtOffset(root, startOffset);
const end = pointAtOffset(root, endOffset);
const range = document.createRange();
range.setStart(start.node, start.offset);
range.setEnd(end.node, end.offset);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
document.dispatchEvent(new Event('selectionchange'));
fireEvent.mouseUp(root);
}
function replaceEditorDomText(root: HTMLElement, text: string, caret = text.length): void {
root.textContent = text;
selectEditorText(root, caret, caret);
}
function editorSelectionOffsets(root: HTMLElement): { start: number; end: number } | undefined {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return undefined;
const range = selection.getRangeAt(0);
if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return undefined;
const start = document.createRange();
start.selectNodeContents(root);
start.setEnd(range.startContainer, range.startOffset);
const end = document.createRange();
end.selectNodeContents(root);
end.setEnd(range.endContainer, range.endOffset);
return { start: start.toString().length, end: end.toString().length };
}
function selectionToolbar(): HTMLElement {
return screen.getByLabelText('所选文本工具栏 / Selected text toolbar');
}
function catalogVariable(key: string, overrides: Partial<RichTextVariableDefinition> = {}): RichTextVariableDefinition {
return {
key, nameZh: `变量 ${key}`, nameEn: `Variable ${key}`,
descriptionZh: `变量 ${key} 的中文说明`, descriptionEn: `English description for ${key}`,
category: 'server', scopes: ['*'], example: `{${key}}`, sampleValue: `value-${key}`,
templateAllowed: true, conditionAllowed: true, ...overrides,
};
}
describe('RichTextEditor', () => {
it('preserves literal technical strings through typing, line breaks, and plain paste when opted in', () => {
const changes = vi.fn();
function Harness() {
const [value, setValue] = useState('{player.name} == "Alex"');
return <><RichTextEditor value={value} preservePlainText onChange={(next) => { changes(next); setValue(next); }} />
<output data-testid="literal-value">{value}</output></>;
}
render(<Harness />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
replaceEditorDomText(editor, '{player.name} == "Alex" && ready');
fireEvent.input(editor, { data: ' && ready', inputType: 'insertText' });
expect(changes).toHaveBeenLastCalledWith('{player.name} == "Alex" && ready');
fireEvent.keyDown(editor, { key: 'Enter' });
expect(changes).toHaveBeenLastCalledWith('{player.name} == "Alex" && ready\n');
fireEvent.paste(editor, { clipboardData: { getData: (type: string) => type === 'text/plain' ? 'minecraft:diamond_sword' : '' } });
expect(screen.getByTestId('literal-value').textContent).toBe('{player.name} == "Alex" && ready\nminecraft:diamond_sword');
expect(changes.mock.calls.every(([next]) => !(next as string).startsWith(RICH_TEXT_PREFIX))).toBe(true);
});
it('retains the previous encoded-on-edit behavior unless preservePlainText is enabled', () => {
const changes = vi.fn();
render(<RichTextEditor value="plain" onChange={changes} />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
replaceEditorDomText(editor, 'plain text');
fireEvent.input(editor, { data: ' text', inputType: 'insertText' });
const next = changes.mock.calls.at(-1)?.[0] as string;
expect(next.startsWith(RICH_TEXT_PREFIX)).toBe(true);
expect(richTextPlainText(next)).toBe('plain text');
});
it('upgrades a preserved string when formatting is added and never silently flattens it again', async () => {
function Harness() {
const [value, setValue] = useState('Styled');
return <><RichTextEditor value={value} onChange={setValue} preservePlainText />
<output data-testid="styled-literal-value">{value}</output></>;
}
const user = userEvent.setup();
render(<Harness />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
selectEditorText(editor, 0, 6);
const bold = within(selectionToolbar()).getByTitle('粗体 / Bold');
await user.click(bold);
let next = screen.getByTestId('styled-literal-value').textContent ?? '';
expect(next.startsWith(RICH_TEXT_PREFIX)).toBe(true);
expect(decodeRichText(next)[0]).toMatchObject({ text: 'Styled', bold: true });
await user.click(bold);
next = screen.getByTestId('styled-literal-value').textContent ?? '';
expect(next.startsWith(RICH_TEXT_PREFIX)).toBe(true);
expect(decodeRichText(next)[0].bold).toBe(false);
});
it('keeps an existing unstyled rich document encoded with preservePlainText', () => {
const changes = vi.fn();
render(<RichTextEditor value={encodeRichText(decodeRichText('already rich'))} onChange={changes} preservePlainText />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
replaceEditorDomText(editor, 'already rich!');
fireEvent.input(editor, { data: '!', inputType: 'insertText' });
expect((changes.mock.calls.at(-1)?.[0] as string).startsWith(RICH_TEXT_PREFIX)).toBe(true);
});
it.each([
['所选文字悬停说明 / Selected text hover details', 'More information'],
['所选文字插入内容 / Selected text insertion', 'insert value'],
['所选文字点击动作 / Selected text click action', 'copy_to_clipboard'],
['所选文字颜色 / Selected text color', '#ff0000'],
['浮点变量保留位数 / Floating-point fraction digits', '4'],
])('encodes preserved text when %s becomes non-default', (label, targetValue) => {
const changes = vi.fn();
render(<RichTextEditor value="literal" onChange={changes} preservePlainText />);
selectEditorText(screen.getByRole('textbox', { name: '富文本内容 / Rich text content' }), 0, 7);
fireEvent.change(screen.getByLabelText(label), { target: { value: targetValue } });
expect(changes).toHaveBeenCalledTimes(1);
expect((changes.mock.calls[0][0] as string).startsWith(RICH_TEXT_PREFIX)).toBe(true);
expect(richTextPlainText(changes.mock.calls[0][0] as string)).toBe('literal');
});
it('encodes an explicitly configured sender even if it matches the displayed default', () => {
const changes = vi.fn();
render(<RichTextEditor value="literal" onChange={changes} preservePlainText />);
fireEvent.click(screen.getByLabelText('使用全局默认发送者 / Use global default sender'));
const next = changes.mock.calls.at(-1)?.[0] as string;
expect(next.startsWith(RICH_TEXT_PREFIX)).toBe(true);
expect(decodeRichTextDocument(next).sender?.text).toBe('XFEServerManager');
});
it('makes read-only content and every mutation control non-editable while still allowing copy', () => {
const changes = vi.fn();
const value = encodeRichText([{ ...decodeRichText('Read only')[0], clickAction: 'copy_to_clipboard', clickValue: 'copied' }], {
visible: true, text: 'Custom', color: '#abcdef', bold: true, italic: false, underlined: false, strikethrough: false,
});
const { container } = render(<RichTextEditor value={value} onChange={changes} readOnly />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
expect(editor).toHaveAttribute('contenteditable', 'false');
expect(editor).toHaveAttribute('aria-readonly', 'true');
selectEditorText(editor, 0, 9);
for (const control of container.querySelectorAll('button,input,select,textarea')) expect(control).toBeDisabled();
fireEvent.change(screen.getByLabelText('浮点变量保留位数 / Floating-point fraction digits'), { target: { value: '6' } });
fireEvent.change(screen.getByLabelText('所选文字悬停说明 / Selected text hover details'), { target: { value: 'forged' } });
fireEvent.keyDown(editor, { key: 'b', ctrlKey: true });
fireEvent.keyDown(editor, { key: 'Enter' });
const clipboardData = { getData: vi.fn(() => 'injected'), setData: vi.fn() };
expect(fireEvent.paste(editor, { clipboardData })).toBe(false);
expect(clipboardData.getData).not.toHaveBeenCalled();
fireEvent.copy(editor, { clipboardData });
expect(clipboardData.setData).toHaveBeenCalledWith('text/plain', 'Read only');
replaceEditorDomText(editor, 'forged DOM');
fireEvent.input(editor, { data: 'forged DOM', inputType: 'insertText' });
expect(editor).toHaveTextContent('Read only');
expect(changes).not.toHaveBeenCalled();
});
it('cancels pending IME commits when changed to read-only and allows a later editable session', async () => {
const changes = vi.fn();
const { rerender } = render(<RichTextEditor value="原文" onChange={changes} preservePlainText />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
fireEvent.compositionStart(editor, { data: '' });
replaceEditorDomText(editor, '原文修改');
fireEvent.compositionEnd(editor, { data: '修改' });
rerender(<RichTextEditor value="原文" onChange={changes} preservePlainText readOnly />);
await new Promise((resolve) => setTimeout(resolve, 5));
expect(changes).not.toHaveBeenCalled();
expect(editor).toHaveTextContent('原文');
fireEvent(editor, new InputEvent('beforeinput', { bubbles: true, cancelable: true, data: '!', inputType: 'insertText' }));
fireEvent.compositionStart(editor, { data: '' });
replaceEditorDomText(editor, '不应保存');
fireEvent.compositionEnd(editor, { data: '不应保存' });
expect(changes).not.toHaveBeenCalled();
expect(editor).toHaveTextContent('原文');
rerender(<RichTextEditor value="原文" onChange={changes} preservePlainText />);
replaceEditorDomText(editor, '原文!');
fireEvent.input(editor, { data: '!', inputType: 'insertText' });
expect(changes).toHaveBeenLastCalledWith('原文!');
});
it('upgrades legacy text and preserves Minecraft text styles and events', async () => {
const change = vi.fn();
const user = userEvent.setup();
render(<RichTextEditor value="Welcome {player}" onChange={change} />);
expect(richTextPlainText('Welcome {player}')).toBe('Welcome {player}');
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
selectEditorText(editor, 0, 7);
await user.click(within(selectionToolbar()).getByTitle('粗体 / Bold'));
const encoded = change.mock.calls.at(-1)?.[0] as string;
const decoded = decodeRichText(encoded);
expect(richTextPlainText(encoded)).toBe('Welcome {player}');
expect(decoded).toHaveLength(2);
expect(decoded[0]).toMatchObject({ text: 'Welcome', bold: true, color: '#ffffff' });
expect(decoded[1]).toMatchObject({ text: ' {player}', bold: false, color: '#ffffff' });
expect(decodeRichTextDocument(encoded).sender).toBeUndefined();
});
it('defaults message numbers to two decimals and persists a custom precision', async () => {
function Harness() {
const [value, setValue] = useState('X={player.x}');
return <><RichTextEditor value={value} onChange={setValue} />
<output data-testid="precision-value">{value}</output></>;
}
const user = userEvent.setup();
render(<Harness />);
const precision = screen.getByLabelText('浮点变量保留位数 / Floating-point fraction digits');
expect(precision).toHaveValue('2');
await user.selectOptions(precision, '4');
expect(decodeRichTextDocument(screen.getByTestId('precision-value').textContent ?? '').numberPrecision)
.toBe(4);
});
it('inherits the server default until the sender is explicitly customized', async () => {
function Harness() {
const [value, setValue] = useState('News');
return <><RichTextEditor value={value} onChange={setValue} defaultSenderName="Network Default" />
<output data-testid="rich-value">{value}</output></>;
}
const user = userEvent.setup();
render(<Harness />);
expect(screen.getByLabelText('Minecraft rich text preview')).toHaveTextContent('[Network Default] News');
expect(screen.getByLabelText('使用全局默认发送者 / Use global default sender')).toBeChecked();
await user.click(screen.getByLabelText('使用全局默认发送者 / Use global default sender'));
const sender = screen.getByLabelText('发送者名称 / Sender name');
await user.clear(sender);
await user.type(sender, 'Server News');
const document = decodeRichTextDocument(screen.getByTestId('rich-value').textContent ?? '');
expect(document.sender).toMatchObject({ visible: true, text: 'Server News', color: '#71e6a1' });
await user.click(screen.getByLabelText('使用全局默认发送者 / Use global default sender'));
const inherited = decodeRichTextDocument(screen.getByTestId('rich-value').textContent ?? '');
expect(inherited.sender).toBeUndefined();
expect(screen.getByLabelText('Minecraft rich text preview')).toHaveTextContent('[Network Default] News');
});
it('preserves an explicitly hidden sender while editing message content', async () => {
const hidden = {
visible: false, text: 'Hidden sender', color: '#71e6a1', bold: false,
italic: false, underlined: false, strikethrough: false,
};
function Harness() {
const [value, setValue] = useState(encodeRichText(decodeRichText('News'), hidden));
return <><RichTextEditor value={value} onChange={setValue} />
<output data-testid="hidden-value">{value}</output></>;
}
const user = userEvent.setup();
render(<Harness />);
expect(screen.getByLabelText('使用全局默认发送者 / Use global default sender')).not.toBeChecked();
expect(screen.getByLabelText('Minecraft rich text preview')).not.toHaveTextContent('[Hidden sender]');
selectEditorText(screen.getByRole('textbox', { name: '富文本内容 / Rich text content' }), 0, 4);
await user.click(within(selectionToolbar()).getByTitle('粗体 / Bold'));
expect(decodeRichTextDocument(screen.getByTestId('hidden-value').textContent ?? '').sender)
.toMatchObject({ visible: false, text: 'Hidden sender' });
});
it('previews a blank explicit sender with the same global fallback as the server', () => {
const explicitBlank = {
visible: true, text: ' ', color: '#71e6a1', bold: false,
italic: false, underlined: false, strikethrough: false,
};
render(<RichTextEditor value={encodeRichText(decodeRichText('News'), explicitBlank)}
defaultSenderName="Network Default" onChange={vi.fn()} />);
expect(screen.getByLabelText('Minecraft rich text preview')).toHaveTextContent('[Network Default] News');
});
it('round-trips safe URL and hover details', () => {
const encoded = encodeRichText([{
text: 'Rules', color: '#71e6a1', bold: false, italic: true,
underlined: true, strikethrough: false, obfuscated: false, insertion: 'rules', clickAction: 'open_url',
clickValue: 'https://example.com/rules', hoverText: 'Open server rules',
}]);
expect(decodeRichText(encoded)[0]).toMatchObject({
text: 'Rules', italic: true, underlined: true,
clickAction: 'open_url', clickValue: 'https://example.com/rules',
hoverText: 'Open server rules', insertion: 'rules',
});
});
it('loads old multi-run envelopes and applies formatting across run boundaries without data loss', async () => {
const alpha = { ...decodeRichText('Alpha')[0], text: 'Alpha', bold: true, color: '#ff5555' };
const beta = { ...decodeRichText('Beta')[0], text: 'Beta', clickAction: 'open_url' as const,
clickValue: 'https://example.com', hoverText: 'Beta link' };
const initial = encodeRichText([
alpha,
beta,
]);
function Harness() {
const [value, setValue] = useState(initial);
return <><RichTextEditor value={value} onChange={setValue} /><output data-testid="rich-value">{value}</output></>;
}
const user = userEvent.setup();
render(<Harness />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
expect(editor).toHaveTextContent('AlphaBeta');
expect(screen.getByTestId('rich-value')).toHaveTextContent(initial);
selectEditorText(editor, 2, 7);
await user.click(within(selectionToolbar()).getByTitle('下划线 / Underline'));
const decoded = decodeRichText(screen.getByTestId('rich-value').textContent ?? '');
expect(decoded.map((item) => item.text).join('')).toBe('AlphaBeta');
expect(decoded).toEqual([
expect.objectContaining({ text: 'Al', bold: true, underlined: false, color: '#ff5555' }),
expect.objectContaining({ text: 'pha', bold: true, underlined: true, color: '#ff5555' }),
expect.objectContaining({ text: 'Be', underlined: true, clickAction: 'open_url',
clickValue: 'https://example.com', hoverText: 'Beta link' }),
expect.objectContaining({ text: 'ta', underlined: false, clickAction: 'open_url',
clickValue: 'https://example.com', hoverText: 'Beta link' }),
]);
});
it('enforces the aggregate visible-text limit and rejects edits that exceed it', async () => {
const base = decodeRichText('')[0];
const initial = encodeRichText(Array.from({ length: 5 }, () => ({ ...base, text: 'x'.repeat(1638) })));
const change = vi.fn();
const user = userEvent.setup();
render(<RichTextEditor value={initial} onChange={change} />);
expect(screen.getByText((content) => content.includes(`文本字符 / Text characters: 8190 / ${MAX_RICH_TEXT_CHARACTERS}`)))
.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /插入变量 \/ Insert variable/ }));
await user.click(screen.getByRole('button', { name: /插入变量 \{player\}/ }));
expect(change).not.toHaveBeenCalled();
expect(screen.getByRole('alert')).toHaveTextContent('文本总长度不能超过 8192 个字符');
});
it('rejects documents whose bounded auxiliary fields exceed the server encoded-size limit', () => {
const base = decodeRichText('x')[0];
const value = encodeRichText(Array.from({ length: 32 }, () => ({
...base,
insertion: 'i'.repeat(1024),
clickAction: 'copy_to_clipboard' as const,
clickValue: 'c'.repeat(1024),
hoverText: 'h'.repeat(1024),
})));
expect(value.length).toBeGreaterThan(MAX_RICH_TEXT_ENCODED_CHARACTERS);
expect(richTextIsValid(value)).toBe(false);
render(<RichTextEditor value={value} onChange={vi.fn()} />);
expect(screen.getByRole('alert')).toHaveTextContent('富文本编码不能超过 65536 个字符');
});
it('flags unsafe click targets and command values', () => {
const base = decodeRichText('')[0];
const value = encodeRichText([
{ ...base, text: 'Website', clickAction: 'open_url', clickValue: 'ftp://example.com' },
{ ...base, text: 'Command', clickAction: 'run_command', clickValue: 'say hello' },
]);
render(<RichTextEditor value={value} onChange={vi.fn()} />);
expect(richTextIsValid(value)).toBe(false);
expect(screen.getByRole('alert')).toHaveTextContent('http:// 或 https://');
expect(screen.getByRole('alert')).toHaveTextContent('必须以 / 开头');
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
selectEditorText(editor, 0, 'Website'.length);
expect(screen.getByLabelText('所选文字点击动作 / Selected text click action')).toHaveValue('open_url');
expect(screen.getByLabelText('所选动作内容 / Selected action value')).toHaveAttribute('aria-invalid', 'true');
selectEditorText(editor, 'Website'.length, 'WebsiteCommand'.length);
expect(screen.getByLabelText('所选文字点击动作 / Selected text click action')).toHaveValue('run_command');
expect(screen.getByLabelText('所选动作内容 / Selected action value')).toHaveAttribute('aria-invalid', 'true');
});
it('applies every visual style and interaction field to only the selected text', async () => {
function Harness() {
const [value, setValue] = useState('Format rest');
return <><RichTextEditor value={value} onChange={setValue} /><output data-testid="rich-value">{value}</output></>;
}
const user = userEvent.setup();
render(<Harness />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
selectEditorText(editor, 0, 6);
for (const title of ['粗体 / Bold', '斜体 / Italic', '下划线 / Underline',
'删除线 / Strikethrough', '随机字符 / Obfuscated']) {
await user.click(within(selectionToolbar()).getByTitle(title));
}
fireEvent.change(screen.getByLabelText('所选文字颜色 / Selected text color'), {
target: { value: '#55ffff' },
});
await user.selectOptions(screen.getByLabelText('所选文字点击动作 / Selected text click action'),
'copy_to_clipboard');
await user.type(screen.getByLabelText('所选动作内容 / Selected action value'), 'copied value');
await user.type(screen.getByLabelText('所选文字悬停说明 / Selected text hover details'), 'Hover help');
await user.type(screen.getByLabelText('所选文字插入内容 / Selected text insertion'), 'inserted');
const decoded = decodeRichText(screen.getByTestId('rich-value').textContent ?? '');
expect(decoded[0]).toMatchObject({
text: 'Format', color: '#55ffff', bold: true, italic: true, underlined: true,
strikethrough: true, obfuscated: true, clickAction: 'copy_to_clipboard',
clickValue: 'copied value', hoverText: 'Hover help', insertion: 'inserted',
});
expect(decoded[1]).toMatchObject({ text: ' rest', bold: false, clickAction: 'none' });
});
it('supports keyboard formatting, line breaks, and ordinary contenteditable typing', async () => {
function Harness() {
const [value, setValue] = useState('Key');
return <><RichTextEditor value={value} onChange={setValue} /><output data-testid="rich-value">{value}</output></>;
}
const user = userEvent.setup();
render(<Harness />);
let editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
editor.focus();
selectEditorText(editor, 0, 3);
expect(fireEvent.keyDown(editor, { key: 'b', ctrlKey: true })).toBe(false);
expect(decodeRichText(screen.getByTestId('rich-value').textContent ?? '')[0]).toMatchObject({
text: 'Key', bold: true,
});
editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
selectEditorText(editor, 3, 3);
expect(fireEvent.keyDown(editor, { key: 'Enter' })).toBe(false);
const echoedBeforeInput = new InputEvent('beforeinput', {
bubbles: true, cancelable: true, inputType: 'insertParagraph',
});
expect(fireEvent(editor, echoedBeforeInput)).toBe(false);
fireEvent.input(editor, { inputType: 'insertParagraph' });
expect(richTextPlainText(screen.getByTestId('rich-value').textContent ?? '')).toBe('Key\n');
await user.type(editor, 'next', { skipClick: true });
expect(richTextPlainText(screen.getByTestId('rich-value').textContent ?? '')).toBe('Key\nnext');
editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
selectEditorText(editor, 'Key\nnext'.length, 'Key\nnext'.length);
expect(fireEvent.keyDown(editor, { key: 'Enter', shiftKey: true })).toBe(false);
expect(richTextPlainText(screen.getByTestId('rich-value').textContent ?? '')).toBe('Key\nnext\n');
});
it('keeps IME composition browser-owned, commits once at compositionend, and restores the caret', () => {
const changes = vi.fn();
function Harness() {
const [value, setValue] = useState('欢迎');
return <><RichTextEditor value={value} onChange={(next) => { changes(next); setValue(next); }} />
<output data-testid="ime-value">{value}</output></>;
}
render(<Harness />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
editor.focus();
selectEditorText(editor, 2, 2);
fireEvent.compositionStart(editor, { data: '' });
replaceEditorDomText(editor, '欢迎n');
fireEvent.compositionUpdate(editor, { data: 'n' });
fireEvent.input(editor, { data: 'n', inputType: 'insertCompositionText', isComposing: true });
replaceEditorDomText(editor, '欢迎ni');
fireEvent.compositionUpdate(editor, { data: 'ni' });
fireEvent.input(editor, { data: 'ni', inputType: 'insertCompositionText', isComposing: true });
expect(changes).not.toHaveBeenCalled();
expect(editor).toHaveTextContent('欢迎ni');
replaceEditorDomText(editor, '欢迎你');
fireEvent.compositionEnd(editor, { data: '你' });
// Chromium/WebKit may emit one final non-composing input after compositionend.
fireEvent.input(editor, { data: '你', inputType: 'insertText', isComposing: false });
expect(changes).toHaveBeenCalledTimes(1);
expect(richTextPlainText(screen.getByTestId('ime-value').textContent ?? '')).toBe('欢迎你');
expect(editorSelectionOffsets(editor)).toEqual({ start: 3, end: 3 });
});
it('leaves Enter to the IME candidate window without inserting a line break', () => {
const changes = vi.fn();
render(<RichTextEditor value="候选" onChange={changes} />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
editor.focus();
selectEditorText(editor, 2, 2);
fireEvent.compositionStart(editor, { data: '' });
expect(fireEvent.keyDown(editor, { key: 'Enter', isComposing: true, keyCode: 229 })).toBe(true);
expect(editor).toHaveTextContent('候选');
expect(changes).not.toHaveBeenCalled();
fireEvent.compositionEnd(editor, { data: '' });
fireEvent.input(editor, { data: '', inputType: 'insertCompositionText' });
expect(editor).toHaveTextContent('候选');
expect(changes).not.toHaveBeenCalled();
});
it('commits a completed composition through the zero-delay fallback when no tail input arrives', async () => {
const changes = vi.fn();
function Harness() {
const [value, setValue] = useState('中');
return <><RichTextEditor value={value} onChange={(next) => { changes(next); setValue(next); }} />
<output data-testid="ime-fallback-value">{value}</output></>;
}
render(<Harness />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
editor.focus();
selectEditorText(editor, 1, 1);
fireEvent.compositionStart(editor, { data: '' });
replaceEditorDomText(editor, '中文');
fireEvent.compositionEnd(editor, { data: '文' });
expect(changes).not.toHaveBeenCalled();
await waitFor(() => expect(changes).toHaveBeenCalledTimes(1));
expect(richTextPlainText(screen.getByTestId('ime-fallback-value').textContent ?? '')).toBe('中文');
expect(editorSelectionOffsets(editor)).toEqual({ start: 2, end: 2 });
});
it('accepts two legitimate consecutive full-width punctuation inputs from a soft keyboard', () => {
const changes = vi.fn();
function Harness() {
const [value, setValue] = useState('标点');
return <><RichTextEditor value={value} onChange={(next) => { changes(next); setValue(next); }} />
<output data-testid="soft-punctuation-value">{value}</output></>;
}
render(<Harness />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
editor.focus();
selectEditorText(editor, 2, 2);
for (const text of ['标点,', '标点,,']) {
expect(fireEvent(editor, new InputEvent('beforeinput', {
bubbles: true, cancelable: true, data: ',', inputType: 'insertText',
}))).toBe(true);
replaceEditorDomText(editor, text);
fireEvent.input(editor, { data: ',', inputType: 'insertText' });
}
expect(changes).toHaveBeenCalledTimes(2);
expect(richTextPlainText(screen.getByTestId('soft-punctuation-value').textContent ?? '')).toBe('标点,,');
expect(editor).toHaveTextContent('标点,,');
});
it('gives a controlled external value priority over an in-progress composition', () => {
const changes = vi.fn();
function Harness() {
const [value, setValue] = useState('旧值');
return <><button type="button" onClick={() => setValue('外部新值')}>Apply external value</button>
<RichTextEditor value={value} onChange={(next) => { changes(next); setValue(next); }} />
<output data-testid="external-value">{value}</output></>;
}
render(<Harness />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
editor.focus();
selectEditorText(editor, 2, 2);
fireEvent.compositionStart(editor, { data: '' });
replaceEditorDomText(editor, '旧值ji');
fireEvent.input(editor, { data: 'ji', inputType: 'insertCompositionText', isComposing: true });
fireEvent.click(screen.getByRole('button', { name: 'Apply external value' }));
expect(editor).toHaveTextContent('外部新值');
expect(screen.getByTestId('external-value')).toHaveTextContent('外部新值');
// A stale compositionend dispatched after the controlled update cannot restore
// the old base or its provisional DOM.
replaceEditorDomText(editor, '旧值机');
fireEvent.compositionEnd(editor, { data: '机' });
expect(changes).not.toHaveBeenCalled();
expect(editor).toHaveTextContent('外部新值');
expect(richTextPlainText(screen.getByTestId('external-value').textContent ?? '')).toBe('外部新值');
});
it.each([
['ASCII punctuation', ','],
['中文全角标点', ','],
])('deduplicates a trailing composition input for %s', (_label, punctuation) => {
const changes = vi.fn();
function Harness() {
const [value, setValue] = useState('A');
return <><RichTextEditor value={value} onChange={(next) => { changes(next); setValue(next); }} />
<output data-testid="punctuation-value">{value}</output></>;
}
render(<Harness />);
let editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
editor.focus();
selectEditorText(editor, 1, 1);
fireEvent.compositionStart(editor, { data: '' });
replaceEditorDomText(editor, `A${punctuation}`);
fireEvent.compositionEnd(editor, { data: punctuation });
expect(changes).not.toHaveBeenCalled();
// Emulate a browser that has already inserted the compositionend echo before
// dispatching its trailing input event. The editor must roll this duplicate back.
replaceEditorDomText(editor, `A${punctuation}${punctuation}`);
fireEvent.input(editor, { data: punctuation, inputType: 'insertText', isComposing: false });
editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
expect(changes).toHaveBeenCalledTimes(1);
expect(richTextPlainText(screen.getByTestId('punctuation-value').textContent ?? ''))
.toBe(`A${punctuation}`);
expect(editor).toHaveTextContent(`A${punctuation}`);
expect(editorSelectionOffsets(editor)).toEqual({ start: 2, end: 2 });
});
it('keeps two consecutive beforeinput-only line breaks without waiting for keydown', () => {
function Harness() {
const [value, setValue] = useState('Line');
return <><RichTextEditor value={value} onChange={setValue} />
<output data-testid="beforeinput-value">{value}</output></>;
}
render(<Harness />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
editor.focus();
selectEditorText(editor, 4, 4);
const beforeInput = new InputEvent('beforeinput', {
bubbles: true, cancelable: true, inputType: 'insertLineBreak',
});
expect(fireEvent(editor, beforeInput)).toBe(false);
const secondBeforeInput = new InputEvent('beforeinput', {
bubbles: true, cancelable: true, inputType: 'insertLineBreak',
});
expect(fireEvent(editor, secondBeforeInput)).toBe(false);
expect(richTextPlainText(screen.getByTestId('beforeinput-value').textContent ?? '')).toBe('Line\n\n');
expect(editorSelectionOffsets(editor)).toEqual({ start: 6, end: 6 });
});
it('commits one ordinary symbol from an empty editor exactly once', () => {
const changes = vi.fn();
function Harness() {
const [value, setValue] = useState('');
return <><RichTextEditor value={value} onChange={(next) => { changes(next); setValue(next); }} />
<output data-testid="empty-symbol-value">{value}</output></>;
}
render(<Harness />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
editor.focus();
fireEvent(editor, new InputEvent('beforeinput', {
bubbles: true, cancelable: true, data: '.', inputType: 'insertText',
}));
replaceEditorDomText(editor, '.');
fireEvent.input(editor, { data: '.', inputType: 'insertText' });
// A duplicate notification for the same DOM mutation must be a no-op.
fireEvent.input(editor, { data: '.', inputType: 'insertText' });
expect(changes).toHaveBeenCalledTimes(1);
expect(richTextPlainText(screen.getByTestId('empty-symbol-value').textContent ?? '')).toBe('.');
expect(editor).toHaveTextContent('.');
expect([...editor.childNodes]).toHaveLength(1);
expect(editor.firstChild).toBeInstanceOf(HTMLSpanElement);
});
it('normalizes one symbol typed at a differently-styled run boundary without duplication or bare nodes', () => {
const left = { ...decodeRichText('左')[0], text: '左', color: '#ff5555', bold: true };
const right = { ...decodeRichText('右')[0], text: '右', color: '#55ffff', italic: true };
const changes = vi.fn();
function Harness() {
const [value, setValue] = useState(encodeRichText([left, right]));
return <><RichTextEditor value={value} onChange={(next) => { changes(next); setValue(next); }} />
<output data-testid="boundary-symbol-value">{value}</output></>;
}
render(<Harness />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
editor.focus();
selectEditorText(editor, 1, 1);
const rightRun = editor.querySelectorAll('span')[1];
editor.insertBefore(document.createTextNode('。'), rightRun);
selectEditorText(editor, 2, 2);
fireEvent.input(editor, { data: '。', inputType: 'insertText' });
fireEvent.input(editor, { data: '。', inputType: 'insertText' });
const value = screen.getByTestId('boundary-symbol-value').textContent ?? '';
expect(changes).toHaveBeenCalledTimes(1);
expect(richTextPlainText(value)).toBe('左。右');
expect(decodeRichText(value)).toEqual([
expect.objectContaining({ text: '左。', color: '#ff5555', bold: true }),
expect.objectContaining({ text: '右', color: '#55ffff', italic: true }),
]);
expect([...editor.childNodes].every((node) => node instanceof HTMLSpanElement)).toBe(true);
});
it('rebuilds canonical styled spans after a cancelled composition without emitting a change', () => {
const left = { ...decodeRichText('A')[0], text: 'A', bold: true };
const right = { ...decodeRichText('B')[0], text: 'B', italic: true };
const changes = vi.fn();
render(<RichTextEditor value={encodeRichText([left, right])} onChange={changes} />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
editor.focus();
selectEditorText(editor, 1, 1);
fireEvent.compositionStart(editor, { data: '' });
replaceEditorDomText(editor, 'AB', 1);
fireEvent.compositionEnd(editor, { data: '' });
fireEvent.input(editor, { data: '', inputType: 'insertCompositionText' });
expect(changes).not.toHaveBeenCalled();
expect(editor.querySelectorAll(':scope > span')).toHaveLength(2);
expect(editor.querySelectorAll(':scope > span')[0]).toHaveStyle({ fontWeight: '700' });
expect(editor.querySelectorAll(':scope > span')[1]).toHaveStyle({ fontStyle: 'italic' });
expect(editorSelectionOffsets(editor)).toEqual({ start: 1, end: 1 });
});
it('copies and pastes selected rich runs with all style and interaction metadata', () => {
const first = {
...decodeRichText('AB')[0], text: 'AB', color: '#ff5555', bold: true, underlined: true,
insertion: 'insert-alpha', clickAction: 'copy_to_clipboard' as const,
clickValue: 'copy-alpha', hoverText: 'Alpha details',
};
const second = {
...decodeRichText('CD')[0], text: 'CD', color: '#55ffff', italic: true, strikethrough: true,
insertion: 'insert-beta', clickAction: 'open_url' as const,
clickValue: 'https://example.com/details', hoverText: 'Beta details',
};
const changes = vi.fn();
function Harness() {
const [value, setValue] = useState(encodeRichText([first, second, { ...decodeRichText(' target')[0] }]));
return <><RichTextEditor value={value} onChange={(next) => { changes(next); setValue(next); }} />
<output data-testid="rich-clipboard-value">{value}</output></>;
}
render(<Harness />);
const data = new Map<string, string>();
const clipboardData = {
getData: vi.fn((type: string) => data.get(type) ?? ''),
setData: vi.fn((type: string, content: string) => { data.set(type, content); }),
};
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
editor.focus();
selectEditorText(editor, 1, 3);
expect(fireEvent.copy(editor, { clipboardData })).toBe(false);
expect(data.get('text/plain')).toBe('BC');
const envelope = JSON.parse(data.get(RICH_TEXT_CLIPBOARD_MIME) ?? '{}') as Record<string, unknown>;
expect(envelope).toEqual({ version: 1, id: expect.stringMatching(/^[0-9a-f]{32}$/) });
expect(data.get(RICH_TEXT_CLIPBOARD_MIME)).not.toContain('copy-alpha');
selectEditorText(editor, 5, 11);
expect(fireEvent.paste(editor, { clipboardData })).toBe(false);
fireEvent.input(editor, { inputType: 'insertFromPaste' });
const decoded = decodeRichText(screen.getByTestId('rich-clipboard-value').textContent ?? '');
expect(changes).toHaveBeenCalledTimes(1);
expect(decoded.map((item) => item.text).join('')).toBe('ABCD BC');
expect(decoded.slice(-2)).toEqual([
expect.objectContaining({ ...first, text: 'B' }),
expect.objectContaining({ ...second, text: 'C' }),
]);
});
it('leaves copy and paste to the active IME while composition text is provisional', () => {
const clipboardData = { getData: vi.fn(() => 'ignored'), setData: vi.fn() };
const changes = vi.fn();
render(<RichTextEditor value="正在" onChange={changes} />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
editor.focus();
selectEditorText(editor, 2, 2);
fireEvent.compositionStart(editor, { data: '' });
expect(fireEvent.copy(editor, { clipboardData })).toBe(true);
expect(fireEvent.paste(editor, { clipboardData })).toBe(true);
expect(clipboardData.setData).not.toHaveBeenCalled();
expect(clipboardData.getData).not.toHaveBeenCalled();
fireEvent.compositionEnd(editor, { data: '' });
expect(changes).not.toHaveBeenCalled();
});
it.each([
['mismatched plain text', encodeRichText([{ ...decodeRichText('forged')[0], text: 'forged', bold: true }])],
['forged run-command metadata', encodeRichText([{
...decodeRichText('safe')[0], text: 'safe', bold: true,
clickAction: 'run_command' as const, clickValue: '/op @a',
}])],
])('falls back to safe plain text for %s in a custom clipboard payload', (_label, customPayload) => {
function Harness() {
const [value, setValue] = useState('Replace');
return <><RichTextEditor value={value} onChange={setValue} />
<output data-testid="untrusted-clipboard-value">{value}</output></>;
}
render(<Harness />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
selectEditorText(editor, 0, 7);
fireEvent.paste(editor, { clipboardData: {
getData: (type: string) => type === RICH_TEXT_CLIPBOARD_MIME ? customPayload : 'safe',
} });
const decoded = decodeRichText(screen.getByTestId('untrusted-clipboard-value').textContent ?? '');
expect(decoded).toEqual([expect.objectContaining({
text: 'safe', bold: false, clickAction: 'none', clickValue: '', hoverText: '', insertion: '',
})]);
});
it('replaces the current selection with custom variables and deduplicates defaults', async () => {
function Harness() {
const [value, setValue] = useState('Hello name');
return <><RichTextEditor value={value} onChange={setValue}
variables={['{args.name}', '{player}']} /><output data-testid="rich-value">{value}</output></>;
}
const user = userEvent.setup();
render(<Harness />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
selectEditorText(editor, 6, 10);
await user.click(screen.getByRole('button', { name: /插入变量 \/ Insert variable/ }));
expect(screen.getAllByRole('button', { name: /插入变量 \{player\}/ })).toHaveLength(1);
await user.click(screen.getByRole('button', { name: /插入变量 \{args\.name\}/ }));
expect(richTextPlainText(screen.getByTestId('rich-value').textContent ?? ''))
.toBe('Hello {args.name}');
});
it('uses a server catalog as authoritative, exposes 100+ searchable entries, and omits unsafe entries', async () => {
const serverCatalog = [
...Array.from({ length: 101 }, (_, index) => catalogVariable(`server.metric${index}`, index === 73 ? {
nameZh: '特别游戏速度', nameEn: 'Special game speed',
descriptionZh: '用于观察当前游戏速度的指标。',
descriptionEn: 'Metric used to observe the current game speed.',
} : {})),
catalogVariable('server.secret', { sensitive: true }),
catalogVariable('server.conditionOnly', { templateAllowed: false }),
];
function Harness() {
const [value, setValue] = useState('Speed: ');
return <><RichTextEditor value={value} onChange={setValue} variableCatalog={serverCatalog} />
<output data-testid="catalog-value">{value}</output></>;
}
const user = userEvent.setup();
render(<Harness />);
const open = screen.getByRole('button', { name: /Search 101/ });
await user.click(open);
const dialog = screen.getByRole('dialog', { name: '插入变量 / Insert variable' });
expect(dialog.parentElement?.parentElement).toBe(document.body);
expect(within(dialog).queryByText('{player}')).not.toBeInTheDocument();
expect(within(dialog).queryByText('{server.secret}')).not.toBeInTheDocument();
await user.type(within(dialog).getByRole('searchbox', { name: '搜索变量 / Search variables' }), '特别游戏速度');
expect(within(dialog).getByText(/Showing 1 of 101/)).toBeInTheDocument();
expect(within(dialog).getByText('用于观察当前游戏速度的指标。')).toBeInTheDocument();
await user.click(within(dialog).getByRole('button', { name: /\{server\.metric73\}/ }));
expect(richTextPlainText(screen.getByTestId('catalog-value').textContent ?? ''))
.toBe('Speed: {server.metric73}');
});
it('uses a left category navigation and selects all variables by default', async () => {
const catalog = [
catalogVariable('server.online'),
catalogVariable('player.name', { category: 'player', nameZh: '玩家名称' }),
catalogVariable('world.day', { category: 'world', nameZh: '世界天数' }),
];
const user = userEvent.setup();
render(<RichTextEditor value="" onChange={vi.fn()} variableCatalog={catalog} />);
await user.click(screen.getByRole('button', { name: /插入变量 \/ Insert variable/ }));
const dialog = screen.getByRole('dialog', { name: '插入变量 / Insert variable' });
const navigation = within(dialog).getByRole('navigation', { name: '变量分类 / Variable categories' });
const all = within(navigation).getByRole('button', { name: /全部/ });
expect(all).toHaveAttribute('aria-current', 'page');
expect(within(dialog).getByText(/Showing 3 of 3/)).toBeInTheDocument();
await user.click(within(navigation).getByRole('button', { name: /玩家/ }));
expect(within(navigation).getByRole('button', { name: /玩家/ })).toHaveAttribute('aria-current', 'page');
expect(within(dialog).getByText(/Showing 1 of 3/)).toBeInTheDocument();
expect(within(dialog).getByText('{player.name}')).toBeInTheDocument();
expect(within(dialog).queryByText('{world.day}')).not.toBeInTheDocument();
});
it('documents the safe date pattern and inserts a formatted server-time variable', async () => {
function Harness() {
const [value, setValue] = useState('At ');
return <><RichTextEditor value={value} onChange={setValue} variableCatalog={[
catalogVariable('server.time:uuuu-MM-dd HH:mm:ss', {
nameZh: '完整日期时间(推荐)', nameEn: 'Full date and time (recommended)',
category: 'time', sampleValue: '2026-09-04 13:05:09', conditionAllowed: false,
formatHint: 'MM=月份,mm=分钟,HH=24 小时制。',
}),
]} /><output data-testid="time-value">{value}</output></>;
}
const user = userEvent.setup();
render(<Harness />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
await user.click(screen.getByRole('button', { name: /插入变量 \/ Insert variable/ }));
const dialog = screen.getByRole('dialog', { name: '插入变量 / Insert variable' });
expect(within(dialog).getByRole('note')).toHaveTextContent('YYYY 是周历年');
expect(within(dialog).getByText('2026-09-04 13:05:09')).toBeInTheDocument();
const formattedTime = within(dialog).getByRole('button', { name: /server\.time:uuuu-MM-dd HH:mm:ss/ });
expect(formattedTime).toHaveTextContent('MM=月份,mm=分钟');
await user.click(formattedTime);
expect(richTextPlainText(screen.getByTestId('time-value').textContent ?? ''))
.toBe('At {server.time:uuuu-MM-dd HH:mm:ss}');
await waitFor(() => expect(editor).toHaveFocus());
expect(editor.contains(window.getSelection()?.anchorNode ?? null)).toBe(true);
expect(window.getSelection()?.anchorOffset).toBe('At {server.time:uuuu-MM-dd HH:mm:ss}'.length);
});
it('merges dynamic event arguments without reintroducing the legacy fallback', () => {
const merged = mergeRichTextVariableCatalog(
[catalogVariable('server.tickRate')], ['{args.target}', '{server.tickRate}'],
);
expect(merged.map((item) => item.key)).toEqual(['server.tickRate', 'args.target']);
expect(merged.find((item) => item.key === 'args.target')).toMatchObject({
category: 'command', scopes: ['player.command_trigger'],
});
});
it('accepts only plain text from paste and automatically splits oversized style runs', () => {
function Harness() {
const [value, setValue] = useState('Replace me');
return <><RichTextEditor value={value} onChange={setValue} /><output data-testid="rich-value">{value}</output></>;
}
const { rerender } = render(<Harness />);
let editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
selectEditorText(editor, 0, 'Replace me'.length);
fireEvent.paste(editor, { clipboardData: { getData: (type: string) => type === 'text/plain'
? 'Plain only' : '<img src=x onerror=alert(1)><b>HTML</b>' } });
expect(richTextPlainText(screen.getByTestId('rich-value').textContent ?? '')).toBe('Plain only');
expect(editor.querySelector('img, b')).toBeNull();
function LongHarness() {
const [value, setValue] = useState('');
return <><RichTextEditor value={value} onChange={setValue} /><output data-testid="long-value">{value}</output></>;
}
rerender(<LongHarness />);
editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
fireEvent.paste(editor, { clipboardData: { getData: (type: string) => type === 'text/plain'
? 'x'.repeat(MAX_RICH_TEXT_SEGMENT_CHARACTERS + 2) : '<script>bad()</script>' } });
const runs = decodeRichText(screen.getByTestId('long-value').textContent ?? '');
expect(runs.map((item) => item.text.length)).toEqual([MAX_RICH_TEXT_SEGMENT_CHARACTERS, 2]);
expect(runs.map((item) => item.text).join('')).toBe('x'.repeat(MAX_RICH_TEXT_SEGMENT_CHARACTERS + 2));
});
it('rejects a formatting split that would exceed the 256-run document limit', async () => {
const initial = encodeRichText(Array.from({ length: MAX_RICH_TEXT_SEGMENTS }, (_, index) => ({
...decodeRichText('ab')[0], text: 'ab', color: `#${(index + 1).toString(16).padStart(6, '0')}`,
})));
const change = vi.fn();
const user = userEvent.setup();
render(<RichTextEditor value={initial} onChange={change} />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
selectEditorText(editor, 21, 22);
await user.click(within(selectionToolbar()).getByTitle('粗体 / Bold'));
expect(change).not.toHaveBeenCalled();
expect(screen.getByRole('alert')).toHaveTextContent('富文本样式段不能超过 256 个');
});
});
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import {
decodeRichText, decodeRichTextDocument, encodeRichText, MAX_RICH_TEXT_CHARACTERS,
MAX_RICH_TEXT_ENCODED_CHARACTERS, MAX_RICH_TEXT_SEGMENT_CHARACTERS, MAX_RICH_TEXT_SEGMENTS,
mergeRichTextVariableCatalog, RICH_TEXT_CLIPBOARD_MIME, RICH_TEXT_PREFIX, RichTextEditor, richTextIsValid, richTextPlainText,
type RichTextVariableDefinition,
} from './rich-text-editor';
function pointAtOffset(root: HTMLElement, requestedOffset: number): { node: Node; offset: number } {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let remaining = requestedOffset;
let last: Text | undefined;
for (let current = walker.nextNode() as Text | null; current; current = walker.nextNode() as Text | null) {
last = current;
if (remaining <= current.data.length) return { node: current, offset: remaining };
remaining -= current.data.length;
}
return last ? { node: last, offset: last.data.length } : { node: root, offset: 0 };
}
function selectEditorText(root: HTMLElement, startOffset: number, endOffset: number): void {
const start = pointAtOffset(root, startOffset);
const end = pointAtOffset(root, endOffset);
const range = document.createRange();
range.setStart(start.node, start.offset);
range.setEnd(end.node, end.offset);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
document.dispatchEvent(new Event('selectionchange'));
fireEvent.mouseUp(root);
}
function replaceEditorDomText(root: HTMLElement, text: string, caret = text.length): void {
root.textContent = text;
selectEditorText(root, caret, caret);
}
function editorSelectionOffsets(root: HTMLElement): { start: number; end: number } | undefined {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return undefined;
const range = selection.getRangeAt(0);
if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return undefined;
const start = document.createRange();
start.selectNodeContents(root);
start.setEnd(range.startContainer, range.startOffset);
const end = document.createRange();
end.selectNodeContents(root);
end.setEnd(range.endContainer, range.endOffset);
return { start: start.toString().length, end: end.toString().length };
}
function selectionToolbar(): HTMLElement {
return screen.getByLabelText('所选文本工具栏 / Selected text toolbar');
}
function catalogVariable(key: string, overrides: Partial<RichTextVariableDefinition> = {}): RichTextVariableDefinition {
return {
key, nameZh: `变量 ${key}`, nameEn: `Variable ${key}`,
descriptionZh: `变量 ${key} 的中文说明`, descriptionEn: `English description for ${key}`,
category: 'server', scopes: ['*'], example: `{${key}}`, sampleValue: `value-${key}`,
templateAllowed: true, conditionAllowed: true, ...overrides,
};
}
describe('RichTextEditor', () => {
it('preserves literal technical strings through typing, line breaks, and plain paste when opted in', () => {
const changes = vi.fn();
function Harness() {
const [value, setValue] = useState('{player.name} == "Alex"');
return <><RichTextEditor value={value} preservePlainText onChange={(next) => { changes(next); setValue(next); }} />
<output data-testid="literal-value">{value}</output></>;
}
render(<Harness />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
replaceEditorDomText(editor, '{player.name} == "Alex" && ready');
fireEvent.input(editor, { data: ' && ready', inputType: 'insertText' });
expect(changes).toHaveBeenLastCalledWith('{player.name} == "Alex" && ready');
fireEvent.keyDown(editor, { key: 'Enter' });
expect(changes).toHaveBeenLastCalledWith('{player.name} == "Alex" && ready\n');
fireEvent.paste(editor, { clipboardData: { getData: (type: string) => type === 'text/plain' ? 'minecraft:diamond_sword' : '' } });
expect(screen.getByTestId('literal-value').textContent).toBe('{player.name} == "Alex" && ready\nminecraft:diamond_sword');
expect(changes.mock.calls.every(([next]) => !(next as string).startsWith(RICH_TEXT_PREFIX))).toBe(true);
});
it('retains the previous encoded-on-edit behavior unless preservePlainText is enabled', () => {
const changes = vi.fn();
render(<RichTextEditor value="plain" onChange={changes} />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
replaceEditorDomText(editor, 'plain text');
fireEvent.input(editor, { data: ' text', inputType: 'insertText' });
const next = changes.mock.calls.at(-1)?.[0] as string;
expect(next.startsWith(RICH_TEXT_PREFIX)).toBe(true);
expect(richTextPlainText(next)).toBe('plain text');
});
it('upgrades a preserved string when formatting is added and never silently flattens it again', async () => {
function Harness() {
const [value, setValue] = useState('Styled');
return <><RichTextEditor value={value} onChange={setValue} preservePlainText />
<output data-testid="styled-literal-value">{value}</output></>;
}
const user = userEvent.setup();
render(<Harness />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
selectEditorText(editor, 0, 6);
const bold = within(selectionToolbar()).getByTitle('粗体 / Bold');
await user.click(bold);
let next = screen.getByTestId('styled-literal-value').textContent ?? '';
expect(next.startsWith(RICH_TEXT_PREFIX)).toBe(true);
expect(decodeRichText(next)[0]).toMatchObject({ text: 'Styled', bold: true });
await user.click(bold);
next = screen.getByTestId('styled-literal-value').textContent ?? '';
expect(next.startsWith(RICH_TEXT_PREFIX)).toBe(true);
expect(decodeRichText(next)[0].bold).toBe(false);
});
it('keeps an existing unstyled rich document encoded with preservePlainText', () => {
const changes = vi.fn();
render(<RichTextEditor value={encodeRichText(decodeRichText('already rich'))} onChange={changes} preservePlainText />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
replaceEditorDomText(editor, 'already rich!');
fireEvent.input(editor, { data: '!', inputType: 'insertText' });
expect((changes.mock.calls.at(-1)?.[0] as string).startsWith(RICH_TEXT_PREFIX)).toBe(true);
});
it.each([
['所选文字悬停说明 / Selected text hover details', 'More information'],
['所选文字插入内容 / Selected text insertion', 'insert value'],
['所选文字点击动作 / Selected text click action', 'copy_to_clipboard'],
['所选文字颜色 / Selected text color', '#ff0000'],
['浮点变量保留位数 / Floating-point fraction digits', '4'],
])('encodes preserved text when %s becomes non-default', (label, targetValue) => {
const changes = vi.fn();
render(<RichTextEditor value="literal" onChange={changes} preservePlainText />);
selectEditorText(screen.getByRole('textbox', { name: '富文本内容 / Rich text content' }), 0, 7);
fireEvent.change(screen.getByLabelText(label), { target: { value: targetValue } });
expect(changes).toHaveBeenCalledTimes(1);
expect((changes.mock.calls[0][0] as string).startsWith(RICH_TEXT_PREFIX)).toBe(true);
expect(richTextPlainText(changes.mock.calls[0][0] as string)).toBe('literal');
});
it('encodes an explicitly configured sender even if it matches the displayed default', () => {
const changes = vi.fn();
render(<RichTextEditor value="literal" onChange={changes} preservePlainText />);
fireEvent.click(screen.getByLabelText('使用全局默认发送者 / Use global default sender'));
const next = changes.mock.calls.at(-1)?.[0] as string;
expect(next.startsWith(RICH_TEXT_PREFIX)).toBe(true);
expect(decodeRichTextDocument(next).sender?.text).toBe('XFEServerManager');
});
it('makes read-only content and every mutation control non-editable while still allowing copy', () => {
const changes = vi.fn();
const value = encodeRichText([{ ...decodeRichText('Read only')[0], clickAction: 'copy_to_clipboard', clickValue: 'copied' }], {
visible: true, text: 'Custom', color: '#abcdef', bold: true, italic: false, underlined: false, strikethrough: false,
});
const { container } = render(<RichTextEditor value={value} onChange={changes} readOnly />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
expect(editor).toHaveAttribute('contenteditable', 'false');
expect(editor).toHaveAttribute('aria-readonly', 'true');
selectEditorText(editor, 0, 9);
for (const control of container.querySelectorAll('button,input,select,textarea')) expect(control).toBeDisabled();
fireEvent.change(screen.getByLabelText('浮点变量保留位数 / Floating-point fraction digits'), { target: { value: '6' } });
fireEvent.change(screen.getByLabelText('所选文字悬停说明 / Selected text hover details'), { target: { value: 'forged' } });
fireEvent.keyDown(editor, { key: 'b', ctrlKey: true });
fireEvent.keyDown(editor, { key: 'Enter' });
const clipboardData = { getData: vi.fn(() => 'injected'), setData: vi.fn() };
expect(fireEvent.paste(editor, { clipboardData })).toBe(false);
expect(clipboardData.getData).not.toHaveBeenCalled();
fireEvent.copy(editor, { clipboardData });
expect(clipboardData.setData).toHaveBeenCalledWith('text/plain', 'Read only');
replaceEditorDomText(editor, 'forged DOM');
fireEvent.input(editor, { data: 'forged DOM', inputType: 'insertText' });
expect(editor).toHaveTextContent('Read only');
expect(changes).not.toHaveBeenCalled();
});
it('cancels pending IME commits when changed to read-only and allows a later editable session', async () => {
const changes = vi.fn();
const { rerender } = render(<RichTextEditor value="原文" onChange={changes} preservePlainText />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
fireEvent.compositionStart(editor, { data: '' });
replaceEditorDomText(editor, '原文修改');
fireEvent.compositionEnd(editor, { data: '修改' });
rerender(<RichTextEditor value="原文" onChange={changes} preservePlainText readOnly />);
await new Promise((resolve) => setTimeout(resolve, 5));
expect(changes).not.toHaveBeenCalled();
expect(editor).toHaveTextContent('原文');
fireEvent(editor, new InputEvent('beforeinput', { bubbles: true, cancelable: true, data: '!', inputType: 'insertText' }));
fireEvent.compositionStart(editor, { data: '' });
replaceEditorDomText(editor, '不应保存');
fireEvent.compositionEnd(editor, { data: '不应保存' });
expect(changes).not.toHaveBeenCalled();
expect(editor).toHaveTextContent('原文');
rerender(<RichTextEditor value="原文" onChange={changes} preservePlainText />);
replaceEditorDomText(editor, '原文!');
fireEvent.input(editor, { data: '!', inputType: 'insertText' });
expect(changes).toHaveBeenLastCalledWith('原文!');
});
it('upgrades legacy text and preserves Minecraft text styles and events', async () => {
const change = vi.fn();
const user = userEvent.setup();
render(<RichTextEditor value="Welcome {player}" onChange={change} />);
expect(richTextPlainText('Welcome {player}')).toBe('Welcome {player}');
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
selectEditorText(editor, 0, 7);
await user.click(within(selectionToolbar()).getByTitle('粗体 / Bold'));
const encoded = change.mock.calls.at(-1)?.[0] as string;
const decoded = decodeRichText(encoded);
expect(richTextPlainText(encoded)).toBe('Welcome {player}');
expect(decoded).toHaveLength(2);
expect(decoded[0]).toMatchObject({ text: 'Welcome', bold: true, color: '#ffffff' });
expect(decoded[1]).toMatchObject({ text: ' {player}', bold: false, color: '#ffffff' });
expect(decodeRichTextDocument(encoded).sender).toBeUndefined();
});
it('defaults message numbers to two decimals and persists a custom precision', async () => {
function Harness() {
const [value, setValue] = useState('X={player.x}');
return <><RichTextEditor value={value} onChange={setValue} />
<output data-testid="precision-value">{value}</output></>;
}
const user = userEvent.setup();
render(<Harness />);
const precision = screen.getByLabelText('浮点变量保留位数 / Floating-point fraction digits');
expect(precision).toHaveValue('2');
await user.selectOptions(precision, '4');
expect(decodeRichTextDocument(screen.getByTestId('precision-value').textContent ?? '').numberPrecision)
.toBe(4);
});
it('inherits the server default until the sender is explicitly customized', async () => {
function Harness() {
const [value, setValue] = useState('News');
return <><RichTextEditor value={value} onChange={setValue} defaultSenderName="Network Default" />
<output data-testid="rich-value">{value}</output></>;
}
const user = userEvent.setup();
render(<Harness />);
expect(screen.getByLabelText('Minecraft rich text preview')).toHaveTextContent('[Network Default] News');
expect(screen.getByLabelText('使用全局默认发送者 / Use global default sender')).toBeChecked();
await user.click(screen.getByLabelText('使用全局默认发送者 / Use global default sender'));
const sender = screen.getByLabelText('发送者名称 / Sender name');
await user.clear(sender);
await user.type(sender, 'Server News');
const document = decodeRichTextDocument(screen.getByTestId('rich-value').textContent ?? '');
expect(document.sender).toMatchObject({ visible: true, text: 'Server News', color: '#71e6a1' });
await user.click(screen.getByLabelText('使用全局默认发送者 / Use global default sender'));
const inherited = decodeRichTextDocument(screen.getByTestId('rich-value').textContent ?? '');
expect(inherited.sender).toBeUndefined();
expect(screen.getByLabelText('Minecraft rich text preview')).toHaveTextContent('[Network Default] News');
});
it('preserves an explicitly hidden sender while editing message content', async () => {
const hidden = {
visible: false, text: 'Hidden sender', color: '#71e6a1', bold: false,
italic: false, underlined: false, strikethrough: false,
};
function Harness() {
const [value, setValue] = useState(encodeRichText(decodeRichText('News'), hidden));
return <><RichTextEditor value={value} onChange={setValue} />
<output data-testid="hidden-value">{value}</output></>;
}
const user = userEvent.setup();
render(<Harness />);
expect(screen.getByLabelText('使用全局默认发送者 / Use global default sender')).not.toBeChecked();
expect(screen.getByLabelText('Minecraft rich text preview')).not.toHaveTextContent('[Hidden sender]');
selectEditorText(screen.getByRole('textbox', { name: '富文本内容 / Rich text content' }), 0, 4);
await user.click(within(selectionToolbar()).getByTitle('粗体 / Bold'));
expect(decodeRichTextDocument(screen.getByTestId('hidden-value').textContent ?? '').sender)
.toMatchObject({ visible: false, text: 'Hidden sender' });
});
it('previews a blank explicit sender with the same global fallback as the server', () => {
const explicitBlank = {
visible: true, text: ' ', color: '#71e6a1', bold: false,
italic: false, underlined: false, strikethrough: false,
};
render(<RichTextEditor value={encodeRichText(decodeRichText('News'), explicitBlank)}
defaultSenderName="Network Default" onChange={vi.fn()} />);
expect(screen.getByLabelText('Minecraft rich text preview')).toHaveTextContent('[Network Default] News');
});
it('round-trips safe URL and hover details', () => {
const encoded = encodeRichText([{
text: 'Rules', color: '#71e6a1', bold: false, italic: true,
underlined: true, strikethrough: false, obfuscated: false, insertion: 'rules', clickAction: 'open_url',
clickValue: 'https://example.com/rules', hoverText: 'Open server rules',
}]);
expect(decodeRichText(encoded)[0]).toMatchObject({
text: 'Rules', italic: true, underlined: true,
clickAction: 'open_url', clickValue: 'https://example.com/rules',
hoverText: 'Open server rules', insertion: 'rules',
});
});
it('loads old multi-run envelopes and applies formatting across run boundaries without data loss', async () => {
const alpha = { ...decodeRichText('Alpha')[0], text: 'Alpha', bold: true, color: '#ff5555' };
const beta = { ...decodeRichText('Beta')[0], text: 'Beta', clickAction: 'open_url' as const,
clickValue: 'https://example.com', hoverText: 'Beta link' };
const initial = encodeRichText([
alpha,
beta,
]);
function Harness() {
const [value, setValue] = useState(initial);
return <><RichTextEditor value={value} onChange={setValue} /><output data-testid="rich-value">{value}</output></>;
}
const user = userEvent.setup();
render(<Harness />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
expect(editor).toHaveTextContent('AlphaBeta');
expect(screen.getByTestId('rich-value')).toHaveTextContent(initial);
selectEditorText(editor, 2, 7);
await user.click(within(selectionToolbar()).getByTitle('下划线 / Underline'));
const decoded = decodeRichText(screen.getByTestId('rich-value').textContent ?? '');
expect(decoded.map((item) => item.text).join('')).toBe('AlphaBeta');
expect(decoded).toEqual([
expect.objectContaining({ text: 'Al', bold: true, underlined: false, color: '#ff5555' }),
expect.objectContaining({ text: 'pha', bold: true, underlined: true, color: '#ff5555' }),
expect.objectContaining({ text: 'Be', underlined: true, clickAction: 'open_url',
clickValue: 'https://example.com', hoverText: 'Beta link' }),
expect.objectContaining({ text: 'ta', underlined: false, clickAction: 'open_url',
clickValue: 'https://example.com', hoverText: 'Beta link' }),
]);
});
it('enforces the aggregate visible-text limit and rejects edits that exceed it', async () => {
const base = decodeRichText('')[0];
const initial = encodeRichText(Array.from({ length: 5 }, () => ({ ...base, text: 'x'.repeat(1638) })));
const change = vi.fn();
const user = userEvent.setup();
render(<RichTextEditor value={initial} onChange={change} />);
expect(screen.getByText((content) => content.includes(`文本字符 / Text characters: 8190 / ${MAX_RICH_TEXT_CHARACTERS}`)))
.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /插入变量 \/ Insert variable/ }));
await user.click(screen.getByRole('button', { name: /插入变量 \{player\}/ }));
expect(change).not.toHaveBeenCalled();
expect(screen.getByRole('alert')).toHaveTextContent('文本总长度不能超过 8192 个字符');
});
it('rejects documents whose bounded auxiliary fields exceed the server encoded-size limit', () => {
const base = decodeRichText('x')[0];
const value = encodeRichText(Array.from({ length: 32 }, () => ({
...base,
insertion: 'i'.repeat(1024),
clickAction: 'copy_to_clipboard' as const,
clickValue: 'c'.repeat(1024),
hoverText: 'h'.repeat(1024),
})));
expect(value.length).toBeGreaterThan(MAX_RICH_TEXT_ENCODED_CHARACTERS);
expect(richTextIsValid(value)).toBe(false);
render(<RichTextEditor value={value} onChange={vi.fn()} />);
expect(screen.getByRole('alert')).toHaveTextContent('富文本编码不能超过 65536 个字符');
});
it('flags unsafe click targets and command values', () => {
const base = decodeRichText('')[0];
const value = encodeRichText([
{ ...base, text: 'Website', clickAction: 'open_url', clickValue: 'ftp://example.com' },
{ ...base, text: 'Command', clickAction: 'run_command', clickValue: 'say hello' },
]);
render(<RichTextEditor value={value} onChange={vi.fn()} />);
expect(richTextIsValid(value)).toBe(false);
expect(screen.getByRole('alert')).toHaveTextContent('http:// 或 https://');
expect(screen.getByRole('alert')).toHaveTextContent('必须以 / 开头');
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
selectEditorText(editor, 0, 'Website'.length);
expect(screen.getByLabelText('所选文字点击动作 / Selected text click action')).toHaveValue('open_url');
expect(screen.getByLabelText('所选动作内容 / Selected action value')).toHaveAttribute('aria-invalid', 'true');
selectEditorText(editor, 'Website'.length, 'WebsiteCommand'.length);
expect(screen.getByLabelText('所选文字点击动作 / Selected text click action')).toHaveValue('run_command');
expect(screen.getByLabelText('所选动作内容 / Selected action value')).toHaveAttribute('aria-invalid', 'true');
});
it('applies every visual style and interaction field to only the selected text', async () => {
function Harness() {
const [value, setValue] = useState('Format rest');
return <><RichTextEditor value={value} onChange={setValue} /><output data-testid="rich-value">{value}</output></>;
}
const user = userEvent.setup();
render(<Harness />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
selectEditorText(editor, 0, 6);
for (const title of ['粗体 / Bold', '斜体 / Italic', '下划线 / Underline',
'删除线 / Strikethrough', '随机字符 / Obfuscated']) {
await user.click(within(selectionToolbar()).getByTitle(title));
}
fireEvent.change(screen.getByLabelText('所选文字颜色 / Selected text color'), {
target: { value: '#55ffff' },
});
await user.selectOptions(screen.getByLabelText('所选文字点击动作 / Selected text click action'),
'copy_to_clipboard');
await user.type(screen.getByLabelText('所选动作内容 / Selected action value'), 'copied value');
await user.type(screen.getByLabelText('所选文字悬停说明 / Selected text hover details'), 'Hover help');
await user.type(screen.getByLabelText('所选文字插入内容 / Selected text insertion'), 'inserted');
const decoded = decodeRichText(screen.getByTestId('rich-value').textContent ?? '');
expect(decoded[0]).toMatchObject({
text: 'Format', color: '#55ffff', bold: true, italic: true, underlined: true,
strikethrough: true, obfuscated: true, clickAction: 'copy_to_clipboard',
clickValue: 'copied value', hoverText: 'Hover help', insertion: 'inserted',
});
expect(decoded[1]).toMatchObject({ text: ' rest', bold: false, clickAction: 'none' });
});
it('supports keyboard formatting, line breaks, and ordinary contenteditable typing', async () => {
function Harness() {
const [value, setValue] = useState('Key');
return <><RichTextEditor value={value} onChange={setValue} /><output data-testid="rich-value">{value}</output></>;
}
const user = userEvent.setup();
render(<Harness />);
let editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
editor.focus();
selectEditorText(editor, 0, 3);
expect(fireEvent.keyDown(editor, { key: 'b', ctrlKey: true })).toBe(false);
expect(decodeRichText(screen.getByTestId('rich-value').textContent ?? '')[0]).toMatchObject({
text: 'Key', bold: true,
});
editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
selectEditorText(editor, 3, 3);
expect(fireEvent.keyDown(editor, { key: 'Enter' })).toBe(false);
const echoedBeforeInput = new InputEvent('beforeinput', {
bubbles: true, cancelable: true, inputType: 'insertParagraph',
});
expect(fireEvent(editor, echoedBeforeInput)).toBe(false);
fireEvent.input(editor, { inputType: 'insertParagraph' });
expect(richTextPlainText(screen.getByTestId('rich-value').textContent ?? '')).toBe('Key\n');
await user.type(editor, 'next', { skipClick: true });
expect(richTextPlainText(screen.getByTestId('rich-value').textContent ?? '')).toBe('Key\nnext');
editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
selectEditorText(editor, 'Key\nnext'.length, 'Key\nnext'.length);
expect(fireEvent.keyDown(editor, { key: 'Enter', shiftKey: true })).toBe(false);
expect(richTextPlainText(screen.getByTestId('rich-value').textContent ?? '')).toBe('Key\nnext\n');
});
it('keeps IME composition browser-owned, commits once at compositionend, and restores the caret', () => {
const changes = vi.fn();
function Harness() {
const [value, setValue] = useState('欢迎');
return <><RichTextEditor value={value} onChange={(next) => { changes(next); setValue(next); }} />
<output data-testid="ime-value">{value}</output></>;
}
render(<Harness />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
editor.focus();
selectEditorText(editor, 2, 2);
fireEvent.compositionStart(editor, { data: '' });
replaceEditorDomText(editor, '欢迎n');
fireEvent.compositionUpdate(editor, { data: 'n' });
fireEvent.input(editor, { data: 'n', inputType: 'insertCompositionText', isComposing: true });
replaceEditorDomText(editor, '欢迎ni');
fireEvent.compositionUpdate(editor, { data: 'ni' });
fireEvent.input(editor, { data: 'ni', inputType: 'insertCompositionText', isComposing: true });
expect(changes).not.toHaveBeenCalled();
expect(editor).toHaveTextContent('欢迎ni');
replaceEditorDomText(editor, '欢迎你');
fireEvent.compositionEnd(editor, { data: '你' });
// Chromium/WebKit may emit one final non-composing input after compositionend.
fireEvent.input(editor, { data: '你', inputType: 'insertText', isComposing: false });
expect(changes).toHaveBeenCalledTimes(1);
expect(richTextPlainText(screen.getByTestId('ime-value').textContent ?? '')).toBe('欢迎你');
expect(editorSelectionOffsets(editor)).toEqual({ start: 3, end: 3 });
});
it('leaves Enter to the IME candidate window without inserting a line break', () => {
const changes = vi.fn();
render(<RichTextEditor value="候选" onChange={changes} />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
editor.focus();
selectEditorText(editor, 2, 2);
fireEvent.compositionStart(editor, { data: '' });
expect(fireEvent.keyDown(editor, { key: 'Enter', isComposing: true, keyCode: 229 })).toBe(true);
expect(editor).toHaveTextContent('候选');
expect(changes).not.toHaveBeenCalled();
fireEvent.compositionEnd(editor, { data: '' });
fireEvent.input(editor, { data: '', inputType: 'insertCompositionText' });
expect(editor).toHaveTextContent('候选');
expect(changes).not.toHaveBeenCalled();
});
it('commits a completed composition through the zero-delay fallback when no tail input arrives', async () => {
const changes = vi.fn();
function Harness() {
const [value, setValue] = useState('中');
return <><RichTextEditor value={value} onChange={(next) => { changes(next); setValue(next); }} />
<output data-testid="ime-fallback-value">{value}</output></>;
}
render(<Harness />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
editor.focus();
selectEditorText(editor, 1, 1);
fireEvent.compositionStart(editor, { data: '' });
replaceEditorDomText(editor, '中文');
fireEvent.compositionEnd(editor, { data: '文' });
expect(changes).not.toHaveBeenCalled();
await waitFor(() => expect(changes).toHaveBeenCalledTimes(1));
expect(richTextPlainText(screen.getByTestId('ime-fallback-value').textContent ?? '')).toBe('中文');
expect(editorSelectionOffsets(editor)).toEqual({ start: 2, end: 2 });
});
it('accepts two legitimate consecutive full-width punctuation inputs from a soft keyboard', () => {
const changes = vi.fn();
function Harness() {
const [value, setValue] = useState('标点');
return <><RichTextEditor value={value} onChange={(next) => { changes(next); setValue(next); }} />
<output data-testid="soft-punctuation-value">{value}</output></>;
}
render(<Harness />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
editor.focus();
selectEditorText(editor, 2, 2);
for (const text of ['标点,', '标点,,']) {
expect(fireEvent(editor, new InputEvent('beforeinput', {
bubbles: true, cancelable: true, data: ',', inputType: 'insertText',
}))).toBe(true);
replaceEditorDomText(editor, text);
fireEvent.input(editor, { data: ',', inputType: 'insertText' });
}
expect(changes).toHaveBeenCalledTimes(2);
expect(richTextPlainText(screen.getByTestId('soft-punctuation-value').textContent ?? '')).toBe('标点,,');
expect(editor).toHaveTextContent('标点,,');
});
it('gives a controlled external value priority over an in-progress composition', () => {
const changes = vi.fn();
function Harness() {
const [value, setValue] = useState('旧值');
return <><button type="button" onClick={() => setValue('外部新值')}>Apply external value</button>
<RichTextEditor value={value} onChange={(next) => { changes(next); setValue(next); }} />
<output data-testid="external-value">{value}</output></>;
}
render(<Harness />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
editor.focus();
selectEditorText(editor, 2, 2);
fireEvent.compositionStart(editor, { data: '' });
replaceEditorDomText(editor, '旧值ji');
fireEvent.input(editor, { data: 'ji', inputType: 'insertCompositionText', isComposing: true });
fireEvent.click(screen.getByRole('button', { name: 'Apply external value' }));
expect(editor).toHaveTextContent('外部新值');
expect(screen.getByTestId('external-value')).toHaveTextContent('外部新值');
// A stale compositionend dispatched after the controlled update cannot restore
// the old base or its provisional DOM.
replaceEditorDomText(editor, '旧值机');
fireEvent.compositionEnd(editor, { data: '机' });
expect(changes).not.toHaveBeenCalled();
expect(editor).toHaveTextContent('外部新值');
expect(richTextPlainText(screen.getByTestId('external-value').textContent ?? '')).toBe('外部新值');
});
it.each([
['ASCII punctuation', ','],
['中文全角标点', ','],
])('deduplicates a trailing composition input for %s', (_label, punctuation) => {
const changes = vi.fn();
function Harness() {
const [value, setValue] = useState('A');
return <><RichTextEditor value={value} onChange={(next) => { changes(next); setValue(next); }} />
<output data-testid="punctuation-value">{value}</output></>;
}
render(<Harness />);
let editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
editor.focus();
selectEditorText(editor, 1, 1);
fireEvent.compositionStart(editor, { data: '' });
replaceEditorDomText(editor, `A${punctuation}`);
fireEvent.compositionEnd(editor, { data: punctuation });
expect(changes).not.toHaveBeenCalled();
// Emulate a browser that has already inserted the compositionend echo before
// dispatching its trailing input event. The editor must roll this duplicate back.
replaceEditorDomText(editor, `A${punctuation}${punctuation}`);
fireEvent.input(editor, { data: punctuation, inputType: 'insertText', isComposing: false });
editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
expect(changes).toHaveBeenCalledTimes(1);
expect(richTextPlainText(screen.getByTestId('punctuation-value').textContent ?? ''))
.toBe(`A${punctuation}`);
expect(editor).toHaveTextContent(`A${punctuation}`);
expect(editorSelectionOffsets(editor)).toEqual({ start: 2, end: 2 });
});
it('keeps two consecutive beforeinput-only line breaks without waiting for keydown', () => {
function Harness() {
const [value, setValue] = useState('Line');
return <><RichTextEditor value={value} onChange={setValue} />
<output data-testid="beforeinput-value">{value}</output></>;
}
render(<Harness />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
editor.focus();
selectEditorText(editor, 4, 4);
const beforeInput = new InputEvent('beforeinput', {
bubbles: true, cancelable: true, inputType: 'insertLineBreak',
});
expect(fireEvent(editor, beforeInput)).toBe(false);
const secondBeforeInput = new InputEvent('beforeinput', {
bubbles: true, cancelable: true, inputType: 'insertLineBreak',
});
expect(fireEvent(editor, secondBeforeInput)).toBe(false);
expect(richTextPlainText(screen.getByTestId('beforeinput-value').textContent ?? '')).toBe('Line\n\n');
expect(editorSelectionOffsets(editor)).toEqual({ start: 6, end: 6 });
});
it('commits one ordinary symbol from an empty editor exactly once', () => {
const changes = vi.fn();
function Harness() {
const [value, setValue] = useState('');
return <><RichTextEditor value={value} onChange={(next) => { changes(next); setValue(next); }} />
<output data-testid="empty-symbol-value">{value}</output></>;
}
render(<Harness />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
editor.focus();
fireEvent(editor, new InputEvent('beforeinput', {
bubbles: true, cancelable: true, data: '.', inputType: 'insertText',
}));
replaceEditorDomText(editor, '.');
fireEvent.input(editor, { data: '.', inputType: 'insertText' });
// A duplicate notification for the same DOM mutation must be a no-op.
fireEvent.input(editor, { data: '.', inputType: 'insertText' });
expect(changes).toHaveBeenCalledTimes(1);
expect(richTextPlainText(screen.getByTestId('empty-symbol-value').textContent ?? '')).toBe('.');
expect(editor).toHaveTextContent('.');
expect([...editor.childNodes]).toHaveLength(1);
expect(editor.firstChild).toBeInstanceOf(HTMLSpanElement);
});
it('normalizes one symbol typed at a differently-styled run boundary without duplication or bare nodes', () => {
const left = { ...decodeRichText('左')[0], text: '左', color: '#ff5555', bold: true };
const right = { ...decodeRichText('右')[0], text: '右', color: '#55ffff', italic: true };
const changes = vi.fn();
function Harness() {
const [value, setValue] = useState(encodeRichText([left, right]));
return <><RichTextEditor value={value} onChange={(next) => { changes(next); setValue(next); }} />
<output data-testid="boundary-symbol-value">{value}</output></>;
}
render(<Harness />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
editor.focus();
selectEditorText(editor, 1, 1);
const rightRun = editor.querySelectorAll('span')[1];
editor.insertBefore(document.createTextNode('。'), rightRun);
selectEditorText(editor, 2, 2);
fireEvent.input(editor, { data: '。', inputType: 'insertText' });
fireEvent.input(editor, { data: '。', inputType: 'insertText' });
const value = screen.getByTestId('boundary-symbol-value').textContent ?? '';
expect(changes).toHaveBeenCalledTimes(1);
expect(richTextPlainText(value)).toBe('左。右');
expect(decodeRichText(value)).toEqual([
expect.objectContaining({ text: '左。', color: '#ff5555', bold: true }),
expect.objectContaining({ text: '右', color: '#55ffff', italic: true }),
]);
expect([...editor.childNodes].every((node) => node instanceof HTMLSpanElement)).toBe(true);
});
it('rebuilds canonical styled spans after a cancelled composition without emitting a change', () => {
const left = { ...decodeRichText('A')[0], text: 'A', bold: true };
const right = { ...decodeRichText('B')[0], text: 'B', italic: true };
const changes = vi.fn();
render(<RichTextEditor value={encodeRichText([left, right])} onChange={changes} />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
editor.focus();
selectEditorText(editor, 1, 1);
fireEvent.compositionStart(editor, { data: '' });
replaceEditorDomText(editor, 'AB', 1);
fireEvent.compositionEnd(editor, { data: '' });
fireEvent.input(editor, { data: '', inputType: 'insertCompositionText' });
expect(changes).not.toHaveBeenCalled();
expect(editor.querySelectorAll(':scope > span')).toHaveLength(2);
expect(editor.querySelectorAll(':scope > span')[0]).toHaveStyle({ fontWeight: '700' });
expect(editor.querySelectorAll(':scope > span')[1]).toHaveStyle({ fontStyle: 'italic' });
expect(editorSelectionOffsets(editor)).toEqual({ start: 1, end: 1 });
});
it('copies and pastes selected rich runs with all style and interaction metadata', () => {
const first = {
...decodeRichText('AB')[0], text: 'AB', color: '#ff5555', bold: true, underlined: true,
insertion: 'insert-alpha', clickAction: 'copy_to_clipboard' as const,
clickValue: 'copy-alpha', hoverText: 'Alpha details',
};
const second = {
...decodeRichText('CD')[0], text: 'CD', color: '#55ffff', italic: true, strikethrough: true,
insertion: 'insert-beta', clickAction: 'open_url' as const,
clickValue: 'https://example.com/details', hoverText: 'Beta details',
};
const changes = vi.fn();
function Harness() {
const [value, setValue] = useState(encodeRichText([first, second, { ...decodeRichText(' target')[0] }]));
return <><RichTextEditor value={value} onChange={(next) => { changes(next); setValue(next); }} />
<output data-testid="rich-clipboard-value">{value}</output></>;
}
render(<Harness />);
const data = new Map<string, string>();
const clipboardData = {
getData: vi.fn((type: string) => data.get(type) ?? ''),
setData: vi.fn((type: string, content: string) => { data.set(type, content); }),
};
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
editor.focus();
selectEditorText(editor, 1, 3);
expect(fireEvent.copy(editor, { clipboardData })).toBe(false);
expect(data.get('text/plain')).toBe('BC');
const envelope = JSON.parse(data.get(RICH_TEXT_CLIPBOARD_MIME) ?? '{}') as Record<string, unknown>;
expect(envelope).toEqual({ version: 1, id: expect.stringMatching(/^[0-9a-f]{32}$/) });
expect(data.get(RICH_TEXT_CLIPBOARD_MIME)).not.toContain('copy-alpha');
selectEditorText(editor, 5, 11);
expect(fireEvent.paste(editor, { clipboardData })).toBe(false);
fireEvent.input(editor, { inputType: 'insertFromPaste' });
const decoded = decodeRichText(screen.getByTestId('rich-clipboard-value').textContent ?? '');
expect(changes).toHaveBeenCalledTimes(1);
expect(decoded.map((item) => item.text).join('')).toBe('ABCD BC');
expect(decoded.slice(-2)).toEqual([
expect.objectContaining({ ...first, text: 'B' }),
expect.objectContaining({ ...second, text: 'C' }),
]);
});
it('leaves copy and paste to the active IME while composition text is provisional', () => {
const clipboardData = { getData: vi.fn(() => 'ignored'), setData: vi.fn() };
const changes = vi.fn();
render(<RichTextEditor value="正在" onChange={changes} />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
editor.focus();
selectEditorText(editor, 2, 2);
fireEvent.compositionStart(editor, { data: '' });
expect(fireEvent.copy(editor, { clipboardData })).toBe(true);
expect(fireEvent.paste(editor, { clipboardData })).toBe(true);
expect(clipboardData.setData).not.toHaveBeenCalled();
expect(clipboardData.getData).not.toHaveBeenCalled();
fireEvent.compositionEnd(editor, { data: '' });
expect(changes).not.toHaveBeenCalled();
});
it.each([
['mismatched plain text', encodeRichText([{ ...decodeRichText('forged')[0], text: 'forged', bold: true }])],
['forged run-command metadata', encodeRichText([{
...decodeRichText('safe')[0], text: 'safe', bold: true,
clickAction: 'run_command' as const, clickValue: '/op @a',
}])],
])('falls back to safe plain text for %s in a custom clipboard payload', (_label, customPayload) => {
function Harness() {
const [value, setValue] = useState('Replace');
return <><RichTextEditor value={value} onChange={setValue} />
<output data-testid="untrusted-clipboard-value">{value}</output></>;
}
render(<Harness />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
selectEditorText(editor, 0, 7);
fireEvent.paste(editor, { clipboardData: {
getData: (type: string) => type === RICH_TEXT_CLIPBOARD_MIME ? customPayload : 'safe',
} });
const decoded = decodeRichText(screen.getByTestId('untrusted-clipboard-value').textContent ?? '');
expect(decoded).toEqual([expect.objectContaining({
text: 'safe', bold: false, clickAction: 'none', clickValue: '', hoverText: '', insertion: '',
})]);
});
it('replaces the current selection with custom variables and deduplicates defaults', async () => {
function Harness() {
const [value, setValue] = useState('Hello name');
return <><RichTextEditor value={value} onChange={setValue}
variables={['{args.name}', '{player}']} /><output data-testid="rich-value">{value}</output></>;
}
const user = userEvent.setup();
render(<Harness />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
selectEditorText(editor, 6, 10);
await user.click(screen.getByRole('button', { name: /插入变量 \/ Insert variable/ }));
expect(screen.getAllByRole('button', { name: /插入变量 \{player\}/ })).toHaveLength(1);
await user.click(screen.getByRole('button', { name: /插入变量 \{args\.name\}/ }));
expect(richTextPlainText(screen.getByTestId('rich-value').textContent ?? ''))
.toBe('Hello {args.name}');
});
it('uses a server catalog as authoritative, exposes 100+ searchable entries, and omits unsafe entries', async () => {
const serverCatalog = [
...Array.from({ length: 101 }, (_, index) => catalogVariable(`server.metric${index}`, index === 73 ? {
nameZh: '特别游戏速度', nameEn: 'Special game speed',
descriptionZh: '用于观察当前游戏速度的指标。',
descriptionEn: 'Metric used to observe the current game speed.',
} : {})),
catalogVariable('server.secret', { sensitive: true }),
catalogVariable('server.conditionOnly', { templateAllowed: false }),
];
function Harness() {
const [value, setValue] = useState('Speed: ');
return <><RichTextEditor value={value} onChange={setValue} variableCatalog={serverCatalog} />
<output data-testid="catalog-value">{value}</output></>;
}
const user = userEvent.setup();
render(<Harness />);
const open = screen.getByRole('button', { name: /Search 101/ });
await user.click(open);
const dialog = screen.getByRole('dialog', { name: '插入变量 / Insert variable' });
expect(dialog.parentElement?.parentElement).toBe(document.body);
expect(within(dialog).queryByText('{player}')).not.toBeInTheDocument();
expect(within(dialog).queryByText('{server.secret}')).not.toBeInTheDocument();
await user.type(within(dialog).getByRole('searchbox', { name: '搜索变量 / Search variables' }), '特别游戏速度');
expect(within(dialog).getByText(/Showing 1 of 101/)).toBeInTheDocument();
expect(within(dialog).getByText('用于观察当前游戏速度的指标。')).toBeInTheDocument();
await user.click(within(dialog).getByRole('button', { name: /\{server\.metric73\}/ }));
expect(richTextPlainText(screen.getByTestId('catalog-value').textContent ?? ''))
.toBe('Speed: {server.metric73}');
});
it('uses a left category navigation and selects all variables by default', async () => {
const catalog = [
catalogVariable('server.online'),
catalogVariable('player.name', { category: 'player', nameZh: '玩家名称' }),
catalogVariable('world.day', { category: 'world', nameZh: '世界天数' }),
];
const user = userEvent.setup();
render(<RichTextEditor value="" onChange={vi.fn()} variableCatalog={catalog} />);
await user.click(screen.getByRole('button', { name: /插入变量 \/ Insert variable/ }));
const dialog = screen.getByRole('dialog', { name: '插入变量 / Insert variable' });
const navigation = within(dialog).getByRole('navigation', { name: '变量分类 / Variable categories' });
const all = within(navigation).getByRole('button', { name: /全部/ });
expect(all).toHaveAttribute('aria-current', 'page');
expect(within(dialog).getByText(/Showing 3 of 3/)).toBeInTheDocument();
await user.click(within(navigation).getByRole('button', { name: /玩家/ }));
expect(within(navigation).getByRole('button', { name: /玩家/ })).toHaveAttribute('aria-current', 'page');
expect(within(dialog).getByText(/Showing 1 of 3/)).toBeInTheDocument();
expect(within(dialog).getByText('{player.name}')).toBeInTheDocument();
expect(within(dialog).queryByText('{world.day}')).not.toBeInTheDocument();
});
it('documents the safe date pattern and inserts a formatted server-time variable', async () => {
function Harness() {
const [value, setValue] = useState('At ');
return <><RichTextEditor value={value} onChange={setValue} variableCatalog={[
catalogVariable('server.time:uuuu-MM-dd HH:mm:ss', {
nameZh: '完整日期时间(推荐)', nameEn: 'Full date and time (recommended)',
category: 'time', sampleValue: '2026-09-04 13:05:09', conditionAllowed: false,
formatHint: 'MM=月份,mm=分钟,HH=24 小时制。',
}),
]} /><output data-testid="time-value">{value}</output></>;
}
const user = userEvent.setup();
render(<Harness />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
await user.click(screen.getByRole('button', { name: /插入变量 \/ Insert variable/ }));
const dialog = screen.getByRole('dialog', { name: '插入变量 / Insert variable' });
expect(within(dialog).getByRole('note')).toHaveTextContent('YYYY 是周历年');
expect(within(dialog).getByText('2026-09-04 13:05:09')).toBeInTheDocument();
const formattedTime = within(dialog).getByRole('button', { name: /server\.time:uuuu-MM-dd HH:mm:ss/ });
expect(formattedTime).toHaveTextContent('MM=月份,mm=分钟');
await user.click(formattedTime);
expect(richTextPlainText(screen.getByTestId('time-value').textContent ?? ''))
.toBe('At {server.time:uuuu-MM-dd HH:mm:ss}');
await waitFor(() => expect(editor).toHaveFocus());
expect(editor.contains(window.getSelection()?.anchorNode ?? null)).toBe(true);
expect(window.getSelection()?.anchorOffset).toBe('At {server.time:uuuu-MM-dd HH:mm:ss}'.length);
});
it('merges dynamic event arguments without reintroducing the legacy fallback', () => {
const merged = mergeRichTextVariableCatalog(
[catalogVariable('server.tickRate')], ['{args.target}', '{server.tickRate}'],
);
expect(merged.map((item) => item.key)).toEqual(['server.tickRate', 'args.target']);
expect(merged.find((item) => item.key === 'args.target')).toMatchObject({
category: 'command', scopes: ['player.command_trigger'],
});
});
it('accepts only plain text from paste and automatically splits oversized style runs', () => {
function Harness() {
const [value, setValue] = useState('Replace me');
return <><RichTextEditor value={value} onChange={setValue} /><output data-testid="rich-value">{value}</output></>;
}
const { rerender } = render(<Harness />);
let editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
selectEditorText(editor, 0, 'Replace me'.length);
fireEvent.paste(editor, { clipboardData: { getData: (type: string) => type === 'text/plain'
? 'Plain only' : '<img src=x onerror=alert(1)><b>HTML</b>' } });
expect(richTextPlainText(screen.getByTestId('rich-value').textContent ?? '')).toBe('Plain only');
expect(editor.querySelector('img, b')).toBeNull();
function LongHarness() {
const [value, setValue] = useState('');
return <><RichTextEditor value={value} onChange={setValue} /><output data-testid="long-value">{value}</output></>;
}
rerender(<LongHarness />);
editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
fireEvent.paste(editor, { clipboardData: { getData: (type: string) => type === 'text/plain'
? 'x'.repeat(MAX_RICH_TEXT_SEGMENT_CHARACTERS + 2) : '<script>bad()</script>' } });
const runs = decodeRichText(screen.getByTestId('long-value').textContent ?? '');
expect(runs.map((item) => item.text.length)).toEqual([MAX_RICH_TEXT_SEGMENT_CHARACTERS, 2]);
expect(runs.map((item) => item.text).join('')).toBe('x'.repeat(MAX_RICH_TEXT_SEGMENT_CHARACTERS + 2));
});
it('rejects a formatting split that would exceed the 256-run document limit', async () => {
const initial = encodeRichText(Array.from({ length: MAX_RICH_TEXT_SEGMENTS }, (_, index) => ({
...decodeRichText('ab')[0], text: 'ab', color: `#${(index + 1).toString(16).padStart(6, '0')}`,
})));
const change = vi.fn();
const user = userEvent.setup();
render(<RichTextEditor value={initial} onChange={change} />);
const editor = screen.getByRole('textbox', { name: '富文本内容 / Rich text content' });
selectEditorText(editor, 21, 22);
await user.click(within(selectionToolbar()).getByTitle('粗体 / Bold'));
expect(change).not.toHaveBeenCalled();
expect(screen.getByRole('alert')).toHaveTextContent('富文本样式段不能超过 256 个');
});
});