// @vitest-environment jsdom import { act, fireEvent, render } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { RendererTextEditor } from '../../../src/react/text/RendererTextEditor'; import type { TextContentChange, TextEditorController, TextFormatState, } from '../../../src/react/text/types'; describe('RendererTextEditor', () => { beforeEach(() => vi.useFakeTimers()); afterEach(() => { vi.runOnlyPendingTimers(); vi.useRealTimers(); }); it('publishes a controller, format state, then content before requesting layout', () => { let controller: TextEditorController | null = null; const onControllerChange = vi.fn((next: TextEditorController | null) => { controller = next; }); const onFormatChange = vi.fn<(id: string, state: TextFormatState) => void>(); const onContentChange = vi.fn<(change: TextContentChange) => void>(); const onLayoutChange = vi.fn(); render( , ); expect(controller).not.toBeNull(); expect(onFormatChange).toHaveBeenCalledWith( 'txt', expect.objectContaining({ color: '#112233', fontname: 'Inter' }), ); act(() => controller?.execute({ command: 'replace', value: 'Edited' })); expect(onLayoutChange).not.toHaveBeenCalled(); expect(onContentChange).not.toHaveBeenCalled(); act(() => vi.advanceTimersByTime(300)); expect(onContentChange).toHaveBeenLastCalledWith({ intent: { type: 'text.updateContent', id: 'txt', content: expect.stringContaining('Edited'), target: 'text', }, history: 'record', }); expect(onLayoutChange).toHaveBeenCalledTimes(1); }); it('does not steal focus from a toolbar input when executing a command', () => { let controller: TextEditorController | null = null; render( { controller = next; }} />, ); const colorInput = document.createElement('input'); document.body.append(colorInput); colorInput.focus(); act(() => controller?.execute({ command: 'forecolor', value: '#ff0000' })); expect(document.activeElement).toBe(colorInput); colorInput.remove(); }); it('flushes pending content on blur and lets the Escape host decide how to exit', () => { let controller: TextEditorController | null = null; const onContentChange = vi.fn<(change: TextContentChange) => void>(); const onEscape = vi.fn(() => controller?.flush()); const { container } = render( { controller = next; }} onContentChange={onContentChange} onEscape={onEscape} />, ); expect(controller).not.toBeNull(); const editor = container.querySelector('.ProseMirror') as HTMLElement; act(() => controller?.execute({ command: 'replace', value: 'Blurred' })); fireEvent.blur(editor); expect(onContentChange).toHaveBeenLastCalledWith( expect.objectContaining({ intent: expect.objectContaining({ content: expect.stringContaining('Blurred') }), }), ); act(() => controller?.execute({ command: 'replace', value: 'Escaped' })); fireEvent.keyDown(editor, { key: 'Escape' }); expect(onEscape).toHaveBeenCalledTimes(1); expect(onContentChange).toHaveBeenLastCalledWith( expect.objectContaining({ intent: expect.objectContaining({ content: expect.stringContaining('Escaped') }), }), ); }); it('does not commit normalized HTML when focus changes without an edit', () => { const onContentChange = vi.fn<(change: TextContentChange) => void>(); const { container } = render( , ); fireEvent.blur(container.querySelector('.ProseMirror') as HTMLElement); expect(onContentChange).not.toHaveBeenCalled(); }); it.each(['text', 'shape'] as const)( 'keeps literal line breaks when saving a %s editor', (target) => { let controller: TextEditorController | null = null; const onContentChange = vi.fn<(change: TextContentChange) => void>(); render( { controller = next; }} onContentChange={onContentChange} />, ); act(() => controller?.execute({ command: 'insert', value: '!' })); act(() => vi.advanceTimersByTime(300)); expect(onContentChange).toHaveBeenLastCalledWith( expect.objectContaining({ intent: expect.objectContaining({ target, content: expect.stringContaining('First line
Second line'), }), }), ); }, ); it('marks ProseMirror undo content as history-neutral and releases focus on unmount', () => { let controller: TextEditorController | null = null; const onControllerChange = vi.fn((next: TextEditorController | null) => { controller = next; }); const onContentChange = vi.fn<(change: TextContentChange) => void>(); const onFocusChange = vi.fn(); const { container, unmount } = render( , ); expect(controller).not.toBeNull(); const editor = container.querySelector('.ProseMirror') as HTMLElement; act(() => controller?.execute({ command: 'replace', value: 'Changed' })); act(() => vi.advanceTimersByTime(300)); fireEvent.keyDown(editor, { key: 'z', ctrlKey: true }); act(() => vi.advanceTimersByTime(300)); expect(onContentChange).toHaveBeenLastCalledWith( expect.objectContaining({ history: 'navigate' }), ); fireEvent.focus(editor); unmount(); expect(onControllerChange).toHaveBeenLastCalledWith(null); expect(onFocusChange).toHaveBeenLastCalledWith(false); }); it('adopts an external canonical value while focused without emitting stale content', () => { const onContentChange = vi.fn<(change: TextContentChange) => void>(); const props = { elementId: 'txt', defaultColor: '#000000', defaultFontName: 'Inter', onContentChange, } as const; const { container, rerender } = render(); const editor = container.querySelector('.ProseMirror') as HTMLElement; act(() => editor.focus()); expect(document.activeElement).toBe(editor); rerender(); expect(editor.textContent).toBe('External'); act(() => vi.advanceTimersByTime(300)); expect(onContentChange).not.toHaveBeenCalled(); }); });