Răsfoiți Sursa

Merge pull request #323 from ckeditor/t/306

Implemented test utils getData() and setData()
Szymon Cofalik 9 ani în urmă
părinte
comite
d3dffbd25c

+ 9 - 0
packages/ckeditor5-engine/src/treemodel/selection.js

@@ -106,6 +106,15 @@ export default class Selection {
 		return true;
 	}
 
+	/**
+	 * Specifies whether the last added range was added as a backward or forward range.
+	 *
+	 * @type {Boolean}
+	 */
+	get isBackward() {
+		return this._lastRangeBackward;
+	}
+
 	/**
 	 * Adds a range to the selection. Added range is copied and converted to {@link engine.treeModel.LiveRange}. This means
 	 * that passed range is not saved in the Selection instance and you can safely operate on it.

+ 365 - 0
packages/ckeditor5-engine/tests/_utils-tests/model.js

@@ -0,0 +1,365 @@
+/**
+ * @license Copyright (c) 2003-20'INSERT'6, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import { getData, setData } from '/tests/engine/_utils/model.js';
+import Document from '/ckeditor5/engine/treemodel/document.js';
+import Element from '/ckeditor5/engine/treemodel/element.js';
+import Text from '/ckeditor5/engine/treemodel/text.js';
+import Range from '/ckeditor5/engine/treemodel/range.js';
+
+describe( 'model test utils', () => {
+	let document, root, selection;
+
+	beforeEach( () => {
+		document = new Document();
+		root = document.createRoot( 'main', '$root' );
+		selection = document.selection;
+
+		selection.removeAllRanges();
+	} );
+
+	describe( 'getData', () => {
+		it( 'writes elements and texts', () => {
+			root.appendChildren( [
+				new Element( 'a', null, 'atext' ),
+				new Element( 'b', null, [
+					new Element( 'c1' ),
+					'ctext',
+					new Element( 'c2' )
+				] ),
+				new Element( 'd' )
+			] );
+
+			expect( getData( document, 'main' ) ).to.equal(
+				'<a>atext</a><b><c1></c1>ctext<c2></c2></b><d></d>'
+			);
+		} );
+
+		it( 'writes element attributes', () => {
+			root.appendChildren(
+				new Element( 'a', { foo: true, bar: 1, car: false }, [
+					new Element( 'b', { fooBar: 'x y', barFoo: { x: 1, y: 2 } } )
+				] )
+			);
+
+			// Note: attributes are written in a very simplistic way, because they are not to be parsed. They are just
+			// to be compared in the tests with some patterns.
+			expect( getData( document, 'main' ) ).to.equal(
+				'<a bar=1 car=false foo=true><b barFoo={"x":1,"y":2} fooBar="x y"></b></a>'
+			);
+		} );
+
+		it( 'writes text attributes', () => {
+			root.appendChildren( [
+				new Text( 'foo', { bold: true } ),
+				'bar',
+				new Text( 'bom', { bold: true, italic: true } ),
+				new Element( 'a', null, [
+					new Text( 'pom', { underline: true, bold: true } )
+				] )
+			] );
+
+			expect( getData( document, 'main' ) ).to.equal(
+				'<$text bold=true>foo</$text>' +
+				'bar' +
+				'<$text bold=true italic=true>bom</$text>' +
+				'<a><$text bold=true underline=true>pom</$text></a>'
+			);
+		} );
+
+		describe( 'options.selection', () => {
+			let elA, elB;
+			const options = { selection: true };
+
+			beforeEach( () => {
+				elA = new Element( 'a' );
+				elB = new Element( 'b' );
+
+				root.appendChildren( [
+					elA,
+					'foo',
+					new Text( 'bar', { bold: true } ),
+					elB
+				] );
+			} );
+
+			it( 'writes selection collapsed in an element', () => {
+				selection.collapse( root );
+
+				expect( getData( document, 'main', options ) ).to.equal(
+					'<selection /><a></a>foo<$text bold=true>bar</$text><b></b>'
+				);
+			} );
+
+			it( 'writes selection collapsed in a text', () => {
+				selection.collapse( root, 3 );
+
+				expect( getData( document, 'main', options ) ).to.equal(
+					'<a></a>fo<selection />o<$text bold=true>bar</$text><b></b>'
+				);
+			} );
+
+			it( 'writes selection collapsed at the text left boundary', () => {
+				selection.collapse( elA, 'AFTER' );
+
+				expect( getData( document, 'main', options ) ).to.equal(
+					'<a></a><selection />foo<$text bold=true>bar</$text><b></b>'
+				);
+			} );
+
+			it( 'writes selection collapsed at the text right boundary', () => {
+				selection.collapse( elB, 'BEFORE' );
+
+				expect( getData( document, 'main', options ) ).to.equal(
+					'<a></a>foo<$text bold=true>bar</$text><selection bold=true /><b></b>'
+				);
+			} );
+
+			it( 'writes selection collapsed at the end of the root', () => {
+				selection.collapse( root, 'END' );
+
+				// Needed due to https://github.com/ckeditor/ckeditor5-engine/issues/320.
+				selection.clearAttributes();
+
+				expect( getData( document, 'main', options ) ).to.equal(
+					'<a></a>foo<$text bold=true>bar</$text><b></b><selection />'
+				);
+			} );
+
+			it( 'writes selection attributes', () => {
+				selection.collapse( root );
+				selection.setAttributesTo( { italic: true, bold: true } );
+
+				expect( getData( document, 'main', options ) ).to.equal(
+					'<selection bold=true italic=true /><a></a>foo<$text bold=true>bar</$text><b></b>'
+				);
+			} );
+
+			it( 'writes selection collapsed selection in a text with attributes', () => {
+				selection.collapse( root, 5 );
+
+				expect( getData( document, 'main', options ) ).to.equal(
+					'<a></a>foo<$text bold=true>b<selection bold=true />ar</$text><b></b>'
+				);
+			} );
+
+			it( 'writes flat selection containing couple of nodes', () => {
+				selection.addRange(
+					Range.createFromParentsAndOffsets( root, 0, root, 4 )
+				);
+
+				expect( getData( document, 'main', options ) ).to.equal(
+					'<selection><a></a>foo</selection><$text bold=true>bar</$text><b></b>'
+				);
+			} );
+
+			it( 'writes flat selection within text', () => {
+				selection.addRange(
+					Range.createFromParentsAndOffsets( root, 2, root, 3 )
+				);
+
+				expect( getData( document, 'main', options ) ).to.equal(
+					'<a></a>f<selection>o</selection>o<$text bold=true>bar</$text><b></b>'
+				);
+			} );
+
+			it( 'writes multi-level selection', () => {
+				selection.addRange(
+					Range.createFromParentsAndOffsets( elA, 0, elB, 0 )
+				);
+
+				expect( getData( document, 'main', options ) ).to.equal(
+					'<a><selection></a>foo<$text bold=true>bar</$text><b></selection></b>'
+				);
+			} );
+
+			it( 'writes backward selection', () => {
+				selection.addRange(
+					Range.createFromParentsAndOffsets( elA, 0, elB, 0 ),
+					true
+				);
+
+				expect( getData( document, 'main', options ) ).to.equal(
+					'<a><selection backward></a>foo<$text bold=true>bar</$text><b></selection></b>'
+				);
+			} );
+		} );
+	} );
+
+	describe( 'setData', () => {
+		test( 'creates elements', {
+			data: '<a></a><b><c></c></b>'
+		} );
+
+		test( 'creates text nodes', {
+			data: 'foo<a>bar</a>bom'
+		} );
+
+		test( 'sets elements attributes', {
+			data: '<a foo=1 bar=true car="x y"><b x="y"></b></a>',
+			output: '<a bar=true car="x y" foo=1><b x="y"></b></a>',
+			check() {
+				expect( root.getChild( 0 ).getAttribute( 'car' ) ).to.equal( 'x y' );
+			}
+		} );
+
+		test( 'sets complex attributes', {
+			data: '<a foo={"a":1,"b":"c"}></a>',
+			check() {
+				expect( root.getChild( 0 ).getAttribute( 'foo' ) ).to.have.property( 'a', 1 );
+			}
+		} );
+
+		test( 'sets text attributes', {
+			data: '<$text bold=true italic=true>foo</$text><$text bold=true>bar</$text>bom',
+			check() {
+				expect( root.getChildCount() ).to.equal( 9 );
+				expect( root.getChild( 0 ) ).to.have.property( 'character', 'f' );
+				expect( root.getChild( 0 ).getAttribute( 'italic' ) ).to.equal( true );
+			}
+		} );
+
+		it( 'throws when unexpected closing tag', () => {
+			expect( () => {
+				setData( document, 'main', '<a><b></a></b>' );
+			} ).to.throw();
+		} );
+
+		it( 'throws when unexpected attribute', () => {
+			expect( () => {
+				setData( document, 'main', '<a ?></a>' );
+			} ).to.throw();
+		} );
+
+		it( 'throws when incorrect tag', () => {
+			expect( () => {
+				setData( document, 'main', '<a' );
+			} ).to.throw();
+		} );
+
+		it( 'throws when missing closing tag', () => {
+			expect( () => {
+				setData( document, 'main', '<a><b></b>' );
+			} ).to.throw();
+		} );
+
+		it( 'throws when missing opening tag for text', () => {
+			expect( () => {
+				setData( document, 'main', '</$text>' );
+			} ).to.throw();
+		} );
+
+		it( 'throws when missing closing tag for text', () => {
+			expect( () => {
+				setData( document, 'main', '<$text>' );
+			} ).to.throw();
+		} );
+
+		describe( 'selection', () => {
+			const getDataOptions = { selection: true };
+
+			test( 'sets collapsed selection in an element', {
+				data: '<a><selection /></a>',
+				getDataOptions,
+				check() {
+					expect( document.selection.getFirstPosition().parent ).to.have.property( 'name', 'a' );
+				}
+			} );
+
+			test( 'sets collapsed selection between elements', {
+				data: '<a></a><selection /><b></b>',
+				getDataOptions
+			} );
+
+			test( 'sets collapsed selection before a text', {
+				data: '<a></a><selection />foo',
+				getDataOptions
+			} );
+
+			test( 'sets collapsed selection after a text', {
+				data: 'foo<selection />',
+				getDataOptions
+			} );
+
+			test( 'sets collapsed selection within a text', {
+				data: 'foo<selection />bar',
+				getDataOptions,
+				check() {
+					expect( root.getChildCount() ).to.equal( 6 );
+				}
+			} );
+
+			test( 'sets selection attributes', {
+				data: 'foo<selection bold=true italic=true />bar',
+				getDataOptions,
+				check() {
+					expect( selection.getAttribute( 'italic' ) ).to.be.true;
+				}
+			} );
+
+			test( 'sets collapsed selection between text and text with attributes', {
+				data: 'foo<selection /><$text bold=true>bar</$text>',
+				getDataOptions,
+				check() {
+					expect( root.getChildCount() ).to.equal( 6 );
+					expect( document.selection.getAttribute( 'bold' ) ).to.be.undefined;
+				}
+			} );
+
+			test( 'sets selection containing an element', {
+				data: 'x<selection><a></a></selection>',
+				getDataOptions
+			} );
+
+			test( 'sets selection with attribute containing an element', {
+				data: 'x<selection bold=true><a></a></selection>',
+				getDataOptions
+			} );
+
+			test( 'sets a backward selection containing an element', {
+				data: 'x<selection backward bold=true><a></a></selection>',
+				getDataOptions
+			} );
+
+			test( 'sets selection within a text', {
+				data: 'x<selection bold=true>y</selection>z',
+				getDataOptions
+			} );
+
+			test( 'sets selection within a text with different attributes', {
+				data: '<$text bold=true>fo<selection bold=true>o</$text>ba</selection>r',
+				getDataOptions
+			} );
+
+			it( 'throws when missing selection start', () => {
+				expect( () => {
+					setData( document, 'main', 'foo</selection>' );
+				} ).to.throw();
+			} );
+
+			it( 'throws when missing selection end', () => {
+				expect( () => {
+					setData( document, 'main', '<selection>foo' );
+				} ).to.throw();
+			} );
+		} );
+
+		function test( title, options ) {
+			it( title, () => {
+				let output = options.output || options.data;
+
+				setData( document, 'main', options.data, options.setDataOptions );
+
+				expect( getData( document, 'main', options.getDataOptions ) ).to.equal( output );
+
+				if ( options.check ) {
+					options.check();
+				}
+			} );
+		}
+	} );
+} );

+ 356 - 0
packages/ckeditor5-engine/tests/_utils/model.js

@@ -0,0 +1,356 @@
+/**
+ * @license Copyright (c) 2003-20'INSERT'6, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import TreeWalker from '/ckeditor5/engine/treemodel/treewalker.js';
+import Range from '/ckeditor5/engine/treemodel/range.js';
+import Position from '/ckeditor5/engine/treemodel/position.js';
+import Text from '/ckeditor5/engine/treemodel/text.js';
+import Element from '/ckeditor5/engine/treemodel/element.js';
+
+/**
+ * Writes the contents of the document to an HTML-like string.
+ *
+ * @param {engine.treeModel.Document} document
+ * @param {String} rootName
+ * @param {Object} [options]
+ * @param {Boolean} [options.selection] Whether to write the selection.
+ * @returns {String} The stringified data.
+ */
+export function getData( document, rootName, options ) {
+	const walker = new TreeWalker( {
+		boundaries: Range.createFromElement( document.getRoot( rootName ) )
+	} );
+	let ret = '';
+	let lastPosition;
+	const selection = document.selection;
+
+	options = options || {};
+
+	for ( let value of walker ) {
+		if ( options.selection ) {
+			ret += writeSelection( value.previousPosition, selection );
+		}
+
+		ret += writeItem( value, selection, options );
+
+		lastPosition = value.nextPosition;
+	}
+
+	if ( options.selection ) {
+		ret += writeSelection( lastPosition, selection );
+	}
+
+	return ret;
+}
+
+/**
+ * Sets the contents of the model and the selection in it.
+ *
+ * @param {engine.treeModel.Document} document
+ * @param {String} rootName
+ * @param {String} data
+ */
+export function setData( document, rootName, data ) {
+	let appendTo = document.getRoot( rootName );
+	const path = [];
+	let selectionStart, selectionEnd, selectionAttributes, textAttributes;
+
+	const handlers = {
+		text( token ) {
+			appendTo.appendChildren( new Text( token.text, textAttributes ) );
+		},
+
+		textStart( token ) {
+			textAttributes = token.attributes;
+			path.push( '$text' );
+		},
+
+		textEnd() {
+			if ( path.pop() != '$text' ) {
+				throw new Error( 'Parse error - unexpected closing tag.' );
+			}
+
+			textAttributes = null;
+		},
+
+		openingTag( token ) {
+			let el = new Element( token.name, token.attributes );
+			appendTo.appendChildren( el );
+
+			appendTo = el;
+
+			path.push( token.name );
+		},
+
+		closingTag( token ) {
+			if ( path.pop() != token.name ) {
+				throw new Error( 'Parse error - unexpected closing tag.' );
+			}
+
+			appendTo = appendTo.parent;
+		},
+
+		collapsedSelection( token ) {
+			document.selection.collapse( appendTo, 'END' );
+			document.selection.setAttributesTo( token.attributes );
+		},
+
+		selectionStart( token ) {
+			selectionStart = Position.createFromParentAndOffset( appendTo, appendTo.getChildCount() );
+			selectionAttributes = token.attributes;
+		},
+
+		selectionEnd() {
+			if ( !selectionStart ) {
+				throw new Error( 'Parse error - missing selection start' );
+			}
+
+			selectionEnd = Position.createFromParentAndOffset( appendTo, appendTo.getChildCount() );
+
+			document.selection.setRanges(
+				[ new Range( selectionStart, selectionEnd ) ],
+				selectionAttributes.backward
+			);
+
+			delete selectionAttributes.backward;
+
+			document.selection.setAttributesTo( selectionAttributes );
+		}
+	};
+
+	for ( let token of tokenize( data ) ) {
+		handlers[ token.type ]( token );
+	}
+
+	if ( path.length ) {
+		throw new Error( 'Parse error - missing closing tags: ' + path.join( ', ' ) + '.' );
+	}
+
+	if ( selectionStart && !selectionEnd ) {
+		throw new Error( 'Parse error - missing selection end.' );
+	}
+}
+
+// -- getData helpers ---------------------------------------------------------
+
+function writeItem( walkerValue, selection, options ) {
+	const type = walkerValue.type;
+	const item = walkerValue.item;
+
+	if ( type == 'ELEMENT_START' ) {
+		let attrs = writeAttributes( item.getAttributes() );
+
+		if ( attrs ) {
+			return `<${ item.name } ${ attrs }>`;
+		}
+
+		return `<${ item.name }>`;
+	}
+
+	if ( type == 'ELEMENT_END' ) {
+		return `</${ item.name }>`;
+	}
+
+	return writeText( walkerValue, selection, options );
+}
+
+function writeText( walkerValue, selection, options ) {
+	const item = walkerValue.item;
+	const attrs = writeAttributes( item.getAttributes() );
+	let text = Array.from( item.text );
+
+	if ( options.selection ) {
+		const startIndex = walkerValue.previousPosition.offset + 1;
+		const endIndex = walkerValue.nextPosition.offset - 1;
+		let index = startIndex;
+
+		while ( index <= endIndex ) {
+			// Add the selection marker without changing any indexes, so if second marker must be added
+			// in the same loop it does not blow up.
+			text[ index - startIndex ] +=
+				writeSelection( Position.createFromParentAndOffset( item.commonParent, index ), selection );
+
+			index++;
+		}
+	}
+
+	text = text.join( '' );
+
+	if ( attrs ) {
+		return `<$text ${ attrs }>${ text }</$text>`;
+	}
+
+	return text;
+}
+
+function writeAttributes( attrs ) {
+	attrs = Array.from( attrs );
+
+	return attrs.map( attr => attr[ 0 ] + '=' + JSON.stringify( attr[ 1 ] ) ).sort().join( ' ' );
+}
+
+function writeSelection( currentPosition, selection ) {
+	// TODO: This function obviously handles only the first range.
+	const range = selection.getFirstRange();
+
+	// Handle end of the selection.
+	if ( !selection.isCollapsed && range.end.compareWith( currentPosition ) == 'SAME' ) {
+		return '</selection>';
+	}
+
+	// Handle no match.
+	if ( range.start.compareWith( currentPosition ) != 'SAME' ) {
+		return '';
+	}
+
+	// Handle beginning of the selection.
+
+	let ret = '<selection';
+	const attrs = writeAttributes( selection.getAttributes() );
+
+	// TODO: Once we'll support multiple ranges this will need to check which range it is.
+	if ( selection.isBackward ) {
+		ret += ' backward';
+	}
+
+	if ( attrs ) {
+		ret += ' ' + attrs;
+	}
+
+	ret += ( selection.isCollapsed ? ' />' : '>' );
+
+	return ret;
+}
+
+// -- setData helpers ---------------------------------------------------------
+
+const patterns = {
+	selection: /^<(\/?selection)( [^>]*)?>/,
+	tag: /^<([^>]+)>/,
+	text: /^[^<]+/
+};
+
+const handlers = {
+	selection( match ) {
+		const tagName = match[ 1 ];
+		const tagExtension = match[ 2 ] || '';
+
+		if ( tagName[ 0 ] == '/' ) {
+			return {
+				type: 'selectionEnd'
+			};
+		}
+
+		if ( tagExtension.endsWith( ' /' ) ) {
+			return {
+				type: 'collapsedSelection',
+				attributes: parseAttributes( tagExtension.slice( 1, -2 ) )
+			};
+		}
+
+		return {
+			type: 'selectionStart',
+			attributes: parseAttributes( tagExtension.slice( 1 ) )
+		};
+	},
+
+	tag( match ) {
+		const tagContents = match[ 1 ].split( /\s+/ );
+		const tagName = tagContents.shift();
+		const attrs = tagContents.join( ' ' );
+
+		if ( tagName == '/$text' ) {
+			return {
+				type: 'textEnd'
+			};
+		}
+
+		if ( tagName == '$text' ) {
+			return {
+				type: 'textStart',
+				attributes: parseAttributes( attrs )
+			};
+		}
+
+		if ( tagName[ 0 ] == '/' ) {
+			return {
+				type: 'closingTag',
+				name: tagName.slice( 1 )
+			};
+		}
+
+		return {
+			type: 'openingTag',
+			name: tagName,
+			attributes: parseAttributes( attrs )
+		};
+	},
+
+	text( match ) {
+		return {
+			type: 'text',
+			text: match[ 0 ]
+		};
+	}
+};
+
+function *tokenize( data ) {
+	while ( data ) {
+		const consumed = consumeNextToken( data );
+
+		data = consumed.data;
+		yield consumed.token;
+	}
+}
+
+function consumeNextToken( data ) {
+	let match;
+
+	for ( let patternName in patterns ) {
+		match = data.match( patterns[ patternName ] );
+
+		if ( match ) {
+			data = data.slice( match[ 0 ].length );
+
+			return {
+				token: handlers[ patternName ]( match ),
+				data
+			};
+		}
+	}
+
+	throw new Error( 'Parse error - unpexpected token: ' + data + '.' );
+}
+
+function parseAttributes( attrsString ) {
+	attrsString = attrsString.trim();
+
+	if ( !attrsString  ) {
+		return {};
+	}
+
+	const pattern = /(?:backward|(\w+)=("[^"]+"|[^\s]+))\s*/;
+	const attrs = {};
+
+	while ( attrsString ) {
+		let match = attrsString.match( pattern );
+
+		if ( !match ) {
+			throw new Error( 'Parse error - unexpected token: ' + attrsString + '.' );
+		}
+
+		if ( match[ 0 ].trim() == 'backward' ) {
+			attrs.backward = true;
+		} else {
+			attrs[ match[ 1 ] ] = JSON.parse( match[ 2 ] );
+		}
+
+		attrsString = attrsString.slice( match[ 0 ].length );
+	}
+
+	return attrs;
+}

+ 9 - 0
packages/ckeditor5-engine/tests/treemodel/selection.js

@@ -59,6 +59,7 @@ describe( 'Selection', () => {
 		expect( ranges.length ).to.equal( 1 );
 		expect( selection.anchor.isEqual( new Position( root, [ 0 ] ) ) ).to.be.true;
 		expect( selection.focus.isEqual( new Position( root, [ 0 ] ) ) ).to.be.true;
+		expect( selection ).to.have.property( 'isBackward', false );
 		expect( selection._attrs ).to.be.instanceof( Map );
 		expect( selection._attrs.size ).to.equal( 0 );
 	} );
@@ -119,6 +120,14 @@ describe( 'Selection', () => {
 			expect( selection.focus.path ).to.deep.equal( [ 2 ] );
 		} );
 
+		it( 'should set isBackward', () => {
+			selection.addRange( range, true );
+			expect( selection ).to.have.property( 'isBackward', true );
+
+			selection.addRange( liveRange );
+			expect( selection ).to.have.property( 'isBackward', false );
+		} );
+
 		it( 'should return a copy of (not a reference to) array of stored ranges', () => {
 			selection.addRange( liveRange );