Jelajahi Sumber

Merge pull request #16 from ckeditor/t/12

Other: Improved call frequency. Closes #9. Closes #10. Closes #12. Closes ckeditor/ckeditor5#1158.
Piotr Jasiun 7 tahun lalu
induk
melakukan
9e36717c21

+ 2 - 1
packages/ckeditor5-autosave/package.json

@@ -10,7 +10,8 @@
   ],
   ],
   "dependencies": {
   "dependencies": {
     "@ckeditor/ckeditor5-utils": "^10.2.0",
     "@ckeditor/ckeditor5-utils": "^10.2.0",
-    "@ckeditor/ckeditor5-core": "^11.0.0"
+    "@ckeditor/ckeditor5-core": "^11.0.0",
+    "lodash-es": "^4.17.10"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@ckeditor/ckeditor5-engine": "^10.2.0",
     "@ckeditor/ckeditor5-engine": "^10.2.0",

+ 91 - 59
packages/ckeditor5-autosave/src/autosave.js

@@ -10,7 +10,9 @@
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import PendingActions from '@ckeditor/ckeditor5-core/src/pendingactions';
 import PendingActions from '@ckeditor/ckeditor5-core/src/pendingactions';
 import DomEmitterMixin from '@ckeditor/ckeditor5-utils/src/dom/emittermixin';
 import DomEmitterMixin from '@ckeditor/ckeditor5-utils/src/dom/emittermixin';
-import throttle from './throttle';
+import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
+import mix from '@ckeditor/ckeditor5-utils/src/mix';
+import { debounce } from 'lodash-es';
 
 
 /* globals window */
 /* globals window */
 
 
@@ -62,6 +64,12 @@ export default class Autosave extends Plugin {
 	constructor( editor ) {
 	constructor( editor ) {
 		super( editor );
 		super( editor );
 
 
+		const config = editor.config.get( 'autosave' ) || {};
+
+		// A minimum amount of time that needs to pass after the last action.
+		// After that time the provided save callbacks are being called.
+		const waitingTime = config.waitingTime || 1000;
+
 		/**
 		/**
 		 * The adapter is an object with a `save()` method. That method will be called whenever
 		 * The adapter is an object with a `save()` method. That method will be called whenever
 		 * the data changes. It might be called some time after the change,
 		 * the data changes. It might be called some time after the change,
@@ -71,17 +79,31 @@ export default class Autosave extends Plugin {
 		 */
 		 */
 
 
 		/**
 		/**
-		 * Throttled save method.
+		 * The state of this plugin.
+		 *
+		 * The plugin can be in the following states:
+		 *
+		 * * synchronized - when all changes are saved
+		 * * waiting - when the plugin is waiting for other changes before calling `adapter#save()` and `config.autosave.save()`
+		 * * saving - when the provided save method is called and the plugin waits for the response
+		 *
+		 * @member {'synchronized'|'waiting'|'saving'} #state
+		 */
+		this.set( 'state', 'synchronized' );
+
+		/**
+		 * Debounced save method. The `save` method is called the specified `waitingTime` after the `debouncedSave` is called,
+		 * unless new action happens in the meantime.
 		 *
 		 *
-		 * @protected
+		 * @private
 		 * @type {Function}
 		 * @type {Function}
 		 */
 		 */
-		this._throttledSave = throttle( this._save.bind( this ), 1000 );
+		this._debouncedSave = debounce( this._save.bind( this ), waitingTime );
 
 
 		/**
 		/**
 		 * Last document version.
 		 * Last document version.
 		 *
 		 *
-		 * @protected
+		 * @private
 		 * @type {Number}
 		 * @type {Number}
 		 */
 		 */
 		this._lastDocumentVersion = editor.model.document.version;
 		this._lastDocumentVersion = editor.model.document.version;
@@ -95,12 +117,12 @@ export default class Autosave extends Plugin {
 		this._domEmitter = Object.create( DomEmitterMixin );
 		this._domEmitter = Object.create( DomEmitterMixin );
 
 
 		/**
 		/**
-		 * Save action counter monitors number of actions.
+		 * The config of this plugins.
 		 *
 		 *
 		 * @private
 		 * @private
-		 * @type {Number}
+		 * @type {Object}
 		 */
 		 */
-		this._saveActionCounter = 0;
+		this._config = config;
 
 
 		/**
 		/**
 		 * An action that will be added to pending action manager for actions happening in that plugin.
 		 * An action that will be added to pending action manager for actions happening in that plugin.
@@ -109,14 +131,6 @@ export default class Autosave extends Plugin {
 		 * @member {Object} #_action
 		 * @member {Object} #_action
 		 */
 		 */
 
 
-		/**
-		 * Plugins' config.
-		 *
-		 * @private
-		 * @type {Object}
-		 */
-		this._config = editor.config.get( 'autosave' ) || {};
-
 		/**
 		/**
 		 * Editor's pending actions manager.
 		 * Editor's pending actions manager.
 		 *
 		 *
@@ -135,13 +149,24 @@ export default class Autosave extends Plugin {
 		this._pendingActions = editor.plugins.get( PendingActions );
 		this._pendingActions = editor.plugins.get( PendingActions );
 
 
 		this.listenTo( doc, 'change:data', () => {
 		this.listenTo( doc, 'change:data', () => {
-			this._incrementCounter();
+			if ( !this._saveCallbacks.length ) {
+				return;
+			}
+
+			if ( this.state == 'synchronized' ) {
+				this._action = this._pendingActions.add( this.editor.t( 'Saving changes' ) );
+				this.state = 'waiting';
 
 
-			const willOriginalFunctionBeCalled = this._throttledSave();
+				this._debouncedSave();
+			}
 
 
-			if ( !willOriginalFunctionBeCalled ) {
-				this._decrementCounter();
+			else if ( this.state == 'waiting' ) {
+				this._debouncedSave();
 			}
 			}
+
+			// If the plugin is in `saving` state, it will change its state later basing on the `document.version`.
+			// If the `document.version` will be higher than stored `#_lastDocumentVersion`, then it means, that some `change:data`
+			// event has fired in the meantime.
 		} );
 		} );
 
 
 		// Flush on the editor's destroy listener with the highest priority to ensure that
 		// Flush on the editor's destroy listener with the highest priority to ensure that
@@ -175,7 +200,7 @@ export default class Autosave extends Plugin {
 	 * @protected
 	 * @protected
 	 */
 	 */
 	_flush() {
 	_flush() {
-		this._throttledSave.flush();
+		this._debouncedSave.flush();
 	}
 	}
 
 
 	/**
 	/**
@@ -188,74 +213,62 @@ export default class Autosave extends Plugin {
 	_save() {
 	_save() {
 		const version = this.editor.model.document.version;
 		const version = this.editor.model.document.version;
 
 
-		const saveCallbacks = [];
-
-		if ( this.adapter && this.adapter.save ) {
-			saveCallbacks.push( this.adapter.save );
-		}
-
-		if ( this._config.save ) {
-			saveCallbacks.push( this._config.save );
-		}
-
 		// Change may not produce an operation, so the document's version
 		// Change may not produce an operation, so the document's version
 		// can be the same after that change.
 		// can be the same after that change.
 		if (
 		if (
 			version < this._lastDocumentVersion ||
 			version < this._lastDocumentVersion ||
-			!saveCallbacks.length ||
 			this.editor.state === 'initializing'
 			this.editor.state === 'initializing'
 		) {
 		) {
-			this._throttledSave.flush();
-			this._decrementCounter();
+			this._debouncedSave.cancel();
 
 
 			return;
 			return;
 		}
 		}
 
 
 		this._lastDocumentVersion = version;
 		this._lastDocumentVersion = version;
 
 
-		// Wait one promise cycle to be sure that:
-		// 1. The save method is always asynchronous.
-		// 2. Save callbacks are not called inside conversions or while editor's state changes.
+		this.state = 'saving';
+
+		// Wait one promise cycle to be sure that save callbacks are not called
+		// inside a conversion or when the editor's state changes.
 		Promise.resolve()
 		Promise.resolve()
 			.then( () => Promise.all(
 			.then( () => Promise.all(
-				saveCallbacks.map( cb => cb( this.editor ) )
+				this._saveCallbacks.map( cb => cb( this.editor ) )
 			) )
 			) )
 			.then( () => {
 			.then( () => {
-				this._decrementCounter();
+				if ( this.editor.model.document.version > this._lastDocumentVersion ) {
+					this.state = 'waiting';
+					this._debouncedSave();
+				} else {
+					this.state = 'synchronized';
+					this._pendingActions.remove( this._action );
+					this._action = null;
+				}
 			} );
 			} );
 	}
 	}
 
 
 	/**
 	/**
-	 * Increments counter and adds pending action if it not exists.
+	 * Save callbacks.
 	 *
 	 *
 	 * @private
 	 * @private
+	 * @type {Array.<Function>}
 	 */
 	 */
-	_incrementCounter() {
-		const t = this.editor.t;
-
-		this._saveActionCounter++;
+	get _saveCallbacks() {
+		const saveCallbacks = [];
 
 
-		if ( !this._action ) {
-			this._action = this._pendingActions.add( t( 'Saving changes' ) );
+		if ( this.adapter && this.adapter.save ) {
+			saveCallbacks.push( this.adapter.save );
 		}
 		}
-	}
 
 
-	/**
-	 * Decrements counter and removes pending action if counter is empty,
-	 * which means, that no new save action occurred.
-	 *
-	 * @private
-	 */
-	_decrementCounter() {
-		this._saveActionCounter--;
-
-		if ( this._saveActionCounter === 0 ) {
-			this._pendingActions.remove( this._action );
-			this._action = null;
+		if ( this._config.save ) {
+			saveCallbacks.push( this._config.save );
 		}
 		}
+
+		return saveCallbacks;
 	}
 	}
 }
 }
 
 
+mix( Autosave, ObservableMixin );
+
 /**
 /**
  * An interface that requires the `save()` method.
  * An interface that requires the `save()` method.
  *
  *
@@ -324,3 +337,22 @@ export default class Autosave extends Plugin {
  * @param {module:core/editor/editor~Editor} editor The editor instance.
  * @param {module:core/editor/editor~Editor} editor The editor instance.
  * @returns {Promise.<*>}
  * @returns {Promise.<*>}
  */
  */
+
+/**
+ * The minimum amount of time that need to pass after last action to call the provided callback.
+ *
+ *		ClassicEditor
+ *			.create( editorElement, {
+ *				autosave: {
+ *					save( editor ) {
+ *						return saveData( editor.getData() );
+ *					},
+ *					waitingTime: 2000
+ *				}
+ *			} );
+ *			.then( ... )
+ *			.catch( ... );
+ *
+ * @property module:autosave/autosave~AutosaveConfig#waitingTime
+ * @type {Number}
+ */

+ 0 - 68
packages/ckeditor5-autosave/src/throttle.js

@@ -1,68 +0,0 @@
-/**
- * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-/**
- * @module autosave/throttle
- */
-
-/* globals window */
-
-/**
- * Throttle function - a helper that provides ability to specify minimum time gap between calling the original function.
- * Comparing to the lodash implementation, this provides an information if calling the throttled function will result in
- * calling the original function.
- *
- * @param {Function} fn Original function that will be called.
- * @param {Number} wait Minimum amount of time between original function calls.
- */
-export default function throttle( fn, wait ) {
-	// Time in ms of the last call.
-	let lastCallTime = 0;
-
-	// Timeout id that enables stopping scheduled call.
-	let timeoutId = null;
-
-	// @returns {Boolean} `true` if the original function was or will be called.
-	function throttledFn() {
-		const now = Date.now();
-
-		// Cancel call, as the next call is scheduled.
-		if ( timeoutId ) {
-			return false;
-		}
-
-		// Call instantly, as the fn wasn't called within the `time` period.
-		if ( now > lastCallTime + wait ) {
-			call();
-			return true;
-		}
-
-		// Set timeout, so the fn will be called `time` ms after the last call.
-		timeoutId = window.setTimeout( call, lastCallTime + wait - now );
-
-		return true;
-	}
-
-	throttledFn.flush = flush;
-
-	function flush() {
-		if ( timeoutId ) {
-			window.clearTimeout( timeoutId );
-			call();
-		}
-
-		lastCallTime = 0;
-	}
-
-	// Calls the original function and updates internals.
-	function call() {
-		lastCallTime = Date.now();
-		timeoutId = null;
-
-		fn();
-	}
-
-	return throttledFn;
-}

+ 239 - 163
packages/ckeditor5-autosave/tests/autosave.js

@@ -13,9 +13,14 @@ import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import PendingActions from '@ckeditor/ckeditor5-core/src/pendingactions';
 import PendingActions from '@ckeditor/ckeditor5-core/src/pendingactions';
 
 
 describe( 'Autosave', () => {
 describe( 'Autosave', () => {
-	const sandbox = sinon.sandbox.create( { useFakeTimers: true } );
+	const sandbox = sinon.sandbox;
+
 	let editor, element, autosave;
 	let editor, element, autosave;
 
 
+	beforeEach( () => {
+		sandbox.useFakeTimers( { now: Date.now() } );
+	} );
+
 	afterEach( () => {
 	afterEach( () => {
 		sandbox.restore();
 		sandbox.restore();
 	} );
 	} );
@@ -39,9 +44,6 @@ describe( 'Autosave', () => {
 					editor = _editor;
 					editor = _editor;
 					editor.setData( data );
 					editor.setData( data );
 					autosave = editor.plugins.get( Autosave );
 					autosave = editor.plugins.get( Autosave );
-
-					// Clean autosave's state after setting data.
-					autosave._flush();
 				} );
 				} );
 		} );
 		} );
 
 
@@ -56,16 +58,22 @@ describe( 'Autosave', () => {
 		} );
 		} );
 
 
 		it( 'should allow plugin to work without defined adapter and without its config', () => {
 		it( 'should allow plugin to work without defined adapter and without its config', () => {
-			editor.model.change( writer => {
-				writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) );
-				editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection );
-			} );
+			expect( () => {
+				editor.model.change( writer => {
+					writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) );
+					editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection );
+				} );
 
 
-			autosave._flush();
+				sandbox.clock.tick( 1000 );
+			} ).to.not.throw();
+		} );
+
+		it( 'should start with the `synchronized` state', () => {
+			expect( autosave.state ).to.equal( 'synchronized' );
 		} );
 		} );
 	} );
 	} );
 
 
