8
0
Просмотр исходного кода

Merge pull request #902 from ckeditor/t/828

Feature: Introduced `dev-utils.DeltaReplayer`. Introduced new logging methods in `dev-utils.enableEngineDebug`. Closes #828.
Szymon Cofalik 8 лет назад
Родитель
Сommit
8c7a8a7087

+ 135 - 0
packages/ckeditor5-engine/src/dev-utils/deltareplayer.js

@@ -0,0 +1,135 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module engine/dev-utils/deltareplayer
+ */
+
+/* global setTimeout, console */
+
+import DeltaFactory from '../model/delta/deltafactory';
+
+/**
+ * DeltaReplayer is a dev-tool created for easily replaying operations on the document from stringified deltas.
+ */
+export default class DeltaReplayer {
+	/**
+	 * @param {module:engine/model/document~Document} document Document to reply deltas on.
+	 * @param {String} logSeparator Separator between deltas.
+	 * @param {String} stringifiedDeltas Deltas to replay.
+	 */
+	constructor( document, logSeparator, stringifiedDeltas ) {
+		this._document = document;
+		this._logSeparator = logSeparator;
+		this.setStringifiedDeltas( stringifiedDeltas );
+	}
+
+	/**
+	 * Parses given string containing stringified deltas and sets parsed deltas as deltas to reply.
+	 *
+	 * @param {String} stringifiedDeltas Stringified deltas to replay.
+	 */
+	setStringifiedDeltas( stringifiedDeltas ) {
+		if ( stringifiedDeltas === '' ) {
+			this._deltasToReplay = [];
+
+			return;
+		}
+
+		this._deltasToReplay = stringifiedDeltas
+			.split( this._logSeparator )
+			.map( stringifiedDelta => JSON.parse( stringifiedDelta ) );
+	}
+
+	/**
+	 * Returns deltas to reply.
+	 *
+	 * @returns {Array.<module:engine/model/delta/delta~Delta>}
+	 */
+	getDeltasToReplay() {
+		return this._deltasToReplay;
+	}
+
+	/**
+	 * Applies all deltas with delay between actions.
+	 *
+	 * @param {Number} timeInterval Time between applying deltas.
+	 * @returns {Promise}
+	 */
+	play( timeInterval = 1000 ) {
+		const deltaReplayer = this;
+
+		return new Promise( ( res ) => {
+			play();
+
+			function play() {
+				if ( deltaReplayer._deltasToReplay.length === 0 ) {
+					return res();
+				}
+
+				deltaReplayer.applyNextDelta().then( () => {
+					setTimeout( play, timeInterval );
+				}, res );
+			}
+		} );
+	}
+
+	/**
+	 * Applies `numberOfDeltas` deltas, beginning after the last applied delta (or first delta, if no deltas were applied).
+	 *
+	 * @param {Number} numberOfDeltas Number of deltas to apply.
+	 * @returns {Promise}
+	 */
+	applyDeltas( numberOfDeltas ) {
+		if ( numberOfDeltas <= 0 ) {
+			return;
+		}
+
+		return this.applyNextDelta()
+			.then( () => this.applyDeltas( numberOfDeltas - 1 ) )
+			.catch( err => console.warn( err ) );
+	}
+
+	/**
+	 * Applies all deltas to replay at once.
+	 *
+	 * @returns {Promise}
+	 */
+	applyAllDeltas() {
+		return this.applyNextDelta()
+			.then( () => this.applyAllDeltas() )
+			.catch( () => {} );
+	}
+
+	/**
+	 * Applies the next delta to replay.
+	 *
+	 * @returns {Promise}
+	 */
+	applyNextDelta() {
+		const document = this._document;
+
+		return new Promise( ( res, rej ) => {
+			document.enqueueChanges( () => {
+				const jsonDelta = this._deltasToReplay.shift();
+
+				if ( !jsonDelta ) {
+					return rej( new Error( 'No deltas to replay' ) );
+				}
+
+				const delta = DeltaFactory.fromJSON( jsonDelta, this._document );
+
+				const batch = document.batch();
+				batch.addDelta( delta );
+
+				for ( const operation of delta.operations ) {
+					document.applyOperation( operation );
+				}
+
+				res();
+			} );
+		} );
+	}
+}

