diff --git a/src/components/text-editor/prosemirror-adapter/menu/menu-commands.ts b/src/components/text-editor/prosemirror-adapter/menu/menu-commands.ts index 8c79a826c2..9798c2c99c 100644 --- a/src/components/text-editor/prosemirror-adapter/menu/menu-commands.ts +++ b/src/components/text-editor/prosemirror-adapter/menu/menu-commands.ts @@ -1,5 +1,11 @@ import { toggleMark, setBlockType, wrapIn, lift } from 'prosemirror-commands'; -import { Schema, MarkType, NodeType, Attrs } from 'prosemirror-model'; +import { + Schema, + MarkType, + NodeType, + Attrs, + ResolvedPos, +} from 'prosemirror-model'; import { findWrapping, liftTarget } from 'prosemirror-transform'; import { Command, EditorState, TextSelection } from 'prosemirror-state'; import { EditorMenuTypes, EditorTextLink, LevelMapping } from './types'; @@ -128,7 +134,7 @@ const createToggleMarkCommand = ( const getAttributes = ( markName: string, link: EditorTextLink -): Attrs | null => { +): Attrs | undefined => { if (markName === EditorMenuTypes.Link && link.href) { return { href: link.href, @@ -143,6 +149,19 @@ export const isExternalLink = (url: string): boolean => { return !url.startsWith(window.location.origin); }; +const checkForActiveWrap = ( + $from: ResolvedPos, + nodeType: NodeType +): boolean => { + for (let d = $from.depth; d > 0; d--) { + if ($from.node(d).type === nodeType) { + return true; + } + } + + return false; +}; + /** * Toggles or wraps a node type based on the selection and parameters. * - Toggles to paragraph if the selection is of the specified type. @@ -166,7 +185,7 @@ const toggleNodeType = ( return (state, dispatch) => { const { $from, $to } = state.selection; - const hasActiveWrap = $from.node($from.depth - 1).type === nodeType; + const hasActiveWrap = checkForActiveWrap($from, nodeType); if ( state.selection instanceof TextSelection && diff --git a/src/components/text-editor/prosemirror-adapter/plugins/table-plugin.spec.ts b/src/components/text-editor/prosemirror-adapter/plugins/table-plugin.spec.ts new file mode 100644 index 0000000000..70e9fc26d7 --- /dev/null +++ b/src/components/text-editor/prosemirror-adapter/plugins/table-plugin.spec.ts @@ -0,0 +1,171 @@ +import { Schema, NodeType } from 'prosemirror-model'; +import { Plugin } from 'prosemirror-state'; +import { + cleanupEditorView, + createCustomTestSchema, + createEditorState, + createEditorView, +} from '../../test-setup/editor-test-utils'; + +import { + getTableEditingPlugins, + getTableNodes, + createStyleAttribute, +} from './table-plugin'; +import { EditorView } from 'prosemirror-view'; + +describe('Table Plugin', () => { + let view: EditorView | null = null; + let container: HTMLElement | null = null; + + afterEach(() => { + if (view) { + cleanupEditorView(view, container); + view = null; + container = null; + } + }); + + describe('getTableEditingPlugins', () => { + it('should return an array with table editing plugin when tables are enabled', () => { + const plugins = getTableEditingPlugins(true); + + expect(plugins).toBeInstanceOf(Array); + expect(plugins.length).toBe(1); + expect(plugins[0]).toBeInstanceOf(Plugin); + }); + + it('should return an empty array when tables are disabled', () => { + const plugins = getTableEditingPlugins(false); + + expect(plugins).toBeInstanceOf(Array); + expect(plugins.length).toBe(0); + }); + + it('should create a working plugin that can be added to an editor state', () => { + const baseSchema = createCustomTestSchema({}); + const tableNodes = getTableNodes(); + let nodes = baseSchema.spec.nodes; + + for (const [name, spec] of Object.entries(tableNodes)) { + nodes = nodes.addToEnd(name, spec); + } + + const schema = new Schema({ + nodes: nodes, + marks: baseSchema.spec.marks, + }); + + const content = '

Text with table support

'; + const plugins = getTableEditingPlugins(true); + const state = createEditorState(content, schema, plugins); + + const result = createEditorView(state); + view = result.view; + container = result.container; + + expect(plugins.length).toBe(1); + expect(view.state.plugins).toContain(plugins[0]); + }); + }); + + describe('getTableNodes', () => { + it('should return an object with table node specs', () => { + const tableNodes = getTableNodes(); + + expect(tableNodes).toBeDefined(); + expect(typeof tableNodes).toBe('object'); + + expect(tableNodes.table).toBeDefined(); + expect(tableNodes.table_row).toBeDefined(); + expect(tableNodes.table_cell).toBeDefined(); + expect(tableNodes.table_header).toBeDefined(); + }); + + it('should create node specs that can be added to a schema', () => { + const baseSchema = createCustomTestSchema({}); + const tableNodes = getTableNodes(); + let nodes = baseSchema.spec.nodes; + + for (const [name, spec] of Object.entries(tableNodes)) { + nodes = nodes.addToEnd(name, spec); + } + + const schema = new Schema({ + nodes: nodes, + marks: baseSchema.spec.marks, + }); + + expect(schema.nodes.table instanceof NodeType).toBe(true); + expect(schema.nodes.table_row instanceof NodeType).toBe(true); + expect(schema.nodes.table_cell instanceof NodeType).toBe(true); + expect(schema.nodes.table_header instanceof NodeType).toBe(true); + }); + + it('should include custom cell attributes for styling', () => { + const tableNodes = getTableNodes(); + const cellSpec = tableNodes.table_cell; + + expect(tableNodes.table_cell).toBeDefined(); + expect(tableNodes.table_cell.attrs).toBeDefined(); + expect(tableNodes.table_cell.attrs.background).toBeDefined(); + expect(tableNodes.table_cell.attrs.color).toBeDefined(); + + expect(cellSpec.attrs).toHaveProperty('background'); + expect(cellSpec.attrs).toHaveProperty('color'); + }); + }); + + describe('Style attribute helper', () => { + it('should get style values from DOM elements', () => { + const backgroundAttr = createStyleAttribute('background-color'); + + const element = document.createElement('td'); + element.style.backgroundColor = 'red'; + + expect(backgroundAttr.getFromDOM(element)).toBe('red'); + }); + + it('should set style values on attributes object', () => { + const backgroundAttr = createStyleAttribute('background-color'); + const attrs: Record = {}; + + backgroundAttr.setDOMAttr('blue', attrs); + + expect(attrs.style).toBe('background-color: blue;'); + }); + + it('should append to existing style attribute when setting multiple styles', () => { + const backgroundAttr = createStyleAttribute('background-color'); + const colorAttr = createStyleAttribute('color'); + const attrs: Record = {}; + + backgroundAttr.setDOMAttr('blue', attrs); + colorAttr.setDOMAttr('white', attrs); + + expect(attrs.style).toBe('background-color: blue;color: white;'); + }); + + it('should not modify the style attribute when value is falsy', () => { + const backgroundAttr = createStyleAttribute('background-color'); + const attrs: Record = {}; + + // Test with empty string + backgroundAttr.setDOMAttr('', attrs); + expect(attrs.style).toBeUndefined(); + + // Test with null + backgroundAttr.setDOMAttr(null, attrs); + expect(attrs.style).toBeUndefined(); + + // Test with undefined + backgroundAttr.setDOMAttr(undefined, attrs); + expect(attrs.style).toBeUndefined(); + + // Test that existing style is preserved when falsy value is passed + attrs.style = 'color: red;'; + backgroundAttr.setDOMAttr('', attrs); + expect(attrs.style).toBe('color: red;'); + }); + }); +}); diff --git a/src/components/text-editor/prosemirror-adapter/plugins/table-plugin.ts b/src/components/text-editor/prosemirror-adapter/plugins/table-plugin.ts index b24408c537..2093838f64 100644 --- a/src/components/text-editor/prosemirror-adapter/plugins/table-plugin.ts +++ b/src/components/text-editor/prosemirror-adapter/plugins/table-plugin.ts @@ -9,10 +9,13 @@ export const getTableEditingPlugins = (tablesEnabled: boolean): Plugin[] => { return []; }; -const createStyleAttribute = (cssProperty: string) => ({ +export const createStyleAttribute = (cssProperty: string) => ({ default: null, getFromDOM: (dom: HTMLElement) => dom.style[cssProperty] || null, - setDOMAttr: (value: string, attrs: Record) => { + setDOMAttr: ( + value: string | null | undefined, + attrs: Record + ) => { if (value) { attrs.style = (attrs.style || '') + `${cssProperty}: ${value};`; } diff --git a/src/components/text-editor/test-setup/command-tester.spec.ts b/src/components/text-editor/test-setup/command-tester.spec.ts new file mode 100644 index 0000000000..1904bca1b0 --- /dev/null +++ b/src/components/text-editor/test-setup/command-tester.spec.ts @@ -0,0 +1,188 @@ +import { EditorView } from 'prosemirror-view'; +import { createEditorState } from './editor-state-builder'; +import { cleanupEditorView } from './editor-view-builder'; +import { + getCommandResult, + testCommand, + testCommandWithView, + createCommandTester, +} from './command-tester'; + +describe('Command Testing Utilities', () => { + describe('getCommandResult', () => { + it('should return false result for commands that cannot be applied', () => { + const state = createEditorState('

Test

'); + const impossibleCommand = () => false; // Command that always fails + + const result = getCommandResult(impossibleCommand, state); + + expect(result.result).toBe(false); + expect(result.transaction).toBeUndefined(); + expect(result.newState).toBeUndefined(); + }); + + it('should return true result with transaction for applicable commands', () => { + const state = createEditorState('

Test

'); + const insertTextCommand = (editorState, dispatch) => { + if (dispatch) { + const tr = editorState.tr.insertText(' additional text'); + dispatch(tr); + } + + return true; + }; + + const result = getCommandResult(insertTextCommand, state); + + expect(result.result).toBe(true); + expect(result.transaction).toBeDefined(); + expect(result.newState).toBeDefined(); + + // Check that some text was added (but don't rely on specific ordering) + expect(result.newState.doc.textContent).toContain('Test'); + expect(result.newState.doc.textContent).toContain( + 'additional text' + ); + }); + }); + + describe('testCommand', () => { + it('should test command applicability', () => { + const state = createEditorState('

Test

'); + + // Command that always succeeds + const alwaysApplicable = () => true; + + const result = testCommand(alwaysApplicable, state, { + shouldApply: true, + }); + + expect(result.result).toBe(true); + }); + + it('should test document content after command', () => { + const state = createEditorState('

Initial

'); + + const changeContent = (editorState, dispatch) => { + if (dispatch) { + const tr = editorState.tr.insertText(' Modified'); + dispatch(tr); + } + + return true; + }; + + const commandResult = testCommand(changeContent, state, { + shouldApply: true, + }); + + expect(commandResult.newState.doc.textContent).toContain('Initial'); + expect(commandResult.newState.doc.textContent).toContain( + 'Modified' + ); + }); + + it('should test document size after command', () => { + const state = createEditorState('

Size test

'); + const initialSize = state.doc.nodeSize; + + // Command that adds content, increasing size + const increaseSize = (editorState, dispatch) => { + if (dispatch) { + const tr = editorState.tr.insertText(' More text'); + dispatch(tr); + } + + return true; + }; + + const result = testCommand(increaseSize, state, { + shouldApply: true, + }); + + expect(result.newState.doc.nodeSize).toBeGreaterThan(initialSize); + }); + }); + + describe('testCommandWithView', () => { + let viewAndContainer: { + view: EditorView; + container: HTMLElement; + } | null = null; + + afterEach(() => { + if (viewAndContainer) { + cleanupEditorView( + viewAndContainer.view, + viewAndContainer.container + ); + viewAndContainer = null; + } + }); + + it('should test commands that require view context', () => { + const state = createEditorState('

View test

'); + + const viewCommand = (editorState, dispatch, viewArg) => { + // This command requires a view to work + if (!viewArg) { + return false; + } + + if (dispatch) { + const tr = editorState.tr.insertText(' with view'); + dispatch(tr); + } + + return true; + }; + + const { result, view, container } = testCommandWithView( + viewCommand, + state, + { + shouldApply: true, + } + ); + + viewAndContainer = { view: view, container: container }; + + expect(result.result).toBe(true); + expect(result.newState).toBeDefined(); + + expect(view.state.doc.textContent).toContain('View test'); + expect(view.state.doc.textContent).toContain('with view'); + }); + }); + + describe('createCommandTester', () => { + it('should create a reusable tester for a command', () => { + const addPrefix = (prefix) => (editorState, dispatch) => { + if (dispatch) { + const tr = editorState.tr.insertText(prefix, 1, 1); + dispatch(tr); + } + + return true; + }; + + const testHelloCommand = createCommandTester(addPrefix('Hello ')); + + const state1 = createEditorState('

World

'); + const state2 = createEditorState('

Universe

'); + + const result1 = testHelloCommand(state1, { + shouldApply: true, + docContentAfter: 'Hello World', + }); + + const result2 = testHelloCommand(state2, { + shouldApply: true, + docContentAfter: 'Hello Universe', + }); + + expect(result1.result).toBe(true); + expect(result2.result).toBe(true); + }); + }); +}); diff --git a/src/components/text-editor/test-setup/command-tester.ts b/src/components/text-editor/test-setup/command-tester.ts new file mode 100644 index 0000000000..07ab2b13cb --- /dev/null +++ b/src/components/text-editor/test-setup/command-tester.ts @@ -0,0 +1,260 @@ +import { EditorState, Transaction, Command } from 'prosemirror-state'; +import { createEditorView } from './editor-view-builder'; +import { EditorView } from 'prosemirror-view'; + +/** + * CommandResult represents the possible outcomes of a command execution + * - For successfully applied commands: + * - result is true + * - transaction contains the transaction that was created + * - newState contains the state after applying the transaction + * - For commands that couldn't be applied: + * - result is false + * - transaction is undefined + * - newState is undefined + */ +export interface CommandResult { + result: boolean; + transaction?: Transaction; + newState?: EditorState; +} + +/** + * Gets the result of applying a ProseMirror command to a state + * + * @param command - The ProseMirror command to test + * @param state - The editor state to apply the command to + * @returns An object containing the command result, transaction, and new state (if successful) + */ +export function getCommandResult( + command: Command, + state: EditorState +): CommandResult { + let transaction: Transaction | undefined; + + // Command signature is (state, dispatch, view?) => boolean + const commandResult = command( + state, + (tr) => { + transaction = tr; + }, + undefined + ); + + if (!commandResult) { + return { result: false }; + } + + let newState: EditorState | undefined; + if (transaction) { + newState = state.apply(transaction); + } + + return { + result: true, + transaction: transaction, + newState: newState, + }; +} + +/** + * Verifies if the document's exact text content matches the expected content + * @param doc + * @param doc.textContent + * @param expectedContent + */ +function verifyExactDocumentContent( + doc: { textContent: string }, + expectedContent: string +): void { + expect(doc.textContent).toBe(expectedContent); +} + +/** + * Verifies if the document contains all the expected text snippets + * @param doc + * @param doc.textContent + * @param expectedContent + */ +function verifyDocumentIncludes( + doc: { textContent: string }, + expectedContent: string | string[] +): void { + if (Array.isArray(expectedContent)) { + // Check that all strings in the array are contained in the doc content + for (const content of expectedContent) { + expect(doc.textContent).toContain(content); + } + } else { + expect(doc.textContent).toContain(expectedContent); + } +} + +/** + * Verifies if the document's node size matches the expected size + * @param doc + * @param doc.nodeSize + * @param expectedSize + */ +function verifyDocumentSize( + doc: { nodeSize: number }, + expectedSize: number +): void { + expect(doc.nodeSize).toBe(expectedSize); +} + +/** + * Verifies the content of the document against expected values + * + * @param state - The editor state containing the document to verify + * @param expected - The expected values to verify against + * @param expected.docContentAfter + * @param expected.docSizeAfter + * @param expected.includesContent + */ +function verifyDocumentContent( + state: EditorState, + expected: { + docContentAfter?: string; + docSizeAfter?: number; + includesContent?: string | string[]; + } +): void { + if (expected.docContentAfter !== undefined) { + verifyExactDocumentContent(state.doc, expected.docContentAfter); + } + + if ('includesContent' in expected) { + verifyDocumentIncludes(state.doc, expected.includesContent); + } + + if (expected.docSizeAfter !== undefined) { + verifyDocumentSize(state.doc, expected.docSizeAfter); + } +} + +/** + * Tests a ProseMirror command and verifies its result + * + * @param command - The ProseMirror command to test + * @param state - The editor state to apply the command to + * @param expected - An object containing expected values to verify: + * - `shouldApply`: whether the command should be applicable + * - `docContentAfter?`: expected document content after applying + * - `docSizeAfter?`: expected document size after applying + * = `includesContent?`: content that should exist in the document + * @param expected.shouldApply + * @param expected.docContentAfter + * @param expected.docSizeAfter + * @param expected.includesContent + * @returns The result of applying the command for further assertions if needed + */ +export function testCommand( + command: Command, + state: EditorState, + expected: { + shouldApply: boolean; + docContentAfter?: string; + docSizeAfter?: number; + includesContent?: string | string[]; + } +): CommandResult { + const commandResult = getCommandResult(command, state); + + // Verify if command was applicable as expected + expect(commandResult.result).toBe(expected.shouldApply); + + // If command should apply and doc expectations are provided, verify the new state + if (expected.shouldApply) { + const hasDocExpectations = + expected.docContentAfter !== undefined || + expected.docSizeAfter !== undefined || + expected.includesContent !== undefined; + + if (hasDocExpectations) { + expect(commandResult.newState).toBeDefined(); + } + + if (commandResult.newState) { + verifyDocumentContent(commandResult.newState, expected); + } + } + + return commandResult; +} + +/** + * Tests a command with a view context, useful for commands that require access to DOM + * + * @param command - The command to test + * @param state - The editor state to apply the command to + * @param expected - Expected results after command execution + * - `shouldApply`: Whether the command should be applicable + * - `docContentAfter?`: Expected document content after applying + * - `docSizeAfter?`: Expected document size after applying + * @param expected.shouldApply + * @param expected.docContentAfter + * @param expected.docSizeAfter + * @param expected.includesContent + * @returns An extended result containing the command result and the created view + */ +export function testCommandWithView( + command: Command, + state: EditorState, + expected: { + shouldApply: boolean; + docContentAfter?: string; + docSizeAfter?: number; + includesContent?: string | string[]; + } +): { result: CommandResult; view: EditorView; container: HTMLElement } { + const { view, container } = createEditorView(state); + + let result = false; + let transaction: Transaction | undefined; + + const commandResult = command( + state, + (tr) => { + transaction = tr; + view.updateState(state.apply(tr)); + result = true; + }, + view + ); + + const commandResultObj: CommandResult = { + result: commandResult && result, + }; + + if (transaction) { + commandResultObj.transaction = transaction; + commandResultObj.newState = state.apply(transaction); + } + + expect(commandResultObj.result).toBe(expected.shouldApply); + + if (expected.shouldApply && commandResultObj.newState) { + verifyDocumentContent(commandResultObj.newState, expected); + } + + return { result: commandResultObj, view: view, container: container }; +} + +/** + * Creates a reusable test function for testing commands under various conditions + * + * @param command - The command to test + * @returns A function that accepts a state and expected results + */ +export function createCommandTester(command: Command) { + return ( + state: EditorState, + expected: { + shouldApply: boolean; + docContentAfter?: string; + docSizeAfter?: number; + includesContent?: string | string[]; + } + ) => testCommand(command, state, expected); +} diff --git a/src/components/text-editor/test-setup/content-generator.spec.ts b/src/components/text-editor/test-setup/content-generator.spec.ts new file mode 100644 index 0000000000..046c4a6337 --- /dev/null +++ b/src/components/text-editor/test-setup/content-generator.spec.ts @@ -0,0 +1,231 @@ +import { EditorState } from 'prosemirror-state'; +import { createCustomTestSchema } from './schema-builder'; +import { + createDocWithText, + createDocWithHTML, + createDocWithFormattedText, + createDocWithBulletList, + createDocWithHeading, + createDocWithBlockquote, + createDocWithCodeBlock, + MarkApplication, +} from './content-generator'; + +describe('Content Generation Utilities', () => { + describe('createDocWithText', () => { + it('should create a document with plain text', () => { + const text = 'Plain text content'; + const state = createDocWithText(text); + + expect(state).toBeInstanceOf(EditorState); + expect(state.doc.textContent).toBe(text); + expect(state.doc.childCount).toBeGreaterThan(0); + }); + + it('should accept custom schema', () => { + const customSchema = createCustomTestSchema({ + addLists: false, + }); + + const state = createDocWithText('Test', customSchema); + + expect(state.schema.nodes.bullet_list).toBeUndefined(); + }); + }); + + describe('createDocWithHTML', () => { + it('should parse HTML content into a document', () => { + const html = + '

Heading

Paragraph with bold

'; + const state = createDocWithHTML(html); + + expect(state).toBeInstanceOf(EditorState); + expect(state.doc.textContent).toBe('HeadingParagraph with bold'); + + const firstChild = state.doc.firstChild; + const secondChild = state.doc.child(1); + + expect(firstChild).toBeDefined(); + expect(secondChild).toBeDefined(); + + if (firstChild && secondChild) { + expect(firstChild.type.name).toBe('heading'); + expect(secondChild.type.name).toBe('paragraph'); + } + }); + + it('should handle empty or invalid HTML', () => { + const state = createDocWithHTML(''); + + expect(state).toBeInstanceOf(EditorState); + expect(state.doc.childCount).toBeGreaterThan(0); + }); + }); + + describe('createDocWithFormattedText', () => { + it('should apply specified marks to text', () => { + const text = 'Formatted text'; + const marks: MarkApplication[] = [ + { type: 'strong' }, + { type: 'em' }, + ]; + + const state = createDocWithFormattedText(text, marks); + + expect(state).toBeInstanceOf(EditorState); + expect(state.doc.textContent).toBe(text); + + const firstChild = state.doc.firstChild; + expect(firstChild).toBeDefined(); + + if (firstChild) { + const textNode = firstChild.firstChild; + expect(textNode).toBeDefined(); + + if (textNode) { + const appliedMarks = textNode.marks; + expect(appliedMarks.length).toBe(2); + + const markNames = appliedMarks.map((m) => m.type.name); + expect(markNames).toContain('strong'); + expect(markNames).toContain('em'); + } + } + }); + + it('should apply marks with attributes', () => { + const text = 'Link text'; + const marks: MarkApplication[] = [ + { + type: 'link', + attrs: { + href: 'https://example.com', + title: 'Example', + }, + }, + ]; + + const state = createDocWithFormattedText(text, marks); + + const firstChild = state.doc.firstChild; + expect(firstChild).toBeDefined(); + expect(firstChild.firstChild).toBeDefined(); + + const linkMark = firstChild.firstChild.marks.find( + (m) => m.type.name === 'link' + ); + expect(linkMark).toBeDefined(); + expect(linkMark.attrs.href).toBe('https://example.com'); + expect(linkMark.attrs.title).toBe('Example'); + }); + + it('should throw an error for invalid mark types', () => { + const text = 'Test'; + const marks: MarkApplication[] = [{ type: 'nonexistent_mark' }]; + + expect(() => { + createDocWithFormattedText(text, marks); + }).toThrow(/not found in schema/); + }); + }); + + describe('createDocWithBulletList', () => { + it('should create a document with a bullet list', () => { + const items = ['Item 1', 'Item 2', 'Item 3']; + const state = createDocWithBulletList(items); + + expect(state).toBeInstanceOf(EditorState); + + const firstChild = state.doc.firstChild; + expect(firstChild).toBeDefined(); + + if (firstChild) { + expect(firstChild.type.name).toBe('bullet_list'); + expect(firstChild.childCount).toBe(3); + + for (const [i, item] of items.entries()) { + const listItem = firstChild.child(i); + expect(listItem.type.name).toBe('list_item'); + expect(listItem.textContent).toBe(item); + } + } + }); + + it('should throw when given an empty items array', () => { + expect(() => createDocWithBulletList([])).toThrow( + 'createDocWithBulletList requires at least one item' + ); + }); + }); + + describe('createDocWithHeading', () => { + it('should create a document with a heading', () => { + const text = 'Heading Text'; + const level = 2; + const state = createDocWithHeading(text, level); + + expect(state).toBeInstanceOf(EditorState); + expect(state.doc.textContent).toBe(text); + + const firstChild = state.doc.firstChild; + expect(firstChild).toBeDefined(); + + if (firstChild) { + expect(firstChild.type.name).toBe('heading'); + expect(firstChild.attrs.level).toBe(level); + } + }); + + it('should default to level 1 if not specified', () => { + const state = createDocWithHeading('Heading'); + + const firstChild = state.doc.firstChild; + if (firstChild) { + expect(firstChild.attrs.level).toBe(1); + } + }); + }); + + describe('createDocWithBlockquote', () => { + it('should create a document with a blockquote', () => { + const text = 'Quote text'; + const state = createDocWithBlockquote(text); + + expect(state).toBeInstanceOf(EditorState); + expect(state.doc.textContent).toBe(text); + + const firstChild = state.doc.firstChild; + expect(firstChild).toBeDefined(); + + if (firstChild) { + expect(firstChild.type.name).toBe('blockquote'); + + const paragraph = firstChild.firstChild; + expect(paragraph).toBeDefined(); + + if (paragraph) { + expect(paragraph.type.name).toBe('paragraph'); + expect(paragraph.textContent).toBe(text); + } + } + }); + }); + + describe('createDocWithCodeBlock', () => { + it('should create a document with a code block', () => { + const code = 'function test() { return true; }'; + const state = createDocWithCodeBlock(code); + + expect(state).toBeInstanceOf(EditorState); + expect(state.doc.textContent).toBe(code); + + const firstChild = state.doc.firstChild; + expect(firstChild).toBeDefined(); + + if (firstChild) { + expect(firstChild.type.name).toBe('code_block'); + expect(firstChild.textContent).toBe(code); + } + }); + }); +}); diff --git a/src/components/text-editor/test-setup/content-generator.ts b/src/components/text-editor/test-setup/content-generator.ts new file mode 100644 index 0000000000..780e375e67 --- /dev/null +++ b/src/components/text-editor/test-setup/content-generator.ts @@ -0,0 +1,211 @@ +import { Schema, Mark, Fragment, Node, Attrs } from 'prosemirror-model'; +import { createTestSchema } from './schema-builder'; +import { createEditorState } from './editor-state-builder'; +import { EditorState } from 'prosemirror-state'; + +/** + * Represents a mark applied to text, with a type name and optional attributes. + * This is distinct from `MarkSpec` (which defines how a mark is configured in a schema) + * and from ProseMirror's `Mark` (which is a live instance). Use this when specifying + * which marks to apply to a text node in test content helpers. + */ +export interface MarkApplication { + type: string; + attrs?: Attrs; +} + +/** + * Creates a document with plain text content wrapped in a paragraph. + * + * @param text - The text content to include (defaults to empty string) + * @param schema - Optional schema to use (defaults to createTestSchema()) + * @returns An EditorState with the specified text content + */ +export function createDocWithText( + text: string = '', + schema?: Schema +): EditorState { + const editorSchema = schema || createTestSchema(); + const content = text ? `

${text}

` : '

'; + + return createEditorState(content, editorSchema); +} + +/** + * Creates a document from HTML content. + * + * @param html - The HTML content to parse + * @param schema - Optional schema to use (defaults to test schema) + * @returns An EditorState with the parsed HTML content + */ +export function createDocWithHTML(html: string, schema?: Schema): EditorState { + const editorSchema = schema || createTestSchema(); + + return createEditorState(html, editorSchema); +} + +/** + * Creates a document with formatted text. + * + * @param text - The text content to include + * @param marks - Array of mark specifications to apply to the text + * @param schema - Optional schema to use (defaults to test schema) + * @returns An EditorState with the formatted text + */ +export function createDocWithFormattedText( + text: string, + marks: MarkApplication[], + schema?: Schema +): EditorState { + const editorSchema = schema || createTestSchema(); + + const paragraph = createTextNodeWithMarks(text, marks, editorSchema); + + const doc = editorSchema.nodes.doc.createAndFill(null, paragraph); + + return EditorState.create({ doc: doc }); +} + +/** + * Creates text nodes with specified marks. + * + * @param text - The text content + * @param marks - Array of mark specifications to apply + * @param schema - The schema to use + * @returns A paragraph node containing the formatted text + */ +function createTextNodeWithMarks( + text: string, + marks: MarkApplication[], + schema: Schema +): Node { + const appliedMarks: Mark[] = marks.map((markSpec) => { + const markType = schema.marks[markSpec.type]; + if (!markType) { + throw new Error(`Mark type "${markSpec.type}" not found in schema`); + } + + return markType.create(markSpec.attrs || {}); + }); + + const textNode = schema.text(text, appliedMarks); + + return schema.nodes.paragraph.create(null, textNode); +} + +/** + * Creates a document with a bullet list. + * + * @param items - Array of text items for the list + * @param schema - Optional schema to use (defaults to test schema) + * @returns An EditorState with a bullet list + */ +export function createDocWithBulletList( + items: string[], + schema?: Schema +): EditorState { + if (items.length === 0) { + throw new Error('createDocWithBulletList requires at least one item'); + } + + const editorSchema = schema || createTestSchema(); + + const listItems = items.map((text) => { + const textNode = editorSchema.text(text); + const paragraph = editorSchema.nodes.paragraph.create(null, textNode); + + return editorSchema.nodes.list_item.create(null, paragraph); + }); + + const bulletList = editorSchema.nodes.bullet_list.create( + null, + Fragment.from(listItems) + ); + + const doc = editorSchema.nodes.doc.createAndFill(null, bulletList); + + return EditorState.create({ doc: doc }); +} + +/** + * Creates a document with a heading. + * + * @param text - The heading text + * @param level - The heading level (1-6) + * @param schema - Optional schema to use (defaults to test schema) + * @returns An EditorState with a heading + */ +const MAX_HEADING_LEVEL = 6; + +/** + * + * @param text + * @param level + * @param schema + */ +export function createDocWithHeading( + text: string, + level: number = 1, + schema?: Schema +): EditorState { + if (level < 1 || level > MAX_HEADING_LEVEL) { + throw new Error( + `Heading level must be between 1 and ${MAX_HEADING_LEVEL}, got ${level}` + ); + } + + const editorSchema = schema || createTestSchema(); + + const textNode = editorSchema.text(text); + const heading = editorSchema.nodes.heading.create( + { level: level }, + textNode + ); + + const doc = editorSchema.nodes.doc.createAndFill(null, heading); + + return EditorState.create({ doc: doc }); +} + +/** + * Creates a document with a blockquote. + * + * @param text - The blockquote text + * @param schema - Optional schema to use (defaults to test schema) + * @returns An EditorState with a blockquote + */ +export function createDocWithBlockquote( + text: string, + schema?: Schema +): EditorState { + const editorSchema = schema || createTestSchema(); + + const textNode = editorSchema.text(text); + const paragraph = editorSchema.nodes.paragraph.create(null, textNode); + const blockquote = editorSchema.nodes.blockquote.create(null, paragraph); + + const doc = editorSchema.nodes.doc.createAndFill(null, blockquote); + + return EditorState.create({ doc: doc }); +} + +/** + * Creates a document with a code block. + * + * @param code - The code content + * @param schema - Optional schema to use (defaults to test schema) + * @returns An EditorState with a code block + */ +export function createDocWithCodeBlock( + code: string, + schema?: Schema +): EditorState { + const editorSchema = schema || createTestSchema(); + + const textNode = editorSchema.text(code); + const codeBlock = editorSchema.nodes.code_block.create(null, textNode); + + const doc = editorSchema.nodes.doc.createAndFill(null, codeBlock); + + return EditorState.create({ doc: doc }); +} diff --git a/src/components/text-editor/test-setup/editor-state-builder.spec.ts b/src/components/text-editor/test-setup/editor-state-builder.spec.ts new file mode 100644 index 0000000000..ad136e9d81 --- /dev/null +++ b/src/components/text-editor/test-setup/editor-state-builder.spec.ts @@ -0,0 +1,104 @@ +import { EditorState, TextSelection, NodeSelection } from 'prosemirror-state'; +import { createCustomTestSchema } from './schema-builder'; +import { + createEditorState, + createEditorStateWithSelection, + setTextSelection, + setNodeSelection, +} from './editor-state-builder'; + +describe('Editor State Utilities', () => { + describe('createEditorState', () => { + it('should create an empty state with default schema', () => { + const state = createEditorState(); + + expect(state).toBeInstanceOf(EditorState); + expect(state.doc.childCount).toBeGreaterThan(0); + + expect(state.schema.nodes.doc).toBeDefined(); + expect(state.schema.marks.strong).toBeDefined(); + }); + + it('should create a state with content', () => { + const content = '

Hello world!

'; + const state = createEditorState(content); + + expect(state).toBeInstanceOf(EditorState); + + const text = state.doc.textContent; + expect(text).toBe('Hello world!'); + + const wordPos = text.indexOf('world') + 1; + + const $pos = state.doc.resolve(wordPos); + const node = state.doc.nodeAt(wordPos); + expect($pos).toBeDefined(); + expect(node).toBeDefined(); + + if (node) { + const hasStrongMark = node.marks.some( + (m) => m.type.name === 'strong' + ); + expect(hasStrongMark).toBe(true); + } + }); + + it('should accept a custom schema', () => { + const customSchema = createCustomTestSchema({ + addStrikethrough: false, + }); + + const state = createEditorState('

Test

', customSchema); + + expect(state.schema.marks.strikethrough).toBeUndefined(); + }); + }); + + describe('createEditorStateWithSelection', () => { + it('should create a state with the specified selection', () => { + const content = '

Select this text

'; + const from = 1; // Start of "Select" + const to = 11; // End of "this" + + const state = createEditorStateWithSelection(content, from, to); + + expect(state).toBeInstanceOf(EditorState); + expect(state.selection).toBeInstanceOf(TextSelection); + expect(state.selection.from).toBe(from); + expect(state.selection.to).toBe(to); + }); + }); + + describe('setTextSelection', () => { + it('should create a new state with the specified selection', () => { + const originalState = createEditorState('

Test selection

'); + const from = 1; + const to = 5; + + const newState = setTextSelection(originalState, from, to); + + expect(newState).not.toBe(originalState); // Should be a new state object + expect(newState.selection.from).toBe(from); + expect(newState.selection.to).toBe(to); + }); + }); + + describe('setNodeSelection', () => { + it('should create a NodeSelection at the specified position', () => { + // Using a heading because it places a selectable node at position 0. + const state = createEditorState('

Test

'); + // Position 0 is immediately before the h1 node + const newState = setNodeSelection(state, 0); + + expect(newState.selection).toBeInstanceOf(NodeSelection); + expect(newState.selection.from).toBe(0); + }); + + it('should return a new state object', () => { + const originalState = createEditorState('

Test

'); + const newState = setNodeSelection(originalState, 0); + + expect(newState).not.toBe(originalState); + }); + }); +}); diff --git a/src/components/text-editor/test-setup/editor-state-builder.ts b/src/components/text-editor/test-setup/editor-state-builder.ts new file mode 100644 index 0000000000..40cf9d5662 --- /dev/null +++ b/src/components/text-editor/test-setup/editor-state-builder.ts @@ -0,0 +1,112 @@ +import { + EditorState, + TextSelection, + NodeSelection, + Plugin, +} from 'prosemirror-state'; +import { DOMParser, Schema } from 'prosemirror-model'; +import { createTestSchema } from './schema-builder'; + +/** + * Creates a ProseMirror editor state for testing. + * + * @param content - Optional content to initialize the editor with (HTML string) + * @param schema - Optional custom schema (uses createTestSchema by default) + * @param plugins - Optional array of plugins to include + * @returns A configured EditorState instance + */ +export function createEditorState( + content?: string, + schema?: Schema, + plugins: Plugin[] = [] +): EditorState { + const editorSchema = schema || createTestSchema(); + + const doc = content + ? parseContentToDoc(content, editorSchema) + : editorSchema.topNodeType.createAndFill(); + + return EditorState.create({ + doc: doc, + plugins: plugins, + }); +} + +/** + * Creates a ProseMirror editor state with a specific text selection. + * + * @param content - Content to initialize the editor with (HTML string) + * @param from - Start position of the selection + * @param to - End position of the selection + * @param schema - Optional custom schema (uses createTestSchema by default) + * @param plugins - Optional array of plugins to include + * @returns A configured EditorState instance with selection + */ +export function createEditorStateWithSelection( + content: string, + from: number, + to: number, + schema?: Schema, + plugins: Plugin[] = [] +): EditorState { + const editorSchema = schema || createTestSchema(); + + const doc = parseContentToDoc(content, editorSchema); + + const selection = TextSelection.create(doc, from, to); + + return EditorState.create({ + doc: doc, + selection: selection, + plugins: plugins, + }); +} + +/** + * Sets a text selection on an existing editor state. + * + * @param state - The editor state to modify + * @param from - Start position of the selection + * @param to - End position of the selection (defaults to from) + * @returns A new editor state with the specified selection + */ +export function setTextSelection( + state: EditorState, + from: number, + to: number = from +): EditorState { + const selection = TextSelection.create(state.doc, from, to); + + return state.apply(state.tr.setSelection(selection)); +} + +/** + * Sets a node selection on an existing editor state. + * + * @param state - The editor state to modify + * @param pos - Position immediately before the node to select + * @returns A new editor state with the specified node selection + */ +export function setNodeSelection(state: EditorState, pos: number): EditorState { + const selection = NodeSelection.create(state.doc, pos); + + return state.apply(state.tr.setSelection(selection)); +} + +/** + * Parses content string into a ProseMirror document. + * + * @param content - Content string (HTML) + * @param schema - Schema to use for parsing + * @returns A ProseMirror document node + * + * Security note: This helper is intended ONLY for use in writing tests and test code with trusted HTML. + * Do NOT use this function for parsing content in production + * Do NOT pass untrusted HTML here without sanitizing first. + */ +function parseContentToDoc(content: string, schema: Schema) { + const domNode = document.createElement('div'); + domNode.innerHTML = content; + + return DOMParser.fromSchema(schema).parse(domNode); +} diff --git a/src/components/text-editor/test-setup/editor-test-utils.ts b/src/components/text-editor/test-setup/editor-test-utils.ts new file mode 100644 index 0000000000..ac9e3fdcd8 --- /dev/null +++ b/src/components/text-editor/test-setup/editor-test-utils.ts @@ -0,0 +1,7 @@ +export * from './schema-builder'; +export * from './editor-state-builder'; +export * from './editor-view-builder'; +export * from './content-generator'; +export * from './command-tester'; +export * from './event-simulator'; +export * from './mock-factories'; diff --git a/src/components/text-editor/test-setup/editor-view-builder.spec.ts b/src/components/text-editor/test-setup/editor-view-builder.spec.ts new file mode 100644 index 0000000000..6f6d4ecfe1 --- /dev/null +++ b/src/components/text-editor/test-setup/editor-view-builder.spec.ts @@ -0,0 +1,105 @@ +import { EditorState } from 'prosemirror-state'; +import { EditorView } from 'prosemirror-view'; +import { createEditorState } from './editor-state-builder'; +import { createEditorView, cleanupEditorView } from './editor-view-builder'; + +describe('Editor View Utilities', () => { + let view: EditorView | null; + let container: HTMLElement | null; + + afterEach(() => { + if (view) { + cleanupEditorView(view, container); + view = null; + container = null; + } + }); + + describe('createEditorView', () => { + it('should create an editor view with default state', () => { + const result = createEditorView(); + view = result.view; + container = result.container; + + expect(view).toBeInstanceOf(EditorView); + expect(container).toBeDefined(); + expect(container.tagName || container.nodeName).toBeDefined(); + + expect(view.state).toBeInstanceOf(EditorState); + }); + + it('should use provided editor state', () => { + const state = createEditorState('

Custom state

'); + const result = createEditorView(state); + view = result.view; + container = result.container; + + expect(view.state).toBe(state); + expect(view.state.doc.textContent).toBe('Custom state'); + }); + + it('should use provided dispatch function', () => { + const dispatchSpy = vi.fn(); + const result = createEditorView(undefined, dispatchSpy); + view = result.view; + container = result.container; + + const tr = view.state.tr.insertText('Test'); + view.dispatch(tr); + + expect(dispatchSpy).toHaveBeenCalledWith(tr); + }); + + it('should use provided parent element', () => { + const customContainer = document.createElement('div'); + const result = createEditorView( + undefined, + undefined, + customContainer + ); + view = result.view; + container = result.container; + + expect(container).toBe(customContainer); + }); + }); + + describe('cleanupEditorView', () => { + it('should destroy the editor view', () => { + const result = createEditorView(); + view = result.view; + container = result.container; + + const destroySpy = vi.spyOn(view, 'destroy'); + + cleanupEditorView(view, container); + + expect(destroySpy).toHaveBeenCalled(); + + view = null; + container = null; + }); + + it('should remove container from DOM if provided', () => { + const customContainer = document.createElement('div'); + document.body.append(customContainer); + + const result = createEditorView( + undefined, + undefined, + customContainer + ); + view = result.view; + container = result.container; + + expect(document.body.contains(customContainer)).toBe(true); + + cleanupEditorView(view, customContainer); + + expect(document.body.contains(customContainer)).toBe(false); + + view = null; + container = null; + }); + }); +}); diff --git a/src/components/text-editor/test-setup/editor-view-builder.ts b/src/components/text-editor/test-setup/editor-view-builder.ts new file mode 100644 index 0000000000..4abd0b9f26 --- /dev/null +++ b/src/components/text-editor/test-setup/editor-view-builder.ts @@ -0,0 +1,57 @@ +import { EditorState, Transaction } from 'prosemirror-state'; +import { EditorView } from 'prosemirror-view'; +import { createEditorState } from './editor-state-builder'; +import { createTestSchema } from './schema-builder'; + +/** + * Creates a ProseMirror editor view for testing purposes. + * + * @param state - The editor state to use (will create a default one if not provided) + * @param dispatchTransaction - Optional custom dispatch function (e.g., a vi.fn() spy) + * @param parentElement - Optional parent DOM element (will create one if not provided) + * @returns The created EditorView instance and its container element + */ +export function createEditorView( + state?: EditorState, + dispatchTransaction?: (tr: Transaction) => void, + parentElement?: HTMLElement +): { view: EditorView; container: HTMLElement } { + const container = parentElement || document.createElement('div'); + if (!parentElement) { + document.body.append(container); + } + + const editorState = + state || createEditorState('

', createTestSchema()); + + const viewProps: { + state: EditorState; + dispatchTransaction?: (tr: Transaction) => void; + } = { + state: editorState, + }; + + if (dispatchTransaction) { + viewProps.dispatchTransaction = dispatchTransaction; + } + + const view = new EditorView(container, viewProps); + + return { view: view, container: container }; +} + +/** + * Properly cleans up an editor view to prevent memory leaks. + * This should be called in test cleanup/afterEach. + * + * @param view - The editor view to destroy + * @param container - The container element to remove (if created by test) + */ +export function cleanupEditorView( + view: EditorView, + container?: HTMLElement +): void { + view.destroy(); + + container?.remove(); +} diff --git a/src/components/text-editor/test-setup/event-simulator.spec.ts b/src/components/text-editor/test-setup/event-simulator.spec.ts new file mode 100644 index 0000000000..b5336df5d9 --- /dev/null +++ b/src/components/text-editor/test-setup/event-simulator.spec.ts @@ -0,0 +1,114 @@ +import { EditorView } from 'prosemirror-view'; +import { keymap } from 'prosemirror-keymap'; +import { toggleMark } from 'prosemirror-commands'; +import { + createEditorState, + createEditorStateWithSelection, +} from './editor-state-builder'; +import { createEditorView, cleanupEditorView } from './editor-view-builder'; +import { simulateKeyPress, simulatePaste } from './event-simulator'; +import { createTestSchema } from './schema-builder'; + +describe('Event Simulation Utilities', () => { + let view: EditorView | null; + let container: HTMLElement | null; + + afterEach(() => { + if (view) { + cleanupEditorView(view, container); + view = null; + container = null; + } + }); + + describe('simulateKeyPress', () => { + it('should trigger a keymap command in ProseMirror', () => { + const schema = createTestSchema(); + const state = createEditorStateWithSelection( + '

Hello world

', + 1, + 6, + schema, + [keymap({ 'Ctrl-b': toggleMark(schema.marks.strong) })] + ); + + const result = createEditorView(state); + view = result.view; + container = result.container; + + simulateKeyPress(view, 'b', { ctrl: true }); + + const textNode = view.state.doc.firstChild?.firstChild; + expect(textNode).toBeDefined(); + + const hasStrongMark = textNode.marks.some( + (m) => m.type.name === 'strong' + ); + expect(hasStrongMark).toBe(true); + }); + + it('should distinguish between different modifier combinations', () => { + const schema = createTestSchema(); + let boldTriggered = false; + let italicTriggered = false; + + const state = createEditorStateWithSelection( + '

Test text

', + 1, + 5, + schema, + [ + keymap({ + 'Ctrl-b': () => { + boldTriggered = true; + + return true; + }, + 'Ctrl-i': () => { + italicTriggered = true; + + return true; + }, + }), + ] + ); + + const result = createEditorView(state); + view = result.view; + container = result.container; + + simulateKeyPress(view, 'b', { ctrl: true }); + + expect(boldTriggered).toBe(true); + expect(italicTriggered).toBe(false); + }); + }); + + describe('simulatePaste', () => { + it('should insert pasted text into the document', () => { + const state = createEditorState('

'); + const result = createEditorView(state); + view = result.view; + container = result.container; + + const contentBefore = view.state.doc.textContent; + + simulatePaste(view, { text: 'pasted content' }); + + const contentAfter = view.state.doc.textContent; + expect(contentAfter).not.toBe(contentBefore); + expect(contentAfter).toContain('pasted content'); + }); + + it('should insert text into a document that already has content', () => { + const state = createEditorState('

existing

'); + const result = createEditorView(state); + view = result.view; + container = result.container; + + simulatePaste(view, { text: ' more' }); + + expect(view.state.doc.textContent).toContain('more'); + }); + }); +}); diff --git a/src/components/text-editor/test-setup/event-simulator.ts b/src/components/text-editor/test-setup/event-simulator.ts new file mode 100644 index 0000000000..5149053cb1 --- /dev/null +++ b/src/components/text-editor/test-setup/event-simulator.ts @@ -0,0 +1,119 @@ +import { EditorView } from 'prosemirror-view'; + +/** + * ProseMirror specific paste event data + */ +export interface PasteData { + text?: string; + html?: string; +} + +/** + * Key modifiers that can be used with keyboard events + */ +export interface KeyModifiers { + shift?: boolean; + alt?: boolean; + ctrl?: boolean; + meta?: boolean; +} + +/** + * Simulates a key press on the editor view + * + * @param view - The editor view to dispatch the key event on + * @param key - The key to simulate (e.g., 'a', 'Enter', 'ArrowUp') + * @param modifiers - Optional key modifiers (Shift, Alt, Ctrl, Meta) + * @returns The result of dispatchEvent — false if a listener called preventDefault + */ +export function simulateKeyPress( + view: EditorView, + key: string, + modifiers: KeyModifiers = {} +): boolean { + const options: KeyboardEventInit = { + key: key, + bubbles: true, + cancelable: true, + shiftKey: !!modifiers.shift, + altKey: !!modifiers.alt, + ctrlKey: !!modifiers.ctrl, + metaKey: !!modifiers.meta, + }; + + const event = new KeyboardEvent('keydown', options); + + const domNode = view.dom; + const eventHandled = domNode.dispatchEvent(event); + + return eventHandled; +} + +/** + * Simulates pasting content into the editor + * + * @param view - The editor view to dispatch the paste event on + * @param content - The content to paste (text, HTML, or both) + * @returns The result of dispatchEvent — false if a listener called preventDefault + */ +export function simulatePaste(view: EditorView, content: PasteData): boolean { + const dataTransfer = createDataTransfer(content); + + const pasteEvent = new CustomEvent('paste', { + bubbles: true, + cancelable: true, + }); + + Object.defineProperty(pasteEvent, 'clipboardData', { + value: dataTransfer, + writable: false, + }); + + const domNode = view.dom; + const eventHandled = domNode.dispatchEvent(pasteEvent); + + return eventHandled; +} + +/** + * Minimal clipboard data mock for test environments where the native + * DataTransfer constructor is not available (e.g., Stencil/Jest). + * Only implements the getData/setData API that ProseMirror's paste + * handler requires. + */ +class ClipboardDataMock { + private readonly data = new Map(); + + setData(format: string, value: string): void { + this.data.set(format, value); + } + + getData(format: string): string { + return this.data.get(format) || ''; + } +} + +/** + * Creates a clipboard data object and populates it with the provided content. + * Uses the native DataTransfer when available, otherwise falls back to a + * minimal mock. + * @param content + */ +function createDataTransfer( + content: PasteData +): DataTransfer | ClipboardDataMock { + const dataTransfer = + typeof DataTransfer === 'undefined' + ? new ClipboardDataMock() + : new DataTransfer(); + + if (content.text !== undefined) { + dataTransfer.setData('text/plain', content.text); + } + + if (content.html !== undefined) { + dataTransfer.setData('text/html', content.html); + } + + return dataTransfer; +} diff --git a/src/components/text-editor/test-setup/mock-factories.spec.ts b/src/components/text-editor/test-setup/mock-factories.spec.ts new file mode 100644 index 0000000000..6f88a41568 --- /dev/null +++ b/src/components/text-editor/test-setup/mock-factories.spec.ts @@ -0,0 +1,102 @@ +import { EditorView } from 'prosemirror-view'; +import { createEditorState } from './editor-state-builder'; +import { createEditorView, cleanupEditorView } from './editor-view-builder'; +import { createDispatchSpy, createMockEditorView } from './mock-factories'; + +describe('Mock Factories', () => { + describe('createDispatchSpy', () => { + let view: EditorView | null; + let container: HTMLElement | null; + + afterEach(() => { + if (view) { + cleanupEditorView(view, container); + view = null; + container = null; + } + }); + + it('should create a spy that tracks transactions', () => { + const state = createEditorState('

Test

'); + const result = createEditorView(state); + view = result.view; + container = result.container; + + const dispatchSpy = createDispatchSpy(view); + + const tr = view.state.tr.insertText(' added'); + dispatchSpy(tr); + + expect(dispatchSpy).toHaveBeenCalledWith(tr); + }); + + it('should update view state when autoUpdate is true', () => { + const state = createEditorState('

'); + const result = createEditorView(state); + view = result.view; + container = result.container; + + const dispatchSpy = createDispatchSpy(view, true); + + expect(view.state.doc.textContent).toBe(''); + + const tr = view.state.tr.insertText('Updated text'); + dispatchSpy(tr); + + expect(view.state.doc.textContent).toBe('Updated text'); + }); + + it('should not update view state when autoUpdate is false', () => { + const state = createEditorState('

'); + const result = createEditorView(state); + view = result.view; + container = result.container; + + const dispatchSpy = createDispatchSpy(view, false); + + expect(view.state.doc.textContent).toBe(''); + + const tr = view.state.tr.insertText('New text'); + dispatchSpy(tr); + + expect(view.state.doc.textContent).toBe(''); + }); + }); + + describe('createMockEditorView', () => { + it('should create a mock view with default state', () => { + const mock = createMockEditorView(); + + expect(mock.state).toBeDefined(); + expect(typeof mock.dispatch).toBe('function'); + expect(mock.dom).toBeDefined(); + }); + + it('should use provided state', () => { + const state = createEditorState('

Custom

'); + const mock = createMockEditorView(state); + + expect(mock.state).toBe(state); + expect(mock.state.doc.textContent).toBe('Custom'); + }); + + it('should have spy functions for dispatch and destroy', () => { + const mock = createMockEditorView(); + + expect(mock.dispatch).toHaveBeenCalledTimes(0); + mock.destroy(); + expect(mock.destroy).toHaveBeenCalledTimes(1); + }); + + it('should update state when dispatch is called', () => { + const state = createEditorState('

Hello

'); + const mock = createMockEditorView(state); + const tr = mock.state.tr.insertText( + ' world', + mock.state.doc.content.size - 1 + ); + mock.dispatch(tr); + expect(mock.state.doc.textContent).toBe('Hello world'); + }); + }); +}); diff --git a/src/components/text-editor/test-setup/mock-factories.ts b/src/components/text-editor/test-setup/mock-factories.ts new file mode 100644 index 0000000000..b5cd367aac --- /dev/null +++ b/src/components/text-editor/test-setup/mock-factories.ts @@ -0,0 +1,67 @@ +import { vi, type Mock } from 'vitest'; +import { EditorState, Transaction } from 'prosemirror-state'; +import { EditorView } from 'prosemirror-view'; +import { createEditorState } from './editor-state-builder'; + +type DispatchFn = (tr: Transaction) => void; + +/** + * Creates a spy function to track dispatch calls on an existing editor view. + * + * When `autoUpdate` is true (the default), calling the spy automatically + * applies the transaction to the view's state, mirroring the behavior of + * a real EditorView. + * + * @param view - The editor view whose state should be updated on dispatch + * @param autoUpdate - Whether to automatically update the view's state (default: true) + * @returns A vitest mock function typed as a dispatch function + */ +export function createDispatchSpy( + view: EditorView, + autoUpdate = true +): Mock { + return vi.fn((transaction: Transaction) => { + if (autoUpdate) { + view.updateState(view.state.apply(transaction)); + } + }); +} + +/** + * A lightweight mock for EditorView, suitable for unit tests that need a + * view-shaped object (e.g., commands that accept `view` as a third argument) + * but do not require real DOM mounting or plugin infrastructure. + */ +export interface MockEditorView { + state: EditorState; + dispatch: Mock; + dom: HTMLElement; + destroy: Mock<() => void>; +} + +/** + * Creates a lightweight mock EditorView for unit testing. + * + * Use this when you need a view-shaped object without real DOM mounting. + * For tests that require actual ProseMirror DOM rendering or plugin + * interaction, use `createEditorView` instead. + * + * Calling `mock.dispatch(tr)` updates `mock.state` (just like a real view), + * so subsequent reads of `mock.state` reflect the result of applied transactions. + * + * @param state - Optional editor state (creates a default empty state if omitted) + * @returns A MockEditorView with vitest spy functions for dispatch and destroy + */ +export function createMockEditorView(state?: EditorState): MockEditorView { + const mock = { + state: state ?? createEditorState(), + dom: document.createElement('div'), + destroy: vi.fn(), + } as unknown as MockEditorView; + + mock.dispatch = vi.fn((tr: Transaction) => { + mock.state = mock.state.apply(tr); + }); + + return mock; +} diff --git a/src/components/text-editor/test-setup/schema-builder.spec.ts b/src/components/text-editor/test-setup/schema-builder.spec.ts new file mode 100644 index 0000000000..264afcce24 --- /dev/null +++ b/src/components/text-editor/test-setup/schema-builder.spec.ts @@ -0,0 +1,80 @@ +import { Schema, MarkType, NodeType } from 'prosemirror-model'; +import { createTestSchema, createCustomTestSchema } from './schema-builder'; + +describe('Schema Utilities', () => { + describe('createTestSchema', () => { + it('should create a schema with basic marks and nodes', () => { + const schema = createTestSchema(); + + expect(schema).toBeInstanceOf(Schema); + + expect(schema.nodes.doc).toBeDefined(); + expect(schema.nodes.paragraph).toBeDefined(); + expect(schema.nodes.text).toBeDefined(); + + expect(schema.nodes.bullet_list).toBeDefined(); + expect(schema.nodes.ordered_list).toBeDefined(); + expect(schema.nodes.list_item).toBeDefined(); + expect(schema.nodes.heading).toBeDefined(); + expect(schema.nodes.blockquote).toBeDefined(); + expect(schema.nodes.code_block).toBeDefined(); + + expect(schema.marks.strong).toBeDefined(); + expect(schema.marks.em).toBeDefined(); + expect(schema.marks.code).toBeDefined(); + expect(schema.marks.link).toBeDefined(); + + expect(schema.marks.strikethrough).toBeDefined(); + expect(schema.marks.underline).toBeDefined(); + }); + }); + + describe('createCustomTestSchema', () => { + it('should create a schema with specified options', () => { + const customSchema = createCustomTestSchema({ + addLists: false, + addStrikethrough: true, + addUnderline: false, + }); + + expect(customSchema).toBeInstanceOf(Schema); + + expect(customSchema.nodes.bullet_list).toBeUndefined(); + expect(customSchema.nodes.ordered_list).toBeUndefined(); + expect(customSchema.nodes.list_item).toBeUndefined(); + + expect(customSchema.marks.strikethrough).toBeDefined(); + expect(customSchema.marks.underline).toBeUndefined(); + }); + + it('should support custom marks', () => { + const highlightMark = { + parseDOM: [{ tag: 'mark' }], + toDOM: () => ['mark', 0], + }; + + const customSchema = createCustomTestSchema({ + customMarks: { highlight: highlightMark }, + }); + + expect(customSchema.marks.highlight).toBeDefined(); + expect(customSchema.marks.highlight instanceof MarkType).toBe(true); + }); + + it('should support custom nodes', () => { + const customNode = { + content: 'inline*', + group: 'block', + parseDOM: [{ tag: 'div.custom' }], + toDOM: () => ['div', { class: 'custom' }, 0], + }; + + const customSchema = createCustomTestSchema({ + customNodes: { custom: customNode }, + }); + + expect(customSchema.nodes.custom).toBeDefined(); + expect(customSchema.nodes.custom instanceof NodeType).toBe(true); + }); + }); +}); diff --git a/src/components/text-editor/test-setup/schema-builder.ts b/src/components/text-editor/test-setup/schema-builder.ts new file mode 100644 index 0000000000..e19b68ac32 --- /dev/null +++ b/src/components/text-editor/test-setup/schema-builder.ts @@ -0,0 +1,92 @@ +import { Schema, MarkSpec, NodeSpec } from 'prosemirror-model'; +import { schema as basicSchema } from 'prosemirror-schema-basic'; +import { addListNodes } from 'prosemirror-schema-list'; +import { strikethrough } from '../prosemirror-adapter/menu/menu-schema-extender'; + +const underline: MarkSpec = { + parseDOM: [ + { tag: 'u' }, + { style: 'text-decoration:underline' }, + { style: 'text-decoration-line:underline' }, + ], + toDOM: () => ['u', 0], +}; + +/** + * Creates a standardized ProseMirror schema for testing the text editor. + * This schema includes all the nodes and marks used in the actual text editor. + * + * @returns A ProseMirror Schema configured for testing + */ +export function createTestSchema(): Schema { + const schema = new Schema({ + nodes: addListNodes( + basicSchema.spec.nodes, + 'paragraph block*', + 'block' + ), + marks: basicSchema.spec.marks.append({ + strikethrough: strikethrough, + underline: underline, + }), + }); + + return schema; +} + +/** + * Creates a custom ProseMirror schema with specified configurations. + * Allows for more flexibility in testing specific schema behaviors. + * + * @param options - Configuration options for the schema + * - `addLists?`: Whether to include list nodes (default: true) + * - `addStrikethrough?`: Whether to include strikethrough mark (default: true) + * - `addUnderline?`: Whether to include underline mark (default: true) + * - `customMarks?`: Custom marks to append to the schema + * - `customNodes?`: Custom nodes to append to the schema + * @param options.addLists + * @param options.addStrikethrough + * @param options.addUnderline + * @param options.customMarks + * @param options.customNodes + * @returns A customized ProseMirror Schema + */ +export function createCustomTestSchema(options: { + addLists?: boolean; + addStrikethrough?: boolean; + addUnderline?: boolean; + customMarks?: Record; + customNodes?: Record; +}): Schema { + let nodes = basicSchema.spec.nodes; + + if (options.addLists !== false) { + nodes = addListNodes(nodes, 'paragraph block*', 'block'); + } + + let marks = basicSchema.spec.marks; + + if (options.addStrikethrough !== false) { + marks = marks.append({ + strikethrough: strikethrough, + }); + } + + if (options.addUnderline !== false) { + marks = marks.append({ + underline: underline, + }); + } + + if (options.customMarks) { + marks = marks.append(options.customMarks); + } + + if (options.customNodes) { + for (const [name, spec] of Object.entries(options.customNodes)) { + nodes = nodes.addToEnd(name, spec); + } + } + + return new Schema({ nodes: nodes, marks: marks }); +} diff --git a/src/components/text-editor/test-setup/text-editor-test-suite-guide.md b/src/components/text-editor/test-setup/text-editor-test-suite-guide.md new file mode 100644 index 0000000000..4faa7f5f85 --- /dev/null +++ b/src/components/text-editor/test-setup/text-editor-test-suite-guide.md @@ -0,0 +1,178 @@ +# Text Editor Testing Suite Guide + +This directory contains utility functions and setup code to help test the text editor component and its ProseMirror-based internals. + +## Available Utilities + +### 1. Schema Setup + +- `createTestSchema()` - Creates a standard ProseMirror schema with all needed marks and nodes +- `createCustomTestSchema(options)` - Creates a custom schema with specified extensions + +```typescript +// Create a standard schema with all marks and nodes +const schema = createTestSchema(); + +// Create a custom schema with only specific features +const customSchema = createCustomTestSchema({ + addLists: true, // Add ordered and bullet list support + addStrikethrough: true, // Add strikethrough mark + addUnderline: false // Skip underline mark +}); +``` + +### 2. Editor State Utilities + +- `createEditorState(content?, schema?, plugins?)` - Creates an editor state with optional content +- `createEditorStateWithSelection(content, from, to, schema?, plugins?)` - Creates an editor state with a specific selection +- `setTextSelection(state, from, to?)` - Sets a text selection on an existing state +- `setNodeSelection(state, pos)` - Sets a node selection at the given position + +```typescript +// Create an empty editor state +const emptyState = createEditorState(); + +// Create state with HTML content +const state = createEditorState('

This is bold text.

'); + +// Create state with selection (from position 5 to 10) +const stateWithSelection = createEditorStateWithSelection( + '

Select this text

', + 5, + 10 +); + +// Add a text selection to an existing state +const newState = setTextSelection(state, 1, 5); + +// Select a node (pos must be immediately before the node) +const nodeState = setNodeSelection(state, 0); +``` + +### 3. Editor View Utilities + +- `createEditorView(state?, dispatchSpy?, parentElement?)` - Creates a ProseMirror editor view with optional dispatch spy +- `cleanupEditorView(view, container?)` - Properly destroys an editor view to prevent memory leaks +- `createDispatchSpy(view, autoUpdate?)` - Creates a spy that tracks dispatch calls on an existing view (from `mock-factories`) +- `createMockEditorView(state?)` - Creates a lightweight mock EditorView without real DOM mounting (from `mock-factories`) + +```typescript +// Create a view with state +const { view, container } = createEditorView(state); + +// Create a dispatch spy that auto-updates view state +const dispatchSpy = createDispatchSpy(view); + +// Create a view with custom parent element +const parent = document.createElement('div'); +const { view: viewWithParent } = createEditorView(state, undefined, parent); + +// Clean up after testing +cleanupEditorView(view, container); +``` + +### 4. Content Generation + +- `createDocWithText(text, schema?)` - Creates a document with plain text +- `createDocWithHTML(html, schema?)` - Creates a document from HTML string +- `createDocWithFormattedText(text, marks, schema?)` - Creates a document with marked text +- `createDocWithBulletList(items, schema?)` - Creates a document with a bullet list +- `createDocWithHeading(text, level?, schema?)` - Creates a document with a heading +- `createDocWithBlockquote(text, schema?)` - Creates a document with a blockquote +- `createDocWithCodeBlock(code, schema?)` - Creates a document with a code block + +```typescript +// Create documents with different content types +const textDoc = createDocWithText('Simple text'); +const htmlDoc = createDocWithHTML('

HTML content

'); + +// Create a document with formatted text +const formattedDoc = createDocWithFormattedText('Bold and italic text', [ + { type: 'strong' }, + { type: 'em' }, +]); + +// Create various structured documents +const listDoc = createDocWithBulletList(['Item 1', 'Item 2', 'Item 3']); +const headingDoc = createDocWithHeading('Section Title', 2); // h2 +const quoteDoc = createDocWithBlockquote('Famous quote here'); +const codeDoc = createDocWithCodeBlock('function test() { return true; }'); +``` + +### 5. Command Testing + +- `testCommand(command, state, expected)` - Tests a command and verifies the result +- `getCommandResult(command, state)` - Gets the result of applying a command +- `testCommandWithView(command, state, expected)` - Tests a command that requires view context +- `createCommandTester(command)` - Creates a reusable tester for a specific command + +```typescript +// Test if a command can be applied +const result = getCommandResult(toggleMark(schema.marks.strong), state); +expect(result.result).toBe(true); + +// Test a command with expectations +testCommand(toggleMark(schema.marks.strong), state, { + shouldApply: true, +}); + +// Create a reusable command tester +const testBold = createCommandTester(toggleMark(schema.marks.strong)); +testBold(state1, { shouldApply: true }); +testBold(state2, { shouldApply: false }); + +// Test a command that requires view context +testCommandWithView(someViewCommand, state, { + shouldApply: true, +}); +``` + +### 6. Event Simulation + +- `simulateKeyPress(view, key, modifiers?)` - Simulates a key press on the editor +- `simulatePaste(view, content)` - Simulates pasting content into the editor + +```typescript +// Simulate keyboard shortcuts +simulateKeyPress(view, 'b', { ctrl: true }); // Ctrl+B for bold +simulateKeyPress(view, 'z', { ctrl: true }); // Ctrl+Z for undo +simulateKeyPress(view, 'Tab'); // Tab key + +// Simulate paste (text only — HTML paste is not supported in jsdom) +simulatePaste(view, { + text: 'Plain text', +}); +``` + +## Examples + +See the `.spec.ts` files in this directory for working, tested examples of each utility. + +## Best Practices + +1. **Setup and Cleanup** + - Always clean up editor views after tests to prevent memory leaks + - Create schema once per test suite when possible + - Use `beforeEach` and `afterEach` for consistent setup and cleanup + +2. **Content Creation** + - Use the appropriate content generation utility for your test case + - Use HTML strings for complex document structures + +3. **Command Testing** + - Test commands in isolation with `testCommand` when possible + - Test both positive and negative cases (when command should and shouldn't apply) + - Verify document structure after command application + +4. **Event Handling** + - For keyboard shortcut tests, ensure the selection is set up correctly before simulating keys + - `simulatePaste` only supports text content in jsdom — use e2e tests for HTML paste + +5. **State Management** + - Editor state is immutable — always use transactions + - Use `view.dispatch(tr)` to update the view's state + - Use `dispatchSpy` with `autoUpdate=true` to track state changes + +6. **Debugging Tips** + - `JSON.stringify(view.state.doc.toJSON())` to view document structure + - `view.state.selection` to check selection ranges