-	describe( 'config', () => {
+	describe( 'config.autosave.save', () => {
 		beforeEach( () => {
 		beforeEach( () => {
 			element = document.createElement( 'div' );
 			element = document.createElement( 'div' );
 			document.body.appendChild( element );
 			document.body.appendChild( element );
@@ -78,14 +86,12 @@ describe( 'Autosave', () => {
 					}
 					}
 				} )
 				} )
 				.then( _editor => {
 				.then( _editor => {
-					const data = '<p>paragraph1</p><p>paragraph2</p>';
-
 					editor = _editor;
 					editor = _editor;
-					editor.setData( data );
+
 					autosave = editor.plugins.get( Autosave );
 					autosave = editor.plugins.get( Autosave );
 
 
-					// Clean autosave's state after setting data.
-					autosave._flush();
+					const data = '<p>paragraph1</p><p>paragraph2</p>';
+					editor.setData( data );
 				} );
 				} );
 		} );
 		} );
 
 
@@ -103,12 +109,14 @@ describe( 'Autosave', () => {
 				editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection );
 				editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection );
 			} );
 			} );
 
 
-			return wait().then( () => {
+			sandbox.clock.tick( 1000 );
+
+			return Promise.resolve().then( () => {
 				sinon.assert.calledOnce( editor.config.get( 'autosave' ).save );
 				sinon.assert.calledOnce( editor.config.get( 'autosave' ).save );
 			} );
 			} );
 		} );
 		} );
 
 