+ 47 - 2
packages/ckeditor5-engine/src/dev-utils/enableenginedebug.js

@@ -46,11 +46,16 @@ import ViewDocumentFragment from '../view/documentfragment';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import Editor from '@ckeditor/ckeditor5-core/src/editor/editor';
 
+import DeltaReplayer from './deltareplayer';
+
 const treeDump = Symbol( '_treeDump' );
 
 // Maximum number of stored states of model and view document.
 const maxTreeDumpLength = 20;
 
+// Separator used to separate stringified deltas
+const LOG_SEPARATOR = '\n----------------\n';
+
 // Specified whether debug tools were already enabled.
 let enabled = false;
 
@@ -96,14 +101,17 @@ let log = console.log;
  * @param {Function} [logger] Function used to log messages. By default messages are logged to console.
  * @returns {module:engine/dev-utils/enableenginedebug~DebugPlugin} Plugin to be loaded in the editor.
  */
-export default function enableEngineDebug( logger = console.log ) {
-	log = logger;
+export default function enableEngineDebug( logger ) {
+	if ( logger ) {
+		log = logger;
+	}
 
 	if ( !enabled ) {
 		enabled = true;
 
 		enableLoggingTools();
 		enableDocumentTools();
+		enableReplayerTools();
 	}
 
 	return DebugPlugin;
@@ -448,12 +456,49 @@ function enableLoggingTools() {
 	};
 }
 
+function enableReplayerTools() {
+	const _modelDocumentApplyOperation = ModelDocument.prototype.applyOperation;
+
+	ModelDocument.prototype.applyOperation = function( operation ) {
+		if ( !this._lastDelta ) {
+			this._appliedDeltas = [];
+		} else if ( this._lastDelta !== operation.delta ) {
+			this._appliedDeltas.push( this._lastDelta.toJSON() );
+		}
+
+		this._lastDelta = operation.delta;
+
+		_modelDocumentApplyOperation.call( this, operation );
+	};
+
+	ModelDocument.prototype.getAppliedDeltas = function() {
+		// No deltas has been applied yet, return empty string.
+		if ( !this._lastDelta ) {
+			return '';
+		}
+
+		const appliedDeltas = this._appliedDeltas.concat( this._lastDelta.toJSON() );
+
+		return appliedDeltas.map( JSON.stringify ).join( LOG_SEPARATOR );
+	};
+
+	ModelDocument.prototype.createReplayer = function( stringifiedDeltas ) {
+		return new DeltaReplayer( this, LOG_SEPARATOR, stringifiedDeltas );
+	};
+}
+
 function enableDocumentTools() {
 	const _modelDocumentApplyOperation = ModelDocument.prototype.applyOperation;
 
 	ModelDocument.prototype.applyOperation = function( operation ) {
 		log( 'Applying ' + operation );
 
+		if ( !this._operationLogs ) {
+			this._operationLogs = [];
+		}
+
+		this._operationLogs.push( JSON.stringify( operation.toJSON() ) );
+
 		_modelDocumentApplyOperation.call( this, operation );
 	};
 

+ 200 - 0
packages/ckeditor5-engine/tests/dev-utils/deltareplayer.js

@@ -0,0 +1,200 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global console */
+
+import DeltaReplayer from '../../src/dev-utils/deltareplayer';
+import Document from '../../src/model/document';
+
+describe( 'DeltaReplayer', () => {
+	const sandbox = sinon.sandbox.create();
+	let stubs;
+
+	beforeEach( () => {
+		stubs = {
+			consoleWarn: sandbox.stub( console, 'warn' ),
+		};
+	} );
+
+	afterEach( () => {
+		sandbox.restore();
+	} );
+
+	describe( 'constructor()', () => {
+		it( 'should be able to initialize replayer without deltas', () => {
+			const doc = getDocument();
+			const stringifiedDeltas = '';
+			const deltaReplayer = new DeltaReplayer( doc, '---', stringifiedDeltas );
+
+			expect( deltaReplayer.getDeltasToReplay() ).to.deep.equal( [] );
+		} );
+
+		it( 'should be able to initialize replayer with deltas', () => {
+			const doc = getDocument();
+			const delta = getFirstDelta();
+
+			const deltaReplayer = new DeltaReplayer( doc, '---', JSON.stringify( delta ) );
+
+			expect( deltaReplayer.getDeltasToReplay() ).to.deep.equal( [ delta ] );
+		} );
+	} );
+
+	describe( 'applyNextDelta()', () => {
+		it( 'should remove first delta from stack', () => {
+			const doc = getDocument();
+			const delta = getFirstDelta();
+
+			const deltaReplayer = new DeltaReplayer( doc, '---', JSON.stringify( delta ) );
+
+			return deltaReplayer.applyNextDelta().then( () => {
+				expect( deltaReplayer.getDeltasToReplay() ).to.deep.equal( [] );
+			} );
+		} );
+
+		it( 'should apply first delta on the document', () => {
+			const doc = getDocument();
+			const delta = getFirstDelta();
+
+			const deltaReplayer = new DeltaReplayer( doc, '---', JSON.stringify( delta ) );
+
+			return deltaReplayer.applyNextDelta().then( () => {
+				expect( Array.from( doc.getRoot().getChildren() ).length ).to.equal( 1 );
+			} );
+		} );
+
+		it( 'should throw an error if 0 deltas are provided', () => {
+			const doc = getDocument();
+			const deltaReplayer = new DeltaReplayer( doc, '---', '' );
+
+			return deltaReplayer.applyNextDelta().then( () => {
+				throw new Error( 'This should throw an error' );
+			}, ( err ) => {
+				expect( err instanceof Error ).to.equal( true );
+				expect( err.message ).to.equal( 'No deltas to replay' );
+			} );
+		} );
+	} );
+
+	describe( 'applyAllDeltas()', () => {
+		it( 'should apply all deltas on the document', () => {
+			const doc = getDocument();
+
+			const stringifiedDeltas = [ getFirstDelta(), getSecondDelta() ]
+				.map( d => JSON.stringify( d ) )
+				.join( '---' );
+
+			const deltaReplayer = new DeltaReplayer( doc, '---', stringifiedDeltas );
+
+			return deltaReplayer.applyAllDeltas().then( () => {
+				expect( Array.from( doc.getRoot().getChildren() ).length ).to.equal( 2 );
+				expect( deltaReplayer.getDeltasToReplay().length ).to.equal( 0 );
+			} );
+		} );
+	} );
+
+	describe( 'applyDeltas()', () => {
+		it( 'should apply certain number of deltas on the document', () => {
+			const doc = getDocument();
+
+			const stringifiedDeltas = [ getFirstDelta(), getSecondDelta() ]
+				.map( d => JSON.stringify( d ) )
+				.join( '---' );
+
+			const deltaReplayer = new DeltaReplayer( doc, '---', stringifiedDeltas );
+
+			return deltaReplayer.applyDeltas( 1 ).then( () => {
+				expect( Array.from( doc.getRoot().getChildren() ).length ).to.equal( 1 );
+				expect( deltaReplayer.getDeltasToReplay().length ).to.equal( 1 );
+			} );
+		} );
+
+		it( 'should not throw an error if the number of deltas is lower than number of expected deltas to replay', () => {
+			const doc = getDocument();
+
+			const stringifiedDeltas = [ getFirstDelta(), getSecondDelta() ]
+				.map( d => JSON.stringify( d ) )
+				.join( '---' );
+
+			const deltaReplayer = new DeltaReplayer( doc, '---', stringifiedDeltas );
+
+			return deltaReplayer.applyDeltas( 3 ).then( () => {
+				expect( Array.from( doc.getRoot().getChildren() ).length ).to.equal( 2 );
+				expect( deltaReplayer.getDeltasToReplay().length ).to.equal( 0 );
+				sinon.assert.calledWithExactly( stubs.consoleWarn, new Error( 'No deltas to replay' ) );
+			} );
+		} );
+	} );
+
+	describe( 'play()', () => {
+		it( 'should play deltas with time interval', () => {
+			const doc = getDocument();
+
+			const stringifiedDeltas = [ getFirstDelta(), getSecondDelta() ]
+				.map( d => JSON.stringify( d ) )
+				.join( '---' );
+
+			const deltaReplayer = new DeltaReplayer( doc, '---', stringifiedDeltas );
+
+			return deltaReplayer.play( 0 ).then( () => {
+				expect( deltaReplayer.getDeltasToReplay().length ).to.equal( 0 );
+			} );
+		} );
+
+		it( 'should work with default time interval', () => {
+			const doc = getDocument();
+
+			const deltaReplayer = new DeltaReplayer( doc, '---', '' );
+
+			return deltaReplayer.play();
+		} );
+	} );
+} );
+
+function getDocument() {
+	const doc = new Document();
+	doc.createRoot( 'main' );
+
+	return doc;
+}
+
+function getFirstDelta() {
+	return {
+		operations: [ {
+			baseVersion: 0,
+			position: {
+				root: 'main',
+				path: [ 0 ]
+			},
+			nodes: [ {
+				name: 'heading1',
+				children: [ {
+					data: 'The great world of open Web standards'
+				} ]
+			} ],
+			__className: 'engine.model.operation.InsertOperation'
+		} ],
+		__className: 'engine.model.delta.InsertDelta'
+	};
+}
+
+function getSecondDelta() {
+	return {
+		operations: [ {
+			baseVersion: 1,
+			position: {
+				root: 'main',
+				path: [ 1 ]
+			},
+			nodes: [ {
+				name: 'heading2',
+				children: [ {
+					data: 'The great world of open Web standards'
+				} ]
+			} ],
+			__className: 'engine.model.operation.InsertOperation'
+		} ],
+		__className: 'engine.model.delta.InsertDelta'
+	};
+}

+ 51 - 0
packages/ckeditor5-engine/tests/dev-utils/enableenginedebug.js

@@ -713,6 +713,57 @@ describe( 'debug tools', () => {
 		} );
 	} );
 
+	describe( 'should provide methods for delta replayer', () => {
+		it( 'getAppliedDeltas()', () => {
+			const modelDoc = new ModelDocument();
+
+			expect( modelDoc.getAppliedDeltas() ).to.equal( '' );
+
+			const otherRoot = modelDoc.createRoot( '$root', 'otherRoot' );
+			const firstEle = new ModelElement( 'paragraph' );
+			const removedEle = new ModelElement( 'paragraph', null, [ new ModelText( 'foo' ) ] );
+
+			otherRoot.appendChildren( [ firstEle, removedEle ] );
+
+			const delta = new MergeDelta();
+			const move = new MoveOperation( ModelPosition.createAt( removedEle, 0 ), 3, ModelPosition.createAt( firstEle, 0 ), 0 );
+			const remove = new RemoveOperation( ModelPosition.createBefore( removedEle ), 1, 1 );
+
+			delta.addOperation( move );
+			delta.addOperation( remove );
+
+			modelDoc.applyOperation( move );
+			modelDoc.applyOperation( remove );
+
+			const stringifiedDeltas = modelDoc.getAppliedDeltas();
+
+			expect( stringifiedDeltas ).to.equal( JSON.stringify( delta.toJSON() ) );
+		} );
+
+		it( 'createReplayer()', () => {
+			const modelDoc = new ModelDocument();
+
+			const otherRoot = modelDoc.createRoot( '$root', 'otherRoot' );
+			const firstEle = new ModelElement( 'paragraph' );
+			const removedEle = new ModelElement( 'paragraph', null, [ new ModelText( 'foo' ) ] );
+
+			otherRoot.appendChildren( [ firstEle, removedEle ] );
+
+			const delta = new MergeDelta();
+			const move = new MoveOperation( ModelPosition.createAt( removedEle, 0 ), 3, ModelPosition.createAt( firstEle, 0 ), 0 );
+			const remove = new RemoveOperation( ModelPosition.createBefore( removedEle ), 1, 1 );
+
+			delta.addOperation( move );
+			delta.addOperation( remove );
+
+			const stringifiedDeltas = JSON.stringify( delta.toJSON() );
+
+			const deltaReplayer = modelDoc.createReplayer( stringifiedDeltas );
+
+			expect( deltaReplayer.getDeltasToReplay() ).to.deep.equal( [ JSON.parse( stringifiedDeltas ) ] );
+		} );
+	} );
+
 	function expectLog( expectedLogMsg ) {
 		expect( log.calledWithExactly( expectedLogMsg ) ).to.be.true;
 		log.reset();