-		it( 'its callback and adapter callback should be called if both are provided', () => {
+		it( 'config callback and adapter callback should be called if both are provided', () => {
 			editor.config.get( 'autosave' ).save.resetHistory();
 			editor.config.get( 'autosave' ).save.resetHistory();
 
 
 			autosave.adapter = {
 			autosave.adapter = {
@@ -120,7 +128,9 @@ describe( 'Autosave', () => {
 				editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection );
 				editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection );
 			} );
 			} );
 
 
-			return wait().then( () => {
+			sandbox.clock.tick( 1000 );
+
+			return Promise.resolve().then( () => {
 				sinon.assert.calledOnce( autosave.adapter.save );
 				sinon.assert.calledOnce( autosave.adapter.save );
 				sinon.assert.calledOnce( editor.config.get( 'autosave' ).save );
 				sinon.assert.calledOnce( editor.config.get( 'autosave' ).save );
 			} );
 			} );
@@ -138,13 +148,98 @@ describe( 'Autosave', () => {
 				editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection );
 				editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection );
 			} );
 			} );
 
 
-			return wait().then( () => {
+			sandbox.clock.tick( 1000 );
+
+			return Promise.resolve().then( () => {
 				sinon.assert.calledWithExactly( autosave.adapter.save, editor );
 				sinon.assert.calledWithExactly( autosave.adapter.save, editor );
 				sinon.assert.calledWithExactly( editor.config.get( 'autosave' ).save, editor );
 				sinon.assert.calledWithExactly( editor.config.get( 'autosave' ).save, editor );
 			} );
 			} );
 		} );
 		} );
 	} );
 	} );
 
 
+	describe( 'config.autosave.waitingTime', () => {
+		afterEach( () => {
+			document.body.removeChild( element );
+
+			return editor.destroy();
+		} );
+
+		it( 'should specify the time of waiting on the next use action before saving', () => {
+			element = document.createElement( 'div' );
+			document.body.appendChild( element );
+
+			return ClassicTestEditor
+				.create( element, {
+					plugins: [ Autosave, Paragraph ],
+					autosave: {
+						save: sinon.spy(),
+						waitingTime: 500
+					}
+				} )
+				.then( _editor => {
+					editor = _editor;
+
+					const data = '<p>paragraph1</p><p>paragraph2</p>';
+					editor.setData( data );
+
+					editor.model.change( writer => {
+						writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) );
+						editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection );
+					} );
+
+					sandbox.clock.tick( 499 );
+
+					return Promise.resolve().then( () => {
+						sinon.assert.notCalled( editor.config.get( 'autosave' ).save );
+
+						sandbox.clock.tick( 1 );
+
+						return Promise.resolve();
+					} ).then( () => {
+						// Callback should be called exactly after 500ms.
+						sinon.assert.calledOnce( editor.config.get( 'autosave' ).save );
+					} );
+				} );
+		} );
+
+		it( 'should be default to 1000', () => {
+			element = document.createElement( 'div' );
+			document.body.appendChild( element );
+
+			return ClassicTestEditor
+				.create( element, {
+					plugins: [ Autosave, Paragraph ],
+					autosave: {
+						save: sinon.spy()
+					}
+				} )
+				.then( _editor => {
+					editor = _editor;
+
+					const data = '<p>paragraph1</p><p>paragraph2</p>';
+					editor.setData( data );
+
+					editor.model.change( writer => {
+						writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) );
+						editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection );
+					} );
+
+					sandbox.clock.tick( 999 );
+
+					return Promise.resolve().then( () => {
+						sinon.assert.notCalled( editor.config.get( 'autosave' ).save );
+
+						sandbox.clock.tick( 1 );
+
+						return Promise.resolve();
+					} ).then( () => {
+						// Callback should be called exactly after 1000ms by default.
+						sinon.assert.calledOnce( editor.config.get( 'autosave' ).save );
+					} );
+				} );
+		} );
+	} );
+
 	describe( 'autosaving', () => {
 	describe( 'autosaving', () => {
 		beforeEach( () => {
 		beforeEach( () => {
 			element = document.createElement( 'div' );
 			element = document.createElement( 'div' );
@@ -160,9 +255,6 @@ describe( 'Autosave', () => {
 					editor = _editor;
 					editor = _editor;
 					editor.setData( data );
 					editor.setData( data );
 					autosave = editor.plugins.get( Autosave );
 					autosave = editor.plugins.get( Autosave );
-
-					// Clean autosave's state after setting data.
-					autosave._flush();
 				} );
 				} );
 		} );
 		} );
 
 
@@ -182,12 +274,14 @@ describe( 'Autosave', () => {
 				editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection );
 				editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection );
 			} );
 			} );
 
 
-			return wait().then( () => {
+			sandbox.clock.tick( 1000 );
+
+			return Promise.resolve().then( () => {
 				sinon.assert.calledOnce( autosave.adapter.save );
 				sinon.assert.calledOnce( autosave.adapter.save );
 			} );
 			} );
 		} );
 		} );
 
 
-		it( 'should throttle editor\'s change event', () => {
+		it( 'should debounce editor\'s change event', () => {
 			const spy = sinon.spy();
 			const spy = sinon.spy();
 			const savedStates = [];
 			const savedStates = [];
 
 
@@ -199,45 +293,42 @@ describe( 'Autosave', () => {
 				}
 				}
 			};
 			};
 
 
-			// Leading (will fire change).
 			editor.model.change( writer => {
 			editor.model.change( writer => {
 				writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 1 ) ) );
 				writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 1 ) ) );
 				editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection );
 				editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection );
 			} );
 			} );
 
 
-			return wait().then( () => {
-				// Throttled (won't fire change).
-				editor.model.change( writer => {
-					writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 1 ) ) );
-					editor.model.insertContent( new ModelText( 'bar' ), editor.model.document.selection );
-				} );
+			sandbox.clock.tick( 1000 );
 
 
-				return wait();
-			} ).then( () => {
-				// Flushed (will fire change).
-				editor.model.change( writer => {
-					writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 1 ) ) );
-					editor.model.insertContent( new ModelText( 'biz' ), editor.model.document.selection );
-				} );
+			editor.model.change( writer => {
+				writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 1 ) ) );
+				editor.model.insertContent( new ModelText( 'bar' ), editor.model.document.selection );
+			} );
 
 
-				autosave._flush();
+			sandbox.clock.tick( 1000 );
 
 
-				return wait();
-			} ).then( () => {
-				expect( spy.callCount ).to.equal( 2 );
+			editor.model.change( writer => {
+				writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 1 ) ) );
+				editor.model.insertContent( new ModelText( 'biz' ), editor.model.document.selection );
+			} );
+
+			sinon.assert.notCalled( spy );
+
+			sandbox.clock.tick( 1000 );
+
+			return Promise.resolve().then( () => {
+				sinon.assert.calledOnce( spy );
 				expect( savedStates ).to.deep.equal( [
 				expect( savedStates ).to.deep.equal( [
-					'<p>paragraph1</p><p>foo</p>',
 					'<p>paragraph1</p><p>biz</p>'
 					'<p>paragraph1</p><p>biz</p>'
 				] );
 				] );
 			} );
 			} );
 		} );
 		} );
 
 
-		it( 'should add a pending action during the saving.', () => {
-			sandbox.useFakeTimers();
+		it( 'should add a pending action after a change and wait on the server response', () => {
 			const pendingActions = editor.plugins.get( PendingActions );
 			const pendingActions = editor.plugins.get( PendingActions );
 			const serverActionSpy = sinon.spy();
 			const serverActionSpy = sinon.spy();
 			const serverActionStub = sinon.stub();
 			const serverActionStub = sinon.stub();
-			serverActionStub.onCall( 0 ).resolves( wait( 1000 ).then( serverActionSpy ) );
+			serverActionStub.callsFake( () => wait( 1000 ).then( serverActionSpy ) );
 
 
 			autosave.adapter = {
 			autosave.adapter = {
 				save: serverActionStub
 				save: serverActionStub
@@ -253,9 +344,18 @@ describe( 'Autosave', () => {
 			expect( pendingActions.first.message ).to.equal( 'Saving changes' );
 			expect( pendingActions.first.message ).to.equal( 'Saving changes' );
 
 
 			sandbox.clock.tick( 1000 );
 			sandbox.clock.tick( 1000 );
-			return Promise.resolve().then( () => Promise.resolve() ).then( () => {
-				sinon.assert.calledOnce( serverActionSpy );
-				expect( pendingActions.hasAny ).to.be.false;
+
+			sinon.assert.notCalled( serverActionSpy );
+			expect( pendingActions.hasAny ).to.be.true;
+			expect( pendingActions.first.message ).to.equal( 'Saving changes' );
+
+			return Promise.resolve().then( () => {
+				sandbox.clock.tick( 1000 );
+
+				return runPromiseCycles().then( () => {
+					sinon.assert.calledOnce( serverActionSpy );
+					expect( pendingActions.hasAny ).to.be.false;
+				} );
 			} );
 			} );
 		} );
 		} );
 
 
@@ -276,101 +376,73 @@ describe( 'Autosave', () => {
 
 
 			expect( pendingActions.hasAny ).to.be.true;
 			expect( pendingActions.hasAny ).to.be.true;
 
 
-			// Server action needs to wait at least a cycle.
-			return wait().then( () => {
+			sandbox.clock.tick( 1000 );
+
+			return runPromiseCycles().then( () => {
 				sinon.assert.calledOnce( serverActionSpy );
 				sinon.assert.calledOnce( serverActionSpy );
 				expect( pendingActions.hasAny ).to.be.false;
 				expect( pendingActions.hasAny ).to.be.false;
 			} );
 			} );
 		} );
 		} );
 
 
-		it( 'should handle correctly throttled save action and preserve pending action until both save actions finish', () => {
-			sandbox.useFakeTimers();
-			const serverActionSpy = sinon.spy();
+		it( 'should be in correct states during the saving', () => {
 			const pendingActions = editor.plugins.get( PendingActions );
 			const pendingActions = editor.plugins.get( PendingActions );
-
-			// Create a fake server that responses after 1000ms for the first call and after 1000ms for the second call.
+			const serverActionSpy = sinon.spy();
 			const serverActionStub = sinon.stub();
 			const serverActionStub = sinon.stub();
-			serverActionStub.onCall( 0 ).resolves( wait( 1000 ).then( serverActionSpy ) );
-			serverActionStub.onCall( 1 ).resolves( wait( 2000 ).then( serverActionSpy ) );
+			serverActionStub.callsFake( () => wait( 1000 ).then( serverActionSpy ) );
 
 
 			autosave.adapter = {
 			autosave.adapter = {
 				save: serverActionStub
 				save: serverActionStub
 			};
 			};
 
 
-			expect( pendingActions.hasAny ).to.be.false;
-
 			editor.model.change( writer => {
 			editor.model.change( writer => {
 				writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) );
 				writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) );
 				editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection );
 				editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection );
 			} );
 			} );
 
 
+			sinon.assert.notCalled( serverActionSpy );
 			expect( pendingActions.hasAny ).to.be.true;
 			expect( pendingActions.hasAny ).to.be.true;
-
-			editor.model.change( writer => {
-				writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) );
-				editor.model.insertContent( new ModelText( 'bar' ), editor.model.document.selection );
-			} );
-
-			autosave._flush();
-
-			expect( pendingActions.hasAny ).to.be.true;
+			expect( pendingActions.first.message ).to.equal( 'Saving changes' );
 
 
 			sandbox.clock.tick( 1000 );
 			sandbox.clock.tick( 1000 );
+			expect( autosave.state ).to.equal( 'saving' );
 
 
 			return Promise.resolve().then( () => {
 			return Promise.resolve().then( () => {
-				expect( pendingActions.hasAny ).to.be.true;
-				sinon.assert.calledOnce( serverActionSpy );
+				// Add new change before the response from the server.
 
 
-				// Wait another 1000ms and a promise cycle for the second server action.
-				sandbox.clock.tick( 1000 );
-			} )
-				.then( () => Promise.resolve() )
-				.then( () => {
-					expect( pendingActions.hasAny ).to.be.false;
-					sinon.assert.calledTwice( serverActionSpy );
+				editor.model.change( writer => {
+					writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) );
+					editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection );
 				} );
 				} );
-		} );
-
-		it( 'should handle correctly throttled save action and preserve pending action until both save actions finish #2', () => {
-			const serverActionSpy = sinon.spy();
-			const pendingActions = editor.plugins.get( PendingActions );
-
-			autosave.adapter = {
-				save: serverActionSpy
-			};
-
-			expect( pendingActions.hasAny ).to.be.false;
 
 
-			editor.model.change( writer => {
-				writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) );
-				editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection );
-			} );
-
-			expect( pendingActions.hasAny ).to.be.true;
+				sandbox.clock.tick( 1000 );
 
 
-			editor.model.change( writer => {
-				writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) );
-				editor.model.insertContent( new ModelText( 'bar' ), editor.model.document.selection );
-			} );
+				return runPromiseCycles();
+			} ).then( () => {
+				// Now there should come the first server response.
+				expect( autosave.state ).to.equal( 'waiting' );
+				expect( pendingActions.hasAny ).to.be.true;
+				sinon.assert.calledOnce( serverActionSpy );
 
 
-			expect( pendingActions.hasAny ).to.be.true;
+				sandbox.clock.tick( 1000 );
 
 
-			// Server action needs to wait at least a cycle.
-			return wait().then( () => {
-				sinon.assert.calledOnce( serverActionSpy );
+				return runPromiseCycles();
+			} ).then( () => {
+				expect( autosave.state ).to.equal( 'saving' );
 				expect( pendingActions.hasAny ).to.be.true;
 				expect( pendingActions.hasAny ).to.be.true;
+				sinon.assert.calledOnce( serverActionSpy );
 
 
-				autosave._flush();
+				// Wait for the second server response.
+				sandbox.clock.tick( 1000 );
 
 
-				// Wait another promise cycle.
-				return wait().then( () => {
-					sinon.assert.calledTwice( serverActionSpy );
-					expect( pendingActions.hasAny ).to.be.false;
-				} );
+				return runPromiseCycles();
+			} ).then( () => {
+				expect( pendingActions.hasAny ).to.be.false;
+				expect( autosave.state ).to.equal( 'synchronized' );
+				sinon.assert.calledTwice( serverActionSpy );
 			} );
 			} );
 		} );
 		} );
 
 
-		it( 'should filter out changes in the selection', () => {
+		it( 'should filter out selection changes', () => {
 			autosave.adapter = {
 			autosave.adapter = {
 				save: sandbox.spy()
 				save: sandbox.spy()
 			};
 			};
@@ -379,11 +451,14 @@ describe( 'Autosave', () => {
 				writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) );
 				writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) );
 			} );
 			} );
 
 
-			autosave._flush();
-			sinon.assert.notCalled( autosave.adapter.save );
+			sandbox.clock.tick( 1000 );
+
+			return runPromiseCycles().then( () => {
+				sinon.assert.notCalled( autosave.adapter.save );
+			} );
 		} );
 		} );
 
 
-		it( 'should filter out markers that does not affect the data model', () => {
+		it( 'should filter out markers that does not affect the data', () => {
 			autosave.adapter = {
 			autosave.adapter = {
 				save: sandbox.spy()
 				save: sandbox.spy()
 			};
 			};
@@ -395,18 +470,20 @@ describe( 'Autosave', () => {
 				writer.addMarker( 'name', { usingOperation: true, range } );
 				writer.addMarker( 'name', { usingOperation: true, range } );
 			} );
 			} );
 
 
-			autosave._flush();
+			sandbox.clock.tick( 1000 );
 
 
 			editor.model.change( writer => {
 			editor.model.change( writer => {
 				writer.updateMarker( 'name', { range: range2 } );
 				writer.updateMarker( 'name', { range: range2 } );
 			} );
 			} );
 
 
-			autosave._flush();
+			sandbox.clock.tick( 1000 );
 
 
-			sinon.assert.notCalled( autosave.adapter.save );
+			return runPromiseCycles().then( () => {
+				sinon.assert.notCalled( autosave.adapter.save );
+			} );
 		} );
 		} );
 
 
-		it( 'should filter out markers that does not affect the data model #2', () => {
+		it( 'should filter out markers that does not affect the data #2', () => {
 			autosave.adapter = {
 			autosave.adapter = {
 				save: sandbox.spy()
 				save: sandbox.spy()
 			};
 			};
@@ -418,45 +495,38 @@ describe( 'Autosave', () => {
 				writer.addMarker( 'name', { usingOperation: false, range } );
 				writer.addMarker( 'name', { usingOperation: false, range } );
 			} );
 			} );
 
 
-			autosave._flush();
+			sandbox.clock.tick( 1000 );
 
 
 			editor.model.change( writer => {
 			editor.model.change( writer => {
 				writer.updateMarker( 'name', { range: range2 } );
 				writer.updateMarker( 'name', { range: range2 } );
 			} );
 			} );
 
 
-			autosave._flush();
+			sandbox.clock.tick( 1000 );
 
 
-			sinon.assert.notCalled( autosave.adapter.save );
+			return runPromiseCycles().then( () => {
+				sinon.assert.notCalled( autosave.adapter.save );
+			} );
 		} );
 		} );
 
 
-		it( 'should call the save method when some marker affects the data model', () => {
+		it( 'should call the save method when some marker affects the data', () => {
 			autosave.adapter = {
 			autosave.adapter = {
 				save: sandbox.spy()
 				save: sandbox.spy()
 			};
 			};
 
 
 			const range = ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) );
 			const range = ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) );
-			const range2 = ModelRange.createIn( editor.model.document.getRoot().getChild( 1 ) );
 
 
 			editor.model.change( writer => {
 			editor.model.change( writer => {
 				writer.addMarker( 'name', { usingOperation: true, affectsData: true, range } );
 				writer.addMarker( 'name', { usingOperation: true, affectsData: true, range } );
 			} );
 			} );
 
 
-			autosave._flush();
-
-			return wait().then( () => {
-				editor.model.change( writer => {
-					writer.updateMarker( 'name', { range: range2 } );
-				} );
-
-				autosave._flush();
+			sandbox.clock.tick( 1000 );
 
 
-				return wait().then( () => {
-					sinon.assert.calledTwice( autosave.adapter.save );
-				} );
+			return runPromiseCycles().then( () => {
+				sinon.assert.calledOnce( autosave.adapter.save );
 			} );
 			} );
 		} );
 		} );
 
 
-		it( 'should call the save method when some marker affects the data model #2', () => {
+		it( 'should call the save method when some marker affects the data #2', () => {
 			autosave.adapter = {
 			autosave.adapter = {
 				save: sandbox.spy()
 				save: sandbox.spy()
 			};
 			};
@@ -468,24 +538,24 @@ describe( 'Autosave', () => {
 				writer.addMarker( 'name', { usingOperation: false, affectsData: true, range } );
 				writer.addMarker( 'name', { usingOperation: false, affectsData: true, range } );
 			} );
 			} );
 
 
-			autosave._flush();
+			sandbox.clock.tick( 1000 );
 
 
-			return wait().then( () => {
+			return runPromiseCycles().then( () => {
 				sinon.assert.calledOnce( autosave.adapter.save );
 				sinon.assert.calledOnce( autosave.adapter.save );
 
 
 				editor.model.change( writer => {
 				editor.model.change( writer => {
 					writer.updateMarker( 'name', { range: range2 } );
 					writer.updateMarker( 'name', { range: range2 } );
 				} );
 				} );
 
 
-				autosave._flush();
+				sandbox.clock.tick( 1000 );
 
 
-				return wait().then( () => {
+				return runPromiseCycles().then( () => {
 					sinon.assert.calledTwice( autosave.adapter.save );
 					sinon.assert.calledTwice( autosave.adapter.save );
 				} );
 				} );
 			} );
 			} );
 		} );
 		} );
 
 
-		it( 'should call the save method when some marker affects the data model #3', () => {
+		it( 'should call the save method when some marker affects the data #3', () => {
 			autosave.adapter = {
 			autosave.adapter = {
 				save: sandbox.spy()
 				save: sandbox.spy()
 			};
 			};
@@ -497,12 +567,14 @@ describe( 'Autosave', () => {
 				writer.addMarker( 'marker-affecting-data', { usingOperation: false, affectsData: false, range } );
 				writer.addMarker( 'marker-affecting-data', { usingOperation: false, affectsData: false, range } );
 			} );
 			} );
 
 
-			return wait().then( () => {
+			sandbox.clock.tick( 1000 );
+
+			return runPromiseCycles().then( () => {
 				sinon.assert.calledOnce( autosave.adapter.save );
 				sinon.assert.calledOnce( autosave.adapter.save );
 			} );
 			} );
 		} );
 		} );
 
 
-		it( 'should flush remaining calls after editor\'s destroy', () => {
+		it( 'should flush remaining call after editor\'s destroy', () => {
 			const spy = sandbox.spy();
 			const spy = sandbox.spy();
 			const savedStates = [];
 			const savedStates = [];
 
 
@@ -519,24 +591,22 @@ describe( 'Autosave', () => {
 				editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection );
 				editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection );
 			} );
 			} );
 
 
-			return wait().then( () => {
-				editor.model.change( writer => {
-					writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 1 ) ) );
-					editor.model.insertContent( new ModelText( 'bar' ), editor.model.document.selection );
-				} );
+			editor.model.change( writer => {
+				writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 1 ) ) );
+				editor.model.insertContent( new ModelText( 'bar' ), editor.model.document.selection );
+			} );
 
 
-				return editor.destroy().then( () => {
-					expect( spy.callCount ).to.equal( 2 );
-					expect( savedStates ).to.deep.equal( [
-						'<p>foo</p><p>paragraph2</p>',
-						'<p>foo</p><p>bar</p>',
-					] );
-				} );
+			sinon.assert.notCalled( spy );
+
+			return editor.destroy().then( () => {
+				sinon.assert.calledOnce( spy );
+				expect( savedStates ).to.deep.equal( [
+					'<p>foo</p><p>bar</p>',
+				] );
 			} );
 			} );
 		} );
 		} );
 
 
 		it( 'should work after editor\'s destroy with long server\'s response time', () => {
 		it( 'should work after editor\'s destroy with long server\'s response time', () => {
-			sandbox.useFakeTimers();
 			const pendingActions = editor.plugins.get( PendingActions );
 			const pendingActions = editor.plugins.get( PendingActions );
 			const serverActionSpy = sinon.spy();
 			const serverActionSpy = sinon.spy();
 			const serverActionStub = sinon.stub();
 			const serverActionStub = sinon.stub();
@@ -565,7 +635,7 @@ describe( 'Autosave', () => {
 		} );
 		} );
 	} );
 	} );
 
 
-	it( 'should wait on editor initialization', () => {
+	it( 'should run callbacks until the editor is in the ready state', () => {
 		element = document.createElement( 'div' );
 		element = document.createElement( 'div' );
 		document.body.appendChild( element );
 		document.body.appendChild( element );
 		editor = null;
 		editor = null;
@@ -576,16 +646,16 @@ describe( 'Autosave', () => {
 			}
 			}
 
 
 			init() {
 			init() {
-				this.editor.once( 'ready', () => {
+				this.editor.once( 'dataReady', () => {
 					const editor = this.editor;
 					const editor = this.editor;
 
 
 					editor.model.change( writer => {
 					editor.model.change( writer => {
 						writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) );
 						writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) );
 						editor.model.insertContent( new ModelText( 'bar' ), editor.model.document.selection );
 						editor.model.insertContent( new ModelText( 'bar' ), editor.model.document.selection );
 					} );
 					} );
-				} );
 
 
-				return Promise.resolve().then( () => Promise.resolve() );
+					sandbox.clock.tick( 10 );
+				} );
 			}
 			}
 		}
 		}
 
 
@@ -593,18 +663,16 @@ describe( 'Autosave', () => {
 			.create( element, {
 			.create( element, {
 				plugins: [ Autosave, Paragraph, AsyncPlugin ],
 				plugins: [ Autosave, Paragraph, AsyncPlugin ],
 				autosave: {
 				autosave: {
-					save: sinon.spy( () => {
-						expect( editor ).to.not.be.null;
-					} )
+					save: sinon.spy(),
+					waitingTime: 5
 				}
 				}
 			} )
 			} )
 			.then( _editor => {
 			.then( _editor => {
 				editor = _editor;
 				editor = _editor;
-				autosave = editor.plugins.get( Autosave );
 				const spy = editor.config.get( 'autosave' ).save;
 				const spy = editor.config.get( 'autosave' ).save;
 
 
 				expect( editor.getData() ).to.equal( '<p>bar</p>' );
 				expect( editor.getData() ).to.equal( '<p>bar</p>' );
-				sinon.assert.calledOnce( spy );
+				sinon.assert.notCalled( spy );
 			} )
 			} )
 			.then( () => {
 			.then( () => {
 				document.body.removeChild( element );
 				document.body.removeChild( element );
@@ -619,3 +687,11 @@ function wait( time ) {
 		window.setTimeout( res, time );
 		window.setTimeout( res, time );
 	} );
 	} );
 }
 }
+
+function runPromiseCycles() {
+	return Promise.resolve()
+		.then( () => Promise.resolve() )
+		.then( () => Promise.resolve() )
+		.then( () => Promise.resolve() )
+		.then( () => Promise.resolve() );
+}

+ 28 - 7
packages/ckeditor5-autosave/tests/manual/autosave.js

@@ -29,17 +29,38 @@ ClassicEditor
 			save() {
 			save() {
 				const data = editor.getData();
 				const data = editor.getData();
 
 
-				return saveEditorContentToDatabase( data );
+				return wait( 1000 )
+					.then( () => console.log( `${ getTime() } Saved content: ${ data }` ) );
 			}
 			}
 		};
 		};
+
+		autosave.listenTo( autosave, 'change:state',
+			( evt, propName, newValue, oldValue ) => console.log( `${ getTime() } Changed state: ${ oldValue } -> ${ newValue }` ) );
 	} );
 	} );
 
 
-function saveEditorContentToDatabase( data ) {
+function wait( time ) {
 	return new Promise( res => {
 	return new Promise( res => {
-		window.setTimeout( () => {
-			console.log( data );
-
-			res();
-		}, 1000 );
+		window.setTimeout( res, time );
 	} );
 	} );
 }
 }
+
+function getTime() {
+	const date = new Date();
+
+	return '[' +
+		date.getHours() + ':' +
+		setDigitSize( date.getMinutes(), 2 ) + ':' +
+		setDigitSize( date.getSeconds(), 2 ) + '.' +
+		setDigitSize( date.getMilliseconds(), 2 ) +
+		']';
+}
+
+function setDigitSize( number, size ) {
+	const string = String( number );
+
+	if ( string.length >= size ) {
+		return string.slice( 0, size );
+	}
+
+	return '0'.repeat( size - string.length ) + string;
+}

+ 5 - 1
packages/ckeditor5-autosave/tests/manual/autosave.md

@@ -1,5 +1,9 @@
-1. Play with the editor. You should logs of the editor's data in console with 1s timeout (it simulates the back-end). You should not see logs when you changes the selection.
+1. Play with the editor. You should logs of the changing autosave's states. You should not see logs when you changes the selection.
 
 
 1. Type something and quickly try to reload the page. You should see something like this: `Reload site? Changes that you made may not be saved.`.
 1. Type something and quickly try to reload the page. You should see something like this: `Reload site? Changes that you made may not be saved.`.
 
 
 1. Type something and quickly and click the `destroy editor` button. Most recent changes should be logged to the console.
 1. Type something and quickly and click the `destroy editor` button. Most recent changes should be logged to the console.
+
+1. Type something without big time gaps. Once you stop typing there should be the first `waiting -> saving` and then the response should show up with the whole editor's content.
+
+1. Type something. Once you'll see the `waiting -> saving` change, type something else. Then you should see the response and the `saving->waiting` change. Then you should see another response from the fake server.

+ 0 - 143
packages/ckeditor5-autosave/tests/throttle.js

@@ -1,143 +0,0 @@
-/**
- * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-import throttle from '../src/throttle';
-
-describe( 'throttle', () => {
-	const sandbox = sinon.sandbox.create( {
-		useFakeTimers: true
-	} );
-
-	beforeEach( () => {
-		sandbox.useFakeTimers( { now: 1000 } );
-	} );
-
-	afterEach( () => {
-		sandbox.restore();
-	} );
-
-	it( 'should run first call synchronously', () => {
-		const spy = sinon.spy();
-		const throttledFn = throttle( spy, 100 );
-
-		throttledFn();
-
-		sinon.assert.calledOnce( spy );
-
-		sandbox.clock.runAll();
-		sinon.assert.calledOnce( spy );
-	} );
-
-	it( 'should run next calls after specified amount of time', () => {
-		const spy = sinon.spy();
-		const throttledFn = throttle( spy, 100 );
-
-		throttledFn();
-		throttledFn();
-
-		sinon.assert.calledOnce( spy );
-
-		sandbox.clock.tick( 99 );
-
-		sinon.assert.calledOnce( spy );
-
-		sandbox.clock.tick( 1 );
-
-		sinon.assert.calledTwice( spy );
-	} );
-
-	it( 'should skip the call if another call is scheduled', () => {
-		const spy = sinon.spy();
-		const throttledFn = throttle( spy, 100 );
-
-		const isFirstInvoked = throttledFn();
-		const willSecondInvoke = throttledFn();
-		const willThirdInvoke = throttledFn();
-
-		expect( isFirstInvoked ).to.be.true;
-		expect( willSecondInvoke ).to.be.true;
-		expect( willThirdInvoke ).to.be.false;
-
-		sandbox.clock.runAll();
-		sinon.assert.calledTwice( spy );
-	} );
-
-	it( 'should call the next call after the specified amount of time', () => {
-		const spy = sinon.spy();
-		const throttledFn = throttle( spy, 100 );
-
-		throttledFn();
-		throttledFn();
-
-		sandbox.clock.tick( 50 );
-
-		sinon.assert.calledOnce( spy );
-
-		sandbox.clock.tick( 50 );
-
-		sinon.assert.calledTwice( spy );
-
-		throttledFn();
-
-		sandbox.clock.tick( 100 );
-
-		sinon.assert.calledThrice( spy );
-	} );
-
-	describe( 'flush', () => {
-		it( 'should be provide as a method on the throttled function', () => {
-			const spy = sinon.spy();
-			const throttledFn = throttle( spy, 100 );
-
-			expect( throttledFn.flush ).to.be.a( 'function' );
-		} );
-
-		it( 'should enable calling the throttled call immediately', () => {
-			const spy = sinon.spy();
-			const throttledFn = throttle( spy, 100 );
-
-			throttledFn();
-			throttledFn();
-
-			sinon.assert.calledOnce( spy );
-
-			throttledFn.flush();
-			sinon.assert.calledTwice( spy );
-
-			sandbox.clock.runAll();
-			sinon.assert.calledTwice( spy );
-		} );
-
-		it( 'should do nothing if there is no scheduled call', () => {
-			const spy = sinon.spy();
-			const throttledFn = throttle( spy, 100 );
-
-			throttledFn();
-
-			sinon.assert.calledOnce( spy );
-
-			throttledFn.flush();
-			sinon.assert.calledOnce( spy );
-
-			sandbox.clock.runAll();
-			sinon.assert.calledOnce( spy );
-		} );
-
-		it( 'should enable calling after the flushed call', () => {
-			const spy = sinon.spy();
-			const throttledFn = throttle( spy, 100 );
-
-			throttledFn();
-			throttledFn();
-			throttledFn.flush();
-			throttledFn();
-
-			sinon.assert.calledThrice( spy );
-
-			sandbox.clock.runAll();
-			sinon.assert.calledThrice( spy );
-		} );
-	} );
-} );