Beispiel #1
0
describe( 'submitHandler', () => {
	let view;

	testUtils.createSinonSandbox();

	beforeEach( () => {
		view = new View();
		view.element = document.createElement( 'div' );
		view.element.child = document.createElement( 'input' );

		view.element.appendChild( view.element.child );

		submitHandler( { view } );
	} );

	it( 'should fire #submit event on the view and prevent the native DOM #submit', done => {
		const evt = new Event( 'submit' );
		const spy = sinon.spy( evt, 'preventDefault' );

		view.on( 'submit', () => {
			sinon.assert.calledOnce( spy );
			done();
		} );

		view.element.child.dispatchEvent( evt );
	} );
} );
Beispiel #2
0
describe( 'EditorUIView', () => {
	let view, locale;

	testUtils.createSinonSandbox();

	beforeEach( () => {
		locale = new Locale( 'en' );
		view = new EditorUIView( locale );

		view.render();
	} );

	afterEach( () => {
		view.destroy();
	} );

	describe( 'constructor()', () => {
		it( 'accepts locale', () => {
			expect( view.locale ).to.equal( locale );
		} );

		it( 'sets all the properties', () => {
			expect( view.body ).to.be.instanceof( ViewCollection );
		} );
	} );

	describe( 'render()', () => {
		it( 'sets the right class set to the body region', () => {
			const el = view._bodyCollectionContainer;

			expect( el.parentNode ).to.equal( document.body );
			expect( el.classList.contains( 'ck' ) ).to.be.true;
			expect( el.classList.contains( 'ck-body' ) ).to.be.true;
			expect( el.classList.contains( 'ck-rounded-corners' ) ).to.be.true;
			expect( el.classList.contains( 'ck-reset_all' ) ).to.be.true;
		} );
	} );

	describe( 'destroy()', () => {
		it( 'removes the body region container', () => {
			const el = view._bodyCollectionContainer;

			view.destroy();
			expect( el.parentNode ).to.be.null;
		} );

		it( 'can be called multiple times', () => {
			expect( () => {
				view.destroy();
				view.destroy();
			} ).to.not.throw();
		} );
	} );
} );
describe( 'SubscriptUI', () => {
	let editor, subView;

	testUtils.createSinonSandbox();

	beforeEach( () => {
		const editorElement = document.createElement( 'div' );
		document.body.appendChild( editorElement );

		return ClassicTestEditor
			.create( editorElement, {
				plugins: [ Paragraph, SubscriptEditing, SubscriptUI ]
			} )
			.then( newEditor => {
				editor = newEditor;

				subView = editor.ui.componentFactory.create( 'subscript' );
			} );
	} );

	afterEach( () => {
		return editor.destroy();
	} );

	it( 'should register subscript feature component', () => {
		expect( subView ).to.be.instanceOf( ButtonView );
		expect( subView.isOn ).to.be.false;
		expect( subView.label ).to.equal( 'Subscript' );
		expect( subView.icon ).to.match( /<svg / );
	} );

	it( 'should execute subscript command on model execute event', () => {
		const executeSpy = testUtils.sinon.spy( editor, 'execute' );

		subView.fire( 'execute' );

		sinon.assert.calledOnce( executeSpy );
		sinon.assert.calledWithExactly( executeSpy, 'subscript' );
	} );

	it( 'should bind model to subscript command', () => {
		const command = editor.commands.get( 'subscript' );

		expect( subView.isOn ).to.be.false;
		expect( subView.isEnabled ).to.be.true;

		command.value = true;
		expect( subView.isOn ).to.be.true;

		command.isEnabled = false;
		expect( subView.isEnabled ).to.be.false;
	} );
} );
describe( 'ObservableMixin', () => {
	testUtils.createSinonSandbox();

	it( 'exists', () => {
		expect( ObservableMixin ).to.be.an( 'object' );
	} );

	it( 'mixes in EmitterMixin', () => {
		expect( ObservableMixin ).to.have.property( 'on', EmitterMixin.on );
	} );

	it( 'implements set, bind, and unbind methods', () => {
		expect( ObservableMixin ).to.contain.keys( 'set', 'bind', 'unbind' );
	} );
} );
describe( 'LinkFormView', () => {
	let view;

	testUtils.createSinonSandbox();

	beforeEach( () => {
		view = new LinkFormView( { t: val => val } );
		view.render();
	} );

	describe( 'constructor()', () => {
		it( 'should create element from template', () => {
			expect( view.element.classList.contains( 'ck' ) ).to.true;
			expect( view.element.classList.contains( 'ck-link-form' ) ).to.true;
			expect( view.element.getAttribute( 'tabindex' ) ).to.equal( '-1' );
		} );

		it( 'should create child views', () => {
			expect( view.urlInputView ).to.be.instanceOf( View );
			expect( view.saveButtonView ).to.be.instanceOf( View );
			expect( view.cancelButtonView ).to.be.instanceOf( View );

			expect( view.saveButtonView.element.classList.contains( 'ck-button-save' ) ).to.be.true;
			expect( view.cancelButtonView.element.classList.contains( 'ck-button-cancel' ) ).to.be.true;

			expect( view._unboundChildren.get( 0 ) ).to.equal( view.urlInputView );
			expect( view._unboundChildren.get( 1 ) ).to.equal( view.saveButtonView );
			expect( view._unboundChildren.get( 2 ) ).to.equal( view.cancelButtonView );
		} );

		it( 'should create #focusTracker instance', () => {
			expect( view.focusTracker ).to.be.instanceOf( FocusTracker );
		} );

		it( 'should create #keystrokes instance', () => {
			expect( view.keystrokes ).to.be.instanceOf( KeystrokeHandler );
		} );

		it( 'should create #_focusCycler instance', () => {
			expect( view._focusCycler ).to.be.instanceOf( FocusCycler );
		} );

		it( 'should create #_focusables view collection', () => {
			expect( view._focusables ).to.be.instanceOf( ViewCollection );
		} );

		it( 'should fire `cancel` event on cancelButtonView#execute', () => {
			const spy = sinon.spy();

			view.on( 'cancel', spy );

			view.cancelButtonView.fire( 'execute' );

			expect( spy.calledOnce ).to.true;
		} );

		describe( 'url input view', () => {
			it( 'has placeholder', () => {
				expect( view.urlInputView.inputView.placeholder ).to.equal( 'https://example.com' );
			} );
		} );

		describe( 'template', () => {
			it( 'has url input view', () => {
				expect( view.template.children[ 0 ] ).to.equal( view.urlInputView );
			} );

			it( 'has button views', () => {
				expect( view.template.children[ 1 ] ).to.equal( view.saveButtonView );
				expect( view.template.children[ 2 ] ).to.equal( view.cancelButtonView );
			} );
		} );
	} );

	describe( 'render()', () => {
		it( 'should register child views in #_focusables', () => {
			expect( view._focusables.map( f => f ) ).to.have.members( [
				view.urlInputView,
				view.saveButtonView,
				view.cancelButtonView,
			] );
		} );

		it( 'should register child views\' #element in #focusTracker', () => {
			const spy = testUtils.sinon.spy( FocusTracker.prototype, 'add' );

			view = new LinkFormView( { t: () => {} } );
			view.render();

			sinon.assert.calledWithExactly( spy.getCall( 0 ), view.urlInputView.element );
			sinon.assert.calledWithExactly( spy.getCall( 1 ), view.saveButtonView.element );
			sinon.assert.calledWithExactly( spy.getCall( 2 ), view.cancelButtonView.element );
		} );

		it( 'starts listening for #keystrokes coming from #element', () => {
			view = new LinkFormView( { t: () => {} } );

			const spy = sinon.spy( view.keystrokes, 'listenTo' );

			view.render();
			sinon.assert.calledOnce( spy );
			sinon.assert.calledWithExactly( spy, view.element );
		} );

		describe( 'activates keyboard navigation for the toolbar', () => {
			it( 'so "tab" focuses the next focusable item', () => {
				const keyEvtData = {
					keyCode: keyCodes.tab,
					preventDefault: sinon.spy(),
					stopPropagation: sinon.spy()
				};

				// Mock the url input is focused.
				view.focusTracker.isFocused = true;
				view.focusTracker.focusedElement = view.urlInputView.element;

				const spy = sinon.spy( view.saveButtonView, 'focus' );

				view.keystrokes.press( keyEvtData );
				sinon.assert.calledOnce( keyEvtData.preventDefault );
				sinon.assert.calledOnce( keyEvtData.stopPropagation );
				sinon.assert.calledOnce( spy );
			} );

			it( 'so "shift + tab" focuses the previous focusable item', () => {
				const keyEvtData = {
					keyCode: keyCodes.tab,
					shiftKey: true,
					preventDefault: sinon.spy(),
					stopPropagation: sinon.spy()
				};

				// Mock the cancel button is focused.
				view.focusTracker.isFocused = true;
				view.focusTracker.focusedElement = view.cancelButtonView.element;

				const spy = sinon.spy( view.saveButtonView, 'focus' );

				view.keystrokes.press( keyEvtData );
				sinon.assert.calledOnce( keyEvtData.preventDefault );
				sinon.assert.calledOnce( keyEvtData.stopPropagation );
				sinon.assert.calledOnce( spy );
			} );
		} );
	} );

	describe( 'DOM bindings', () => {
		describe( 'submit event', () => {
			it( 'should trigger submit event', () => {
				const spy = sinon.spy();

				view.on( 'submit', spy );
				view.element.dispatchEvent( new Event( 'submit' ) );

				expect( spy.calledOnce ).to.true;
			} );
		} );
	} );

	describe( 'focus()', () => {
		it( 'focuses the #urlInputView', () => {
			const spy = sinon.spy( view.urlInputView, 'focus' );

			view.focus();

			sinon.assert.calledOnce( spy );
		} );
	} );
} );
describe( 'clickOutsideHandler', () => {
	let activator, actionSpy, contextElement1, contextElement2;

	testUtils.createSinonSandbox();

	beforeEach( () => {
		activator = testUtils.sinon.stub().returns( false );
		contextElement1 = document.createElement( 'div' );
		contextElement2 = document.createElement( 'div' );
		actionSpy = testUtils.sinon.spy();

		document.body.appendChild( contextElement1 );
		document.body.appendChild( contextElement2 );

		clickOutsideHandler( {
			emitter: Object.create( DomEmitterMixin ),
			activator,
			contextElements: [ contextElement1, contextElement2 ],
			callback: actionSpy
		} );
	} );

	afterEach( () => {
		document.body.removeChild( contextElement1 );
		document.body.removeChild( contextElement2 );
	} );

	it( 'should execute upon #mousedown outside of the contextElements (activator is active)', () => {
		activator.returns( true );

		document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );

		sinon.assert.calledOnce( actionSpy );
	} );

	it( 'should not execute upon #mousedown outside of the contextElements (activator is inactive)', () => {
		activator.returns( false );

		document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );

		sinon.assert.notCalled( actionSpy );
	} );

	it( 'should not execute upon #mousedown from one of the contextElements (activator is active)', () => {
		activator.returns( true );

		contextElement1.dispatchEvent( new Event( 'mouseup', { bubbles: true } ) );
		sinon.assert.notCalled( actionSpy );

		contextElement2.dispatchEvent( new Event( 'mouseup', { bubbles: true } ) );
		sinon.assert.notCalled( actionSpy );
	} );

	it( 'should not execute upon #mousedown from one of the contextElements (activator is inactive)', () => {
		activator.returns( false );

		contextElement1.dispatchEvent( new Event( 'mouseup', { bubbles: true } ) );
		sinon.assert.notCalled( actionSpy );

		contextElement2.dispatchEvent( new Event( 'mouseup', { bubbles: true } ) );
		sinon.assert.notCalled( actionSpy );
	} );

	it( 'should execute if the activator function returns `true`', () => {
		const spy = testUtils.sinon.spy();

		activator.returns( true );

		clickOutsideHandler( {
			emitter: Object.create( DomEmitterMixin ),
			activator,
			contextElements: [ contextElement1 ],
			callback: spy
		} );

		document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );

		sinon.assert.calledOnce( spy );
	} );

	it( 'should not execute if the activator function returns `false`', () => {
		const spy = testUtils.sinon.spy();

		activator.returns( false );

		clickOutsideHandler( {
			emitter: Object.create( DomEmitterMixin ),
			activator,
			contextElements: [ contextElement1 ],
			callback: spy
		} );

		document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );

		sinon.assert.notCalled( spy );
	} );

	it( 'should react to the activator\'s return value change', () => {
		activator.returns( true );

		document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );

		sinon.assert.calledOnce( actionSpy );

		activator.returns( false );

		document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );

		// Still called once, was not called second time.
		sinon.assert.calledOnce( actionSpy );

		activator.returns( true );

		document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );

		// Called one more time.
		sinon.assert.calledTwice( actionSpy );
	} );

	it( 'should not execute if one of contextElements contains the DOM event target', () => {
		const target = document.createElement( 'div' );
		activator.returns( true );

		contextElement2.appendChild( target );
		target.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );

		sinon.assert.notCalled( actionSpy );
	} );
} );
describe( 'ClassicEditorUIView', () => {
	let locale, view, editingView, editingViewRoot;

	testUtils.createSinonSandbox();

	beforeEach( () => {
		locale = new Locale( 'en' );
		editingView = new EditingView();
		editingViewRoot = createRoot( editingView.document );
		view = new ClassicEditorUIView( locale, editingView );
		view.editable.name = editingViewRoot.rootName;
		view.render();
	} );

	afterEach( () => {
		view.destroy();
	} );

	describe( 'constructor()', () => {
		describe( '#stickyPanel', () => {
			it( 'is created', () => {
				expect( view.stickyPanel ).to.be.instanceof( StickyPanelView );
			} );

			it( 'is given a locate object', () => {
				expect( view.stickyPanel.locale ).to.equal( locale );
			} );

			it( 'is put into the "top" collection', () => {
				expect( view.top.get( 0 ) ).to.equal( view.stickyPanel );
			} );
		} );

		describe( '#toolbar', () => {
			it( 'is created', () => {
				expect( view.toolbar ).to.be.instanceof( ToolbarView );
			} );

			it( 'is given a locate object', () => {
				expect( view.toolbar.locale ).to.equal( locale );
			} );

			it( 'is put into the "stickyPanel.content" collection', () => {
				expect( view.stickyPanel.content.get( 0 ) ).to.equal( view.toolbar );
			} );
		} );

		describe( '#editable', () => {
			it( 'is created', () => {
				expect( view.editable ).to.be.instanceof( InlineEditableUIView );
			} );

			it( 'is given a locate object', () => {
				expect( view.editable.locale ).to.equal( locale );
			} );

			it( 'is put into the "main" collection', () => {
				expect( view.main.get( 0 ) ).to.equal( view.editable );
			} );
		} );
	} );
} );
describe( 'HeadingUI', () => {
	let editor, editorElement, dropdown;

	testUtils.createSinonSandbox();

	before( () => {
		addTranslations( 'en', {
			'Choose heading': 'Choose heading',
			'Paragraph': 'Paragraph',
			'Heading': 'Heading',
			'Heading 1': 'Heading 1',
			'Heading 2': 'Heading 2',
		} );

		addTranslations( 'pl', {
			'Choose heading': 'Wybierz nagłówek',
			'Paragraph': 'Akapit',
			'Heading': 'Nagłówek',
			'Heading 1': 'Nagłówek 1',
			'Heading 2': 'Nagłówek 2',
		} );
	} );

	after( () => {
		clearTranslations();
	} );

	beforeEach( () => {
		editorElement = document.createElement( 'div' );
		document.body.appendChild( editorElement );

		return ClassicTestEditor
			.create( editorElement, {
				plugins: [ HeadingUI, HeadingEditing ],
				toolbar: [ 'heading' ]
			} )
			.then( newEditor => {
				editor = newEditor;
				dropdown = editor.ui.componentFactory.create( 'heading' );

				// Set data so the commands will be enabled.
				setData( editor.model, '<paragraph>f{}oo</paragraph>' );
			} );
	} );

	afterEach( () => {
		editorElement.remove();

		return editor.destroy();
	} );

	describe( 'init()', () => {
		it( 'should register options feature component', () => {
			const dropdown = editor.ui.componentFactory.create( 'heading' );

			expect( dropdown ).to.be.instanceOf( DropdownView );
			expect( dropdown.buttonView.isEnabled ).to.be.true;
			expect( dropdown.buttonView.isOn ).to.be.false;
			expect( dropdown.buttonView.label ).to.equal( 'Paragraph' );
			expect( dropdown.buttonView.tooltip ).to.equal( 'Heading' );
		} );

		it( 'should execute format command on model execute event for paragraph', () => {
			const executeSpy = testUtils.sinon.spy( editor, 'execute' );
			const dropdown = editor.ui.componentFactory.create( 'heading' );

			dropdown.commandName = 'paragraph';
			dropdown.fire( 'execute' );

			sinon.assert.calledOnce( executeSpy );
			sinon.assert.calledWithExactly( executeSpy, 'paragraph', undefined );
		} );

		it( 'should execute format command on model execute event for heading', () => {
			const executeSpy = testUtils.sinon.spy( editor, 'execute' );
			const dropdown = editor.ui.componentFactory.create( 'heading' );

			dropdown.commandName = 'heading';
			dropdown.commandValue = 'heading1';
			dropdown.fire( 'execute' );

			sinon.assert.calledOnce( executeSpy );
			sinon.assert.calledWithExactly( executeSpy, 'heading', { value: 'heading1' } );
		} );

		it( 'should focus view after command execution', () => {
			const focusSpy = testUtils.sinon.spy( editor.editing.view, 'focus' );
			const dropdown = editor.ui.componentFactory.create( 'heading' );

			dropdown.commandName = 'paragraph';
			dropdown.fire( 'execute' );

			sinon.assert.calledOnce( focusSpy );
		} );

		it( 'should add custom CSS class to dropdown', () => {
			const dropdown = editor.ui.componentFactory.create( 'heading' );

			dropdown.render();

			expect( dropdown.element.classList.contains( 'ck-heading-dropdown' ) ).to.be.true;
		} );

		describe( 'model to command binding', () => {
			let command, paragraphCommand;

			beforeEach( () => {
				command = editor.commands.get( 'heading' );
				paragraphCommand = editor.commands.get( 'paragraph' );
			} );

			it( 'isEnabled', () => {
				command.isEnabled = false;
				paragraphCommand.isEnabled = false;

				expect( dropdown.buttonView.isEnabled ).to.be.false;

				command.isEnabled = true;
				expect( dropdown.buttonView.isEnabled ).to.be.true;

				command.isEnabled = false;
				expect( dropdown.buttonView.isEnabled ).to.be.false;

				paragraphCommand.isEnabled = true;
				expect( dropdown.buttonView.isEnabled ).to.be.true;
			} );

			it( 'label', () => {
				command.value = false;
				paragraphCommand.value = false;

				expect( dropdown.buttonView.label ).to.equal( 'Choose heading' );

				command.value = 'heading2';
				expect( dropdown.buttonView.label ).to.equal( 'Heading 2' );
				command.value = false;

				paragraphCommand.value = true;
				expect( dropdown.buttonView.label ).to.equal( 'Paragraph' );
			} );
		} );

		describe( 'localization', () => {
			let command, paragraphCommand, editor, dropdown;

			beforeEach( () => {
				return localizedEditor( [
					{ model: 'paragraph', title: 'Paragraph' },
					{ model: 'heading1', view: { name: 'h2' }, title: 'Heading 1' },
					{ model: 'heading2', view: { name: 'h3' }, title: 'Heading 2' }
				] );
			} );

			it( 'does not alter the original config', () => {
				expect( editor.config.get( 'heading.options' ) ).to.deep.equal( [
					{ model: 'paragraph', title: 'Paragraph' },
					{ model: 'heading1', view: { name: 'h2' }, title: 'Heading 1' },
					{ model: 'heading2', view: { name: 'h3' }, title: 'Heading 2' }
				] );
			} );

			it( 'works for the #buttonView', () => {
				const buttonView = dropdown.buttonView;

				// Setting manually paragraph.value to `false` because there might be some content in editor
				// after initialisation (for example empty <p></p> inserted when editor is empty).
				paragraphCommand.value = false;
				expect( buttonView.label ).to.equal( 'Wybierz nagłówek' );
				expect( buttonView.tooltip ).to.equal( 'Nagłówek' );

				paragraphCommand.value = true;
				expect( buttonView.label ).to.equal( 'Akapit' );

				paragraphCommand.value = false;
				command.value = 'heading1';
				expect( buttonView.label ).to.equal( 'Nagłówek 1' );
			} );

			it( 'works for the listView#items in the panel', () => {
				const listView = dropdown.listView;

				expect( listView.items.map( item => item.children.first.label ) ).to.deep.equal( [
					'Akapit',
					'Nagłówek 1',
					'Nagłówek 2'
				] );
			} );

			it( 'allows custom titles', () => {
				return localizedEditor( [
					{ model: 'paragraph', title: 'Custom paragraph title' },
					{ model: 'heading1', view: { name: 'h1' }, title: 'Custom heading1 title' }
				] ).then( () => {
					const listView = dropdown.listView;

					expect( listView.items.map( item => item.children.first.label ) ).to.deep.equal( [
						'Custom paragraph title',
						'Custom heading1 title',
					] );
				} );
			} );

			it( 'translates default using the the locale', () => {
				return localizedEditor( [
					{ model: 'paragraph', title: 'Paragraph' }
				] ).then( () => {
					const listView = dropdown.listView;

					expect( listView.items.map( item => item.children.first.label ) ).to.deep.equal( [
						'Akapit'
					] );
				} );
			} );

			function localizedEditor( options ) {
				const editorElement = document.createElement( 'div' );
				document.body.appendChild( editorElement );

				return ClassicTestEditor
					.create( editorElement, {
						plugins: [ Heading ],
						toolbar: [ 'heading' ],
						language: 'pl',
						heading: {
							options
						}
					} )
					.then( newEditor => {
						editor = newEditor;
						dropdown = editor.ui.componentFactory.create( 'heading' );
						command = editor.commands.get( 'heading' );
						paragraphCommand = editor.commands.get( 'paragraph' );

						editorElement.remove();

						return editor.destroy();
					} );
			}
		} );

		describe( 'class', () => {
			it( 'is set for the listView#items in the panel', () => {
				const listView = dropdown.listView;

				expect( listView.items.map( item => item.children.first.class ) ).to.deep.equal( [
					'ck-heading_paragraph',
					'ck-heading_heading1',
					'ck-heading_heading2',
					'ck-heading_heading3'
				] );
			} );

			it( 'reflects the #value of the commands', () => {
				const listView = dropdown.listView;

				setData( editor.model, '<heading2>f{}oo</heading2>' );

				expect( listView.items.map( item => item.children.first.isOn ) ).to.deep.equal( [
					false,
					false,
					true,
					false
				] );
			} );
		} );
	} );
} );
Beispiel #9
0
describe( 'ButtonView', () => {
	let locale, view;

	testUtils.createSinonSandbox();

	beforeEach( () => {
		locale = { t() {} };

		view = new ButtonView( locale );
		view.render();
	} );

	describe( 'constructor()', () => {
		it( 'creates view#children collection', () => {
			expect( view.children ).to.be.instanceOf( ViewCollection );
		} );

		it( 'creates #tooltipView', () => {
			expect( view.tooltipView ).to.be.instanceOf( TooltipView );
		} );

		it( 'creates #labelView', () => {
			expect( view.labelView ).to.be.instanceOf( View );
			expect( view.labelView.element.classList.contains( 'ck' ) ).to.be.true;
			expect( view.labelView.element.classList.contains( 'ck-button__label' ) ).to.be.true;
		} );

		it( 'creates #iconView', () => {
			expect( view.iconView ).to.be.instanceOf( IconView );
		} );
	} );

	describe( '<button> bindings', () => {
		describe( 'class', () => {
			it( 'is set initially', () => {
				expect( view.element.classList ).to.have.length( 3 );
				expect( view.element.classList.contains( 'ck' ) ).to.true;
				expect( view.element.classList.contains( 'ck-button' ) ).to.true;
				expect( view.element.classList.contains( 'ck-disabled' ) ).to.false;
				expect( view.element.classList.contains( 'ck-off' ) ).to.true;
			} );

			it( 'reacts on view#isEnabled', () => {
				view.isEnabled = true;
				expect( view.element.classList.contains( 'ck-disabled' ) ).to.false;

				view.isEnabled = false;
				expect( view.element.classList.contains( 'ck-disabled' ) ).to.true;
			} );

			it( 'reacts on view#isOn', () => {
				view.isOn = true;
				expect( view.element.classList.contains( 'ck-on' ) ).to.true;

				view.isOn = false;
				expect( view.element.classList.contains( 'ck-on' ) ).to.false;
			} );

			it( 'reacts on view#isVisible', () => {
				view.isVisible = true;
				expect( view.element.classList.contains( 'ck-hidden' ) ).to.be.false;

				view.isVisible = false;
				expect( view.element.classList.contains( 'ck-hidden' ) ).to.be.true;
			} );

			it( 'reacts on view#withText', () => {
				view.withText = true;
				expect( view.element.classList.contains( 'ck-button_with-text' ) ).to.true;

				view.withText = false;
				expect( view.element.classList.contains( 'ck-button_with-text' ) ).to.false;
			} );

			it( 'reacts on view#type', () => {
				// Default value.
				expect( view.element.getAttribute( 'type' ) ).to.equal( 'button' );

				view.type = 'submit';
				expect( view.element.getAttribute( 'type' ) ).to.equal( 'submit' );

				// Default value.
				view.type = null;
				expect( view.element.getAttribute( 'type' ) ).to.equal( 'button' );
			} );

			it( 'reacts on view#class', () => {
				view.set( 'class', 'foo' );

				expect( view.element.classList.contains( 'foo' ) ).to.be.true;
			} );
		} );

		describe( 'labelView', () => {
			it( 'reacts on view#labelStyle', () => {
				expect( view.labelView.element.attributes.getNamedItem( 'style' ) ).to.be.null;

				view.labelStyle = 'color: red';

				expect( view.labelView.element.attributes.getNamedItem( 'style' ).value ).to.equal( 'color: red' );
			} );
		} );

		describe( 'tooltip', () => {
			it( 'is initially set', () => {
				expect( view.children.getIndex( view.tooltipView ) ).to.equal( 0 );
			} );

			it( 'it reacts to #tooltipPosition attribute', () => {
				view.tooltip = 'foo';
				view.icon = '<svg></svg>';

				expect( view.tooltipPosition ).to.equal( 's' );
				expect( view.tooltipView.position ).to.equal( 's' );

				view.tooltipPosition = 'n';
				expect( view.tooltipView.position ).to.equal( 'n' );
			} );

			describe( 'defined as a Boolean', () => {
				it( 'renders tooltip text out of #label and #keystroke', () => {
					view.tooltip = true;
					view.label = 'bar';
					view.keystroke = 'A';

					expect( view.tooltipView.text ).to.equal( 'bar (A)' );
				} );

				it( 'not render tooltip text when #tooltip value is false', () => {
					view.tooltip = false;
					view.label = 'bar';
					view.keystroke = 'A';

					expect( view.tooltipView.text ).to.equal( '' );
				} );

				it( 'reacts to changes in #label and #keystroke', () => {
					view.tooltip = true;
					view.label = 'foo';
					view.keystroke = 'B';

					expect( view.tooltipView.text ).to.equal( 'foo (B)' );

					view.label = 'baz';
					view.keystroke = false;

					expect( view.tooltipView.text ).to.equal( 'baz' );
				} );
			} );

			describe( 'defined as a String', () => {
				it( 'renders as a plain text', () => {
					view.tooltip = 'bar';
					view.label = 'foo';
					view.keystroke = 'A';

					expect( view.tooltipView.text ).to.equal( 'bar' );
				} );

				it( 'reacts to changes of #tooltip', () => {
					view.tooltip = 'bar';

					expect( view.tooltipView.text ).to.equal( 'bar' );

					view.tooltip = 'foo';
					expect( view.tooltipView.text ).to.equal( 'foo' );
				} );
			} );

			describe( 'defined as a Function', () => {
				it( 'generates a tooltip text when passed #label and #keystroke', () => {
					view.tooltip = ( l, k ) => `${ l } - ${ k }`;
					view.label = 'foo';
					view.keystroke = 'A';

					expect( view.tooltipView.text ).to.equal( 'foo - A' );
				} );

				it( 'reacts to changes of #label and #keystroke', () => {
					view.tooltip = ( l, k ) => `${ l } - ${ k }`;
					view.label = 'foo';
					view.keystroke = 'A';

					expect( view.tooltipView.text ).to.equal( 'foo - A' );

					view.label = 'bar';
					view.keystroke = 'B';

					expect( view.tooltipView.text ).to.equal( 'bar - B' );
				} );
			} );
		} );

		describe( 'text', () => {
			it( 'is not initially set ', () => {
				expect( view.element.textContent ).to.equal( '' );
			} );

			it( 'reacts on view#label', () => {
				view.label = 'bar';

				expect( view.element.textContent ).to.equal( 'bar' );
			} );
		} );

		describe( 'tabindex', () => {
			it( 'is initially set ', () => {
				expect( view.element.attributes.tabindex.value ).to.equal( '-1' );
			} );

			it( 'reacts on view#tabindex', () => {
				view.tabindex = 3;

				expect( view.element.attributes.tabindex.value ).to.equal( '3' );
			} );
		} );

		describe( 'aria', () => {
			it( '-labelledby is set', () => {
				expect( view.element.attributes[ 'aria-labelledby' ].value )
					.to.equal( view.element.lastChild.id )
					.to.match( /^ck-editor__aria-label_\w+$/ );
			} );

			it( '-disabled reacts to #isEnabled', () => {
				view.isEnabled = true;
				expect( view.element.attributes[ 'aria-disabled' ] ).to.be.undefined;

				view.isEnabled = false;
				expect( view.element.attributes[ 'aria-disabled' ].value ).to.equal( 'true' );
			} );

			it( '-pressed reacts to #isOn', () => {
				view.isOn = true;
				expect( view.element.attributes[ 'aria-pressed' ].value ).to.equal( 'true' );

				view.isOn = false;
				expect( view.element.attributes[ 'aria-pressed' ] ).to.be.undefined;
			} );
		} );

		describe( 'mousedown event', () => {
			it( 'should be prevented', () => {
				const ret = view.element.dispatchEvent( new Event( 'mousedown', { cancelable: true } ) );

				expect( ret ).to.false;
			} );
		} );

		describe( 'execute event', () => {
			it( 'triggers view#execute event if button is not disabled', () => {
				const spy = sinon.spy();

				view.on( 'execute', spy );
				view.set( 'isEnabled', true );

				view.element.dispatchEvent( new Event( 'click' ) );
				sinon.assert.callCount( spy, 1 );

				view.isEnabled = false;

				view.element.dispatchEvent( new Event( 'click' ) );
				sinon.assert.callCount( spy, 1 );
			} );
		} );
	} );

	describe( 'icon', () => {
		it( 'is omited in #children when view#icon is not defined', () => {
			view = new ButtonView( locale );
			view.render();

			expect( view.element.childNodes ).to.have.length( 2 );
			expect( view.iconView.element ).to.be.null;
		} );

		it( 'is added to the #children when view#icon is defined', () => {
			view = new ButtonView( locale );
			view.icon = '<svg></svg>';
			view.render();

			expect( view.element.childNodes ).to.have.length( 3 );
			expect( view.element.childNodes[ 0 ] ).to.equal( view.iconView.element );

			expect( view.iconView ).to.instanceOf( IconView );
			expect( view.iconView.content ).to.equal( '<svg></svg>' );
			expect( view.iconView.element.classList.contains( 'ck-button__icon' ) ).to.be.true;

			view.icon = '<svg>bar</svg>';
			expect( view.iconView.content ).to.equal( '<svg>bar</svg>' );
		} );

		it( 'is destroyed with the view', () => {
			view = new ButtonView( locale );
			view.icon = '<svg></svg>';
			view.render();

			const spy = sinon.spy( view.iconView, 'destroy' );

			view.destroy();
			sinon.assert.calledOnce( spy );
		} );
	} );

	describe( 'focus()', () => {
		it( 'focuses the button in DOM', () => {
			const spy = sinon.spy( view.element, 'focus' );

			view.focus();

			sinon.assert.calledOnce( spy );
		} );
	} );
} );
Beispiel #10
0
describe( 'FocusCycler', () => {
	let focusables, focusTracker, cycler;

	testUtils.createSinonSandbox();

	beforeEach( () => {
		focusables = new ViewCollection();
		focusTracker = {
			focusedElement: null
		};
		cycler = new FocusCycler( {
			focusables,
			focusTracker
		} );

		testUtils.sinon.stub( global.window, 'getComputedStyle' );

		focusables.add( nonFocusable() );
		focusables.add( focusable() );
		focusables.add( focusable() );
		focusables.add( focusable() );
		focusables.add( nonFocusable() );
	} );

	describe( 'constructor()', () => {
		it( 'sets class properties', () => {
			expect( cycler.focusables ).to.equal( focusables );
			expect( cycler.focusTracker ).to.equal( focusTracker );
		} );
	} );

	describe( 'current()', () => {
		it( 'returns null when no view is focused', () => {
			expect( cycler.current ).to.equal( null );

			focusTracker.focusedElement = focusables.get( 2 ).element;
			expect( cycler.current ).to.equal( 2 );

			focusTracker.focusedElement = null;
			expect( cycler.current ).to.equal( null );
		} );
	} );

	describe( 'first()', () => {
		it( 'returns first focusable view', () => {
			expect( cycler.first ).to.equal( focusables.get( 1 ) );
		} );

		it( 'returns null when no focusable items', () => {
			focusables = new ViewCollection();
			cycler = new FocusCycler( { focusables, focusTracker } );

			focusables.add( nonFocusable() );
			focusables.add( nonFocusable() );

			expect( cycler.first ).to.be.null;
		} );

		it( 'returns null when no items', () => {
			focusables = new ViewCollection();
			cycler = new FocusCycler( { focusables, focusTracker } );

			expect( cycler.first ).to.be.null;
		} );
	} );

	describe( 'last()', () => {
		it( 'returns last focusable view', () => {
			expect( cycler.last ).to.equal( focusables.get( 3 ) );
		} );

		it( 'returns null when no focusable items', () => {
			focusables = new ViewCollection();
			cycler = new FocusCycler( { focusables, focusTracker } );

			focusables.add( nonFocusable() );
			focusables.add( nonFocusable() );

			expect( cycler.last ).to.be.null;
		} );

		it( 'returns null when no items', () => {
			focusables = new ViewCollection();
			cycler = new FocusCycler( { focusables, focusTracker } );

			expect( cycler.last ).to.be.null;
		} );
	} );

	describe( 'next()', () => {
		it( 'cycles to return the next focusable view', () => {
			focusTracker.focusedElement = focusables.get( 2 ).element;
			expect( cycler.next ).to.equal( focusables.get( 3 ) );

			focusTracker.focusedElement = focusables.get( 3 ).element;
			expect( cycler.next ).to.equal( focusables.get( 1 ) );

			focusTracker.focusedElement = focusables.get( 1 ).element;
			expect( cycler.next ).to.equal( focusables.get( 2 ) );
		} );

		it( 'focuses the first focusable view when no view is focused', () => {
			focusTracker.focusedElement = null;

			expect( cycler.next ).to.equal( focusables.get( 1 ) );
		} );

		it( 'returns null when no items', () => {
			focusables = new ViewCollection();
			cycler = new FocusCycler( { focusables, focusTracker } );

			expect( cycler.next ).to.be.null;
		} );

		it( 'returns null when no focusable items', () => {
			focusables = new ViewCollection();
			cycler = new FocusCycler( { focusables, focusTracker } );

			focusables.add( nonFocusable() );
			focusables.add( nonFocusable() );

			expect( cycler.next ).to.be.null;
		} );

		it( 'returns null if the only focusable in focusables', () => {
			focusables = new ViewCollection();
			cycler = new FocusCycler( { focusables, focusTracker } );

			focusables.add( nonFocusable() );
			focusables.add( focusable() );
			focusables.add( nonFocusable() );

			focusTracker.focusedElement = focusables.get( 1 ).element;

			expect( cycler.first ).to.equal( focusables.get( 1 ) );
			expect( cycler.next ).to.be.null;
		} );
	} );

	describe( 'previous()', () => {
		it( 'cycles to return the previous focusable view', () => {
			focusTracker.focusedElement = focusables.get( 1 ).element;
			expect( cycler.previous ).to.equal( focusables.get( 3 ) );

			focusTracker.focusedElement = focusables.get( 2 ).element;
			expect( cycler.previous ).to.equal( focusables.get( 1 ) );

			focusTracker.focusedElement = focusables.get( 3 ).element;
			expect( cycler.previous ).to.equal( focusables.get( 2 ) );
		} );

		it( 'focuses the last focusable view when no view is focused', () => {
			focusTracker.focusedElement = null;

			expect( cycler.previous ).to.equal( focusables.get( 3 ) );
		} );

		it( 'returns null when no items', () => {
			focusables = new ViewCollection();
			cycler = new FocusCycler( { focusables, focusTracker } );

			expect( cycler.previous ).to.be.null;
		} );

		it( 'returns null when no focusable items', () => {
			focusables = new ViewCollection();
			cycler = new FocusCycler( { focusables, focusTracker } );

			focusables.add( nonFocusable() );
			focusables.add( nonFocusable() );

			expect( cycler.previous ).to.be.null;
		} );

		it( 'returns null if the only focusable in focusables', () => {
			focusables = new ViewCollection();
			cycler = new FocusCycler( { focusables, focusTracker } );

			focusables.add( nonFocusable() );
			focusables.add( focusable() );
			focusables.add( nonFocusable() );

			focusTracker.focusedElement = focusables.get( 1 ).element;

			expect( cycler.first ).to.equal( focusables.get( 1 ) );
			expect( cycler.previous ).to.be.null;
		} );
	} );

	describe( 'focusFirst()', () => {
		it( 'focuses first focusable view', () => {
			cycler.focusFirst();

			sinon.assert.calledOnce( focusables.get( 1 ).focus );
		} );

		it( 'does not throw when no focusable items', () => {
			focusables = new ViewCollection();
			cycler = new FocusCycler( { focusables, focusTracker } );

			focusables.add( nonFocusable() );
			focusables.add( nonFocusable() );

			expect( () => {
				cycler.focusFirst();
			} ).to.not.throw();
		} );

		it( 'does not throw when no items', () => {
			focusables = new ViewCollection();
			cycler = new FocusCycler( { focusables, focusTracker } );

			expect( () => {
				cycler.focusFirst();
			} ).to.not.throw();
		} );

		it( 'ignores invisible items', () => {
			const item = focusable();

			focusables = new ViewCollection();
			focusables.add( nonFocusable() );
			focusables.add( focusable( true ) );
			focusables.add( item );

			cycler = new FocusCycler( { focusables, focusTracker } );

			cycler.focusFirst();
			sinon.assert.calledOnce( item.focus );
		} );
	} );

	describe( 'focusLast()', () => {
		it( 'focuses last focusable view', () => {
			cycler.focusLast();

			sinon.assert.calledOnce( focusables.get( 3 ).focus );
		} );

		it( 'does not throw when no focusable items', () => {
			focusables = new ViewCollection();
			cycler = new FocusCycler( { focusables, focusTracker } );

			focusables.add( nonFocusable() );
			focusables.add( nonFocusable() );

			expect( () => {
				cycler.focusLast();
			} ).to.not.throw();
		} );

		it( 'does not throw when no items', () => {
			focusables = new ViewCollection();
			cycler = new FocusCycler( { focusables, focusTracker } );

			expect( () => {
				cycler.focusLast();
			} ).to.not.throw();
		} );
	} );

	describe( 'focusNext()', () => {
		it( 'focuses next focusable view', () => {
			focusTracker.focusedElement = focusables.get( 2 ).element;
			cycler.focusNext();

			sinon.assert.calledOnce( focusables.get( 3 ).focus );
		} );

		it( 'does not throw when no focusable items', () => {
			focusables = new ViewCollection();
			cycler = new FocusCycler( { focusables, focusTracker } );

			focusables.add( nonFocusable() );
			focusables.add( nonFocusable() );

			expect( () => {
				cycler.focusNext();
			} ).to.not.throw();
		} );

		it( 'does not throw when no items', () => {
			focusables = new ViewCollection();
			cycler = new FocusCycler( { focusables, focusTracker } );

			expect( () => {
				cycler.focusNext();
			} ).to.not.throw();
		} );
	} );

	describe( 'focusPrevious()', () => {
		it( 'focuses previous focusable view', () => {
			focusTracker.focusedElement = focusables.get( 1 ).element;
			cycler.focusPrevious();

			sinon.assert.calledOnce( focusables.get( 3 ).focus );
		} );

		it( 'does not throw when no focusable items', () => {
			focusables = new ViewCollection();
			cycler = new FocusCycler( { focusables, focusTracker } );

			focusables.add( nonFocusable() );
			focusables.add( nonFocusable() );

			expect( () => {
				cycler.focusPrevious();
			} ).to.not.throw();
		} );

		it( 'does not throw when no items', () => {
			focusables = new ViewCollection();
			cycler = new FocusCycler( { focusables, focusTracker } );

			expect( () => {
				cycler.focusPrevious();
			} ).to.not.throw();
		} );
	} );

	describe( 'keystrokes', () => {
		it( 'creates event listeners', () => {
			const keystrokeHandler = new KeystrokeHandler();

			cycler = new FocusCycler( {
				focusables, focusTracker, keystrokeHandler,
				actions: {
					focusPrevious: 'arrowup',
					focusNext: 'arrowdown'
				}
			} );

			const keyEvtData = {
				keyCode: keyCodes.arrowup,
				preventDefault: sinon.spy(),
				stopPropagation: sinon.spy()
			};

			const spy1 = sinon.spy( cycler, 'focusPrevious' );
			const spy2 = sinon.spy( cycler, 'focusNext' );

			keystrokeHandler.press( keyEvtData );

			sinon.assert.calledOnce( spy1 );
			sinon.assert.calledOnce( keyEvtData.preventDefault );
			sinon.assert.calledOnce( keyEvtData.stopPropagation );
			sinon.assert.notCalled( spy2 );

			keyEvtData.keyCode = keyCodes.arrowdown;

			keystrokeHandler.press( keyEvtData );

			sinon.assert.calledOnce( spy1 );
			sinon.assert.calledTwice( keyEvtData.preventDefault );
			sinon.assert.calledTwice( keyEvtData.stopPropagation );
			sinon.assert.calledOnce( spy2 );
		} );

		it( 'supports array keystroke syntax', () => {
			const keystrokeHandler = new KeystrokeHandler();

			cycler = new FocusCycler( {
				focusables, focusTracker, keystrokeHandler,
				actions: {
					focusPrevious: [ 'arrowup', 'arrowleft' ],
				}
			} );

			const keyEvtData = {
				keyCode: keyCodes.arrowleft,
				preventDefault: sinon.spy(),
				stopPropagation: sinon.spy()
			};

			const spy = sinon.spy( cycler, 'focusPrevious' );

			keystrokeHandler.press( keyEvtData );

			sinon.assert.calledOnce( spy );
			sinon.assert.calledOnce( keyEvtData.preventDefault );
			sinon.assert.calledOnce( keyEvtData.stopPropagation );
		} );
	} );
} );
Beispiel #11
0
describe( 'EditableUIView', () => {
	let view, editableElement, editingView, editingViewRoot, locale;

	testUtils.createSinonSandbox();

	beforeEach( () => {
		locale = new Locale( 'en' );
		editableElement = document.createElement( 'div' );

		editingView = new EditingView();
		editingViewRoot = new ViewRootEditableElement( 'div' );
		editingViewRoot._document = editingView.document;
		editingView.document.roots.add( editingViewRoot );
		view = new EditableUIView( locale, editingView );
		view.name = editingViewRoot.rootName;

		view.render();
	} );

	describe( 'constructor()', () => {
		it( 'sets initial values of attributes', () => {
			view = new EditableUIView( locale, editingView );

			expect( view.isFocused ).to.be.false;
			expect( view.name ).to.be.null;
			expect( view._externalElement ).to.be.undefined;
			expect( view._editingView ).to.equal( editingView );
		} );

		it( 'renders element from template when no editableElement', () => {
			expect( view.element ).to.equal( view._editableElement );
			expect( view.element.classList.contains( 'ck' ) ).to.be.true;
			expect( view.element.classList.contains( 'ck-content' ) ).to.be.true;
			expect( view.element.classList.contains( 'ck-editor__editable' ) ).to.be.true;
			expect( view.element.classList.contains( 'ck-rounded-corners' ) ).to.be.true;
			expect( view._externalElement ).to.be.undefined;
			expect( view.isRendered ).to.be.true;
		} );

		it( 'accepts editableElement as an argument', () => {
			view = new EditableUIView( locale, editingView, editableElement );
			view.name = editingViewRoot.rootName;

			view.render();
			expect( view.element ).to.equal( editableElement );
			expect( view.element ).to.equal( view._editableElement );
			expect( view.element.classList.contains( 'ck' ) ).to.be.true;
			expect( view.element.classList.contains( 'ck-editor__editable' ) ).to.be.true;
			expect( view.element.classList.contains( 'ck-rounded-corners' ) ).to.be.true;
			expect( view._hasExternalElement ).to.be.true;
			expect( view.isRendered ).to.be.true;
		} );
	} );

	describe( 'View bindings', () => {
		describe( 'class', () => {
			it( 'reacts on view#isFocused', () => {
				view.isFocused = true;

				expect( editingViewRoot.hasClass( 'ck-focused' ) ).to.be.true;
				expect( editingViewRoot.hasClass( 'ck-blurred' ) ).to.be.false;

				view.isFocused = false;
				expect( editingViewRoot.hasClass( 'ck-focused' ) ).to.be.false;
				expect( editingViewRoot.hasClass( 'ck-blurred' ) ).to.be.true;
			} );

			// https://github.com/ckeditor/ckeditor5/issues/1530.
			// https://github.com/ckeditor/ckeditor5/issues/1676.
			it( 'should work when update is handled during the rendering phase', () => {
				const secondEditingViewRoot = new ViewRootEditableElement( 'div' );
				const secondView = new EditableUIView( locale, editingView );
				const secondEditableElement = document.createElement( 'div' );

				document.body.appendChild( secondEditableElement );

				secondEditingViewRoot.rootName = 'second';
				secondEditingViewRoot._document = editingView.document;
				editingView.document.roots.add( secondEditingViewRoot );

				secondView.name = 'second';
				secondView.render();

				editingView.attachDomRoot( editableElement, 'main' );
				editingView.attachDomRoot( secondEditableElement, 'second' );

				view.isFocused = true;
				secondView.isFocused = false;

				expect( editingViewRoot.hasClass( 'ck-focused' ), 1 ).to.be.true;
				expect( editingViewRoot.hasClass( 'ck-blurred' ), 2 ).to.be.false;
				expect( secondEditingViewRoot.hasClass( 'ck-focused' ), 3 ).to.be.false;
				expect( secondEditingViewRoot.hasClass( 'ck-blurred' ), 4 ).to.be.true;

				editingView.isRenderingInProgress = true;
				view.isFocused = false;
				secondView.isFocused = true;

				expect( editingViewRoot.hasClass( 'ck-focused' ), 5 ).to.be.true;
				expect( editingViewRoot.hasClass( 'ck-blurred' ), 6 ).to.be.false;
				expect( secondEditingViewRoot.hasClass( 'ck-focused' ), 7 ).to.be.false;
				expect( secondEditingViewRoot.hasClass( 'ck-blurred' ), 8 ).to.be.true;

				editingView.isRenderingInProgress = false;

				expect( editingViewRoot.hasClass( 'ck-focused' ), 9 ).to.be.false;
				expect( editingViewRoot.hasClass( 'ck-blurred' ), 10 ).to.be.true;
				expect( secondEditingViewRoot.hasClass( 'ck-focused' ), 11 ).to.be.true;
				expect( secondEditingViewRoot.hasClass( 'ck-blurred' ), 12 ).to.be.false;

				secondEditableElement.remove();
			} );
		} );
	} );

	describe( 'destroy()', () => {
		it( 'calls super#destroy()', () => {
			const spy = testUtils.sinon.spy( View.prototype, 'destroy' );

			view.destroy();
			sinon.assert.calledOnce( spy );
		} );

		it( 'can be called multiple times', () => {
			expect( () => {
				view.destroy();
				view.destroy();
			} ).to.not.throw();
		} );

		describe( 'when #editableElement as an argument', () => {
			it( 'reverts the template of editableElement', () => {
				editableElement = document.createElement( 'div' );
				editableElement.classList.add( 'foo' );
				editableElement.contentEditable = false;

				view = new EditableUIView( locale, editingView, editableElement );
				view.name = editingViewRoot.rootName;

				view.render();
				view.destroy();
				expect( view.element.classList.contains( 'ck' ) ).to.be.false;
				expect( view.element.classList.contains( 'foo' ) ).to.be.true;
			} );
		} );
	} );
} );
Beispiel #12
0
describe( 'Input feature', () => {
	let editor, model, modelRoot, view, viewDocument, viewRoot, listenter;

	testUtils.createSinonSandbox();

	beforeEach( () => {
		listenter = Object.create( EmitterMixin );

		const domElement = document.createElement( 'div' );
		document.body.appendChild( domElement );

		return ClassicTestEditor.create( domElement, { plugins: [ Input, Paragraph, Bold, Italic, List, ShiftEnter, Link ] } )
			.then( newEditor => {
				// Mock image feature.
				newEditor.model.schema.register( 'image', { allowWhere: '$text' } );

				newEditor.conversion.elementToElement( {
					model: 'image',
					view: 'img'
				} );

				editor = newEditor;
				model = editor.model;
				modelRoot = model.document.getRoot();
				view = editor.editing.view;
				viewDocument = view.document;
				viewRoot = viewDocument.getRoot();

				editor.setData( '<p>foobar</p>' );

				model.change( writer => {
					writer.setSelection( modelRoot.getChild( 0 ), 3 );
				} );
			} );
	} );

	afterEach( () => {
		listenter.stopListening();

		return editor.destroy();
	} );

	describe( 'mutations handling', () => {
		it( 'should handle text mutation', () => {
			viewDocument.fire( 'mutations', [
				{
					type: 'text',
					oldText: 'foobar',
					newText: 'fooxbar',
					node: viewRoot.getChild( 0 ).getChild( 0 )
				}
			] );

			expect( getModelData( model ) ).to.equal( '<paragraph>foox[]bar</paragraph>' );
			expect( getViewData( view ) ).to.equal( '<p>foox{}bar</p>' );
		} );

		it( 'should handle text mutation change', () => {
			viewDocument.fire( 'mutations', [
				{
					type: 'text',
					oldText: 'foobar',
					newText: 'foodar',
					node: viewRoot.getChild( 0 ).getChild( 0 )
				}
			] );

			expect( getModelData( model ) ).to.equal( '<paragraph>food[]ar</paragraph>' );
			expect( getViewData( view ) ).to.equal( '<p>food{}ar</p>' );
		} );

		it( 'should handle text node insertion', () => {
			editor.setData( '<p></p>' );

			viewDocument.fire( 'mutations', [
				{
					type: 'children',
					oldChildren: [],
					newChildren: [ new ViewText( 'x' ) ],
					node: viewRoot.getChild( 0 )
				}
			] );

			expect( getModelData( model ) ).to.equal( '<paragraph>x[]</paragraph>' );
			expect( getViewData( view ) ).to.equal( '<p>x{}</p>' );
		} );

		it( 'should apply selection attributes to the inserted text', () => {
			setModelData( model, '<paragraph>[]</paragraph>', {
				selectionAttributes: {
					bold: true,
					italic: true
				}
			} );
			viewDocument.fire( 'mutations', [
				{
					type: 'children',
					oldChildren: [],
					newChildren: [ new ViewText( 'x' ) ],
					node: viewRoot.getChild( 0 )
				}
			] );

			expect( getModelData( model ) ).to.equal(
				'<paragraph><$text bold="true" italic="true">x[]</$text></paragraph>'
			);
		} );

		it( 'should handle multiple text mutations', () => {
			editor.setData( '<p>foo<strong>bar</strong></p>' );

			viewDocument.fire( 'mutations', [
				{
					type: 'text',
					oldText: 'foo',
					newText: 'foob',
					node: viewRoot.getChild( 0 ).getChild( 0 )
				},
				{
					type: 'text',
					oldText: 'bar',
					newText: 'ar',
					node: viewRoot.getChild( 0 ).getChild( 1 ).getChild( 0 )
				}
			] );

			expect( getModelData( model ) ).to.equal( '<paragraph>foob[]<$text bold="true">ar</$text></paragraph>' );
			expect( getViewData( view ) ).to.equal( '<p>foob{}<strong>ar</strong></p>' );
		} );

		it( 'should handle multiple text node insertion', () => {
			editor.setData( '<p></p><p></p>' );

			viewDocument.fire( 'mutations', [
				{
					type: 'children',
					oldChildren: [],
					newChildren: [ new ViewText( 'x' ) ],
					node: viewRoot.getChild( 0 )
				},
				{
					type: 'children',
					oldChildren: [],
					newChildren: [ new ViewText( 'y' ) ],
					node: viewRoot.getChild( 1 )
				}
			] );

			expect( getModelData( model ) ).to.equal( '<paragraph>x</paragraph><paragraph>y[]</paragraph>' );
			expect( getViewData( view ) ).to.equal( '<p>x</p><p>y{}</p>' );
		} );

		it( 'should do nothing when two nodes were inserted', () => {
			editor.setData( '<p></p>' );

			viewDocument.fire( 'mutations', [
				{
					type: 'children',
					oldChildren: [],
					newChildren: [ new ViewText( 'x' ), new ViewElement( 'img' ) ],
					node: viewRoot.getChild( 0 )
				}
			] );

			expect( getModelData( model ) ).to.equal( '<paragraph>[]</paragraph>' );
			expect( getViewData( view ) ).to.equal( '<p>[]</p>' );
		} );

		it( 'should do nothing when two nodes were inserted and one removed', () => {
			viewDocument.fire( 'mutations', [
				{
					type: 'children',
					oldChildren: [ new ViewText( 'foobar' ) ],
					newChildren: [ new ViewText( 'x' ), new ViewElement( 'img' ) ],
					node: viewRoot.getChild( 0 )
				}
			] );

			expect( getModelData( model ) ).to.equal( '<paragraph>foo[]bar</paragraph>' );
			expect( getViewData( view ) ).to.equal( '<p>foo{}bar</p>' );
		} );

		it( 'should handle multiple children in the node', () => {
			editor.setData( '<p>foo<img></img></p>' );

			viewDocument.fire( 'mutations', [
				{
					type: 'children',
					oldChildren: [ new ViewText( 'foo' ), viewRoot.getChild( 0 ).getChild( 1 ) ],
					newChildren: [ new ViewText( 'foo' ), viewRoot.getChild( 0 ).getChild( 1 ), new ViewText( 'x' ) ],
					node: viewRoot.getChild( 0 )
				}
			] );

			expect( getModelData( model ) ).to.equal( '<paragraph>foo<image></image>x[]</paragraph>' );
			expect( getViewData( view ) ).to.equal( '<p>foo<img></img>x{}</p>' );
		} );

		it( 'should do nothing when node was removed', () => {
			viewDocument.fire( 'mutations', [
				{
					type: 'children',
					oldChildren: [ new ViewText( 'foobar' ) ],
					newChildren: [],
					node: viewRoot.getChild( 0 )
				}
			] );

			expect( getModelData( model ) ).to.equal( '<paragraph>foo[]bar</paragraph>' );
			expect( getViewData( view ) ).to.equal( '<p>foo{}bar</p>' );
		} );

		it( 'should do nothing when element was inserted', () => {
			editor.setData( '<p></p>' );

			viewDocument.fire( 'mutations', [
				{
					type: 'children',
					oldChildren: [],
					newChildren: [ new ViewElement( 'img' ) ],
					node: viewRoot.getChild( 0 )
				}
			] );

			expect( getModelData( model ) ).to.equal( '<paragraph>[]</paragraph>' );
			expect( getViewData( view ) ).to.equal( '<p>[]</p>' );
		} );

		it( 'should set model selection appropriately to view selection passed in mutations event', () => {
			// This test case emulates spellchecker correction.

			const viewSelection = view.createSelection();
			viewSelection.setTo( viewRoot.getChild( 0 ).getChild( 0 ), 6 );

			viewDocument.fire( 'mutations',
				[ {
					type: 'text',
					oldText: 'foobar',
					newText: 'foodar',
					node: viewRoot.getChild( 0 ).getChild( 0 )
				} ],
				viewSelection
			);

			expect( getModelData( model ) ).to.equal( '<paragraph>foodar[]</paragraph>' );
			expect( getViewData( view ) ).to.equal( '<p>foodar{}</p>' );
		} );

		it( 'should use up to one insert and remove operations (spellchecker)', () => {
			// This test case emulates spellchecker correction.

			const viewSelection = view.createSelection();
			viewSelection.setTo( viewRoot.getChild( 0 ).getChild( 0 ), 6 );

			testUtils.sinon.spy( Writer.prototype, 'insert' );
			testUtils.sinon.spy( Writer.prototype, 'remove' );

			viewDocument.fire( 'mutations',
				[ {
					type: 'text',
					oldText: 'foobar',
					newText: 'fxobxr',
					node: viewRoot.getChild( 0 ).getChild( 0 )
				} ],
				viewSelection
			);

			expect( Writer.prototype.insert.calledOnce ).to.be.true;
			expect( Writer.prototype.remove.calledOnce ).to.be.true;
		} );

		it( 'should place selection after when correcting to longer word (spellchecker)', () => {
			// This test case emulates spellchecker correction.
			editor.setData( '<p>Foo hous a</p>' );

			const viewSelection = view.createSelection();
			viewSelection.setTo( viewRoot.getChild( 0 ).getChild( 0 ), 9 );

			viewDocument.fire( 'mutations',
				[ {
					type: 'text',
					oldText: 'Foo hous a',
					newText: 'Foo house a',
					node: viewRoot.getChild( 0 ).getChild( 0 )
				} ],
				viewSelection
			);

			expect( getModelData( model ) ).to.equal( '<paragraph>Foo house[] a</paragraph>' );
			expect( getViewData( view ) ).to.equal( '<p>Foo house{} a</p>' );
		} );

		it( 'should place selection after when correcting to shorter word (spellchecker)', () => {
			// This test case emulates spellchecker correction.
			editor.setData( '<p>Bar athat foo</p>' );

			const viewSelection = view.createSelection();
			viewSelection.setTo( viewRoot.getChild( 0 ).getChild( 0 ), 8 );

			viewDocument.fire( 'mutations',
				[ {
					type: 'text',
					oldText: 'Bar athat foo',
					newText: 'Bar that foo',
					node: viewRoot.getChild( 0 ).getChild( 0 )
				} ],
				viewSelection
			);

			expect( getModelData( model ) ).to.equal( '<paragraph>Bar that[] foo</paragraph>' );
			expect( getViewData( view ) ).to.equal( '<p>Bar that{} foo</p>' );
		} );

		it( 'should place selection after when merging two words (spellchecker)', () => {
			// This test case emulates spellchecker correction.
			editor.setData( '<p>Foo hous e</p>' );

			const viewSelection = view.createSelection();
			viewSelection.setTo( viewRoot.getChild( 0 ).getChild( 0 ), 9 );

			viewDocument.fire( 'mutations',
				[ {
					type: 'text',
					oldText: 'Foo hous e',
					newText: 'Foo house',
					node: viewRoot.getChild( 0 ).getChild( 0 )
				} ],
				viewSelection
			);

			expect( getModelData( model ) ).to.equal( '<paragraph>Foo house[]</paragraph>' );
			expect( getViewData( view ) ).to.equal( '<p>Foo house{}</p>' );
		} );

		it( 'should place non-collapsed selection after changing single character (composition)', () => {
			editor.setData( '<p>Foo house</p>' );

			const viewSelection = view.createSelection();
			viewSelection.setTo( viewRoot.getChild( 0 ).getChild( 0 ), 8 );
			viewSelection.setFocus( viewRoot.getChild( 0 ).getChild( 0 ), 9 );

			viewDocument.fire( 'mutations',
				[ {
					type: 'text',
					oldText: 'Foo house',
					newText: 'Foo housa',
					node: viewRoot.getChild( 0 ).getChild( 0 )
				} ],
				viewSelection
			);

			expect( getModelData( model ) ).to.equal( '<paragraph>Foo hous[a]</paragraph>' );
			expect( getViewData( view ) ).to.equal( '<p>Foo hous{a}</p>' );
		} );

		it( 'should replace last &nbsp; with space', () => {
			model.change( writer => {
				writer.setSelection( modelRoot.getChild( 0 ), 6 );
			} );

			viewDocument.fire( 'mutations', [
				{
					type: 'text',
					oldText: 'foobar',
					newText: 'foobar\u00A0',
					node: viewRoot.getChild( 0 ).getChild( 0 )
				}
			] );

			expect( getModelData( model ) ).to.equal( '<paragraph>foobar []</paragraph>' );
			expect( getViewData( view ) ).to.equal( '<p>foobar {}</p>' );
		} );

		it( 'should replace first &nbsp; with space', () => {
			model.change( writer => {
				writer.setSelection(
					writer.createRange(
						writer.createPositionAt( modelRoot.getChild( 0 ), 0 ),
						writer.createPositionAt( modelRoot.getChild( 0 ), 0 )
					)
				);
			} );

			viewDocument.fire( 'mutations', [
				{
					type: 'text',
					oldText: 'foobar',
					newText: '\u00A0foobar',
					node: viewRoot.getChild( 0 ).getChild( 0 )
				}
			] );

			expect( getModelData( model ) ).to.equal( '<paragraph> []foobar</paragraph>' );
			expect( getViewData( view ) ).to.equal( '<p> {}foobar</p>' );
		} );

		it( 'should replace all &nbsp; with spaces', () => {
			model.change( writer => {
				writer.setSelection( modelRoot.getChild( 0 ), 6 );
			} );

			viewDocument.fire( 'mutations', [
				{
					type: 'text',
					oldText: 'foobar',
					newText: 'foobar\u00A0\u00A0\u00A0baz',
					node: viewRoot.getChild( 0 ).getChild( 0 )
				}
			] );

			expect( getModelData( model ) ).to.equal( '<paragraph>foobar   baz[]</paragraph>' );
			expect( getViewData( view ) ).to.equal( '<p>foobar   baz{}</p>' );
		} );

		// ckeditor5#718.
		it( 'should not crash and prevent all changes if view common ancestor of mutations cannot be mapped to model', () => {
			editor.setData( '<p>Foo</p><ul><li>Bar</li><li>Baz</li></ul>' );

			const ul = viewRoot.getChild( 1 );

			viewDocument.fire( 'mutations', [
				{
					type: 'text',
					oldText: 'Bar',
					newText: 'Bx',
					node: ul.getChild( 0 )
				},
				{
					type: 'children',
					oldChildren: [ ul.getChild( 0 ), ul.getChild( 1 ) ],
					newChildren: [ ul.getChild( 0 ) ],
					node: ul
				}
			] );

			expect( getViewData( view ) ).to.equal( '<p>{}Foo</p><ul><li>Bar</li><li>Baz</li></ul>' );
		} );

		it( 'should handle bogus br correctly', () => {
			editor.setData( '<p><strong>Foo</strong></p>' );

			editor.model.change( writer => {
				writer.setSelection( editor.model.document.getRoot().getChild( 0 ), 'end' );
				writer.removeSelectionAttribute( 'bold' );
			} );

			// We need to change the DOM content manually because typing algorithm actually does not check
			// `newChildren` and `oldChildren` list but takes them from DOM and model.
			const p = viewRoot.getChild( 0 );
			const domP = editor.editing.view.domConverter.mapViewToDom( p );
			domP.appendChild( document.createTextNode( ' ' ) );
			domP.appendChild( document.createElement( 'br' ) );

			viewDocument.fire( 'mutations', [
				{
					type: 'children',
					oldChildren: [ viewRoot.getChild( 0 ).getChild( 0 ) ],
					newChildren: [
						new ViewElement( 'strong', null, new ViewText( 'Foo' ) ),
						new ViewText( ' ' ),
						new ViewElement( 'br' )
					],
					node: viewRoot.getChild( 0 )
				}
			] );

			expect( getViewData( view ) ).to.equal( '<p><strong>Foo</strong> {}</p>' );
		} );

		it( 'should handle children mutation correctly if there are soft breaks in the mutated container', () => {
			editor.setData( '<p><strong>Foo</strong><br /><strong>Bar</strong></p>' );

			editor.model.change( writer => {
				writer.setSelection( editor.model.document.getRoot().getChild( 0 ), 'end' );
				writer.removeSelectionAttribute( 'bold' );
			} );

			// We need to change the DOM content manually because typing algorithm actually does not check
			// `newChildren` and `oldChildren` list but takes them from DOM and model.
			const p = viewRoot.getChild( 0 );
			const domP = editor.editing.view.domConverter.mapViewToDom( p );
			domP.appendChild( document.createTextNode( ' ' ) );
			domP.appendChild( document.createElement( 'br' ) );

			viewDocument.fire( 'mutations', [
				{
					type: 'children',
					oldChildren: [ ...viewRoot.getChild( 0 ).getChildren() ],
					newChildren: [
						new ViewElement( 'strong', null, new ViewText( 'Foo' ) ),
						new ViewElement( 'br' ),
						new ViewElement( 'strong', null, new ViewText( 'Bar' ) ),
						new ViewText( ' ' ),
						new ViewElement( 'br' )
					],
					node: viewRoot.getChild( 0 )
				}
			] );

			expect( getViewData( view ) ).to.equal( '<p><strong>Foo</strong><br></br><strong>Bar</strong> {}</p>' );
		} );

		// ckeditor5-typing#170.
		it( 'should handle mutations correctly if there was an &nbsp; in model already', () => {
			editor.setData( '<p><a href="#"><strong>F</strong></a><strong>oo&nbsp;bar</strong></p>' );

			model.change( writer => {
				writer.setSelection( modelRoot.getChild( 0 ), 1 );
			} );

			// We need to change the DOM content manually because typing algorithm actually does not check
			// `newChildren` and `oldChildren` list but takes them from DOM and model.
			const strong = viewRoot.getChild( 0 ).getChild( 0 ).getChild( 0 );
			const domStrong = editor.editing.view.domConverter.mapViewToDom( strong );
			domStrong.appendChild( document.createTextNode( 'x' ) );

			// The mutation provided here is a bit different than what browser outputs, but browser outputs three mutations
			// which changes the order of elements in the DOM so to keep it simple, only one, key mutation is used in the test.
			// Also, the only thing that the typing algorithm takes from the mutations is `node`...
			viewDocument.fire( 'mutations', [
				{
					type: 'children',
					oldChildren: Array.from( viewRoot.getChild( 0 ).getChild( 0 ).getChildren() ),
					newChildren: [
						new ViewElement( 'strong', null, new ViewText( 'Fx' ) ),
					],
					node: viewRoot.getChild( 0 ).getChild( 0 )
				}
			] );

			expect( getModelData( model ) ).to.equal(
				'<paragraph>' +
					'<$text bold="true" linkHref="#">Fx[]</$text>' +
					'<$text bold="true">oo\u00A0bar</$text>' +
				'</paragraph>'
			);

			expect( getViewData( view ) ).to.equal(
				'<p>' +
					'<a class="ck-link_selected" href="#"><strong>Fx{}</strong></a>' +
					'<strong>oo\u00A0bar</strong>' +
				'</p>'
			);
		} );

		it( 'should handle correctly if last element is inline', () => {
			editor.model.schema.register( 'placeholder', { allowWhere: '$text', isInline: true } );

			editor.conversion.elementToElement( {
				model: 'placeholder',
				view: 'placeholder'
			} );

			editor.setData( '<p>foo<placeholder></placeholder></p>' );

			editor.model.change( writer => {
				writer.setSelection( editor.model.document.getRoot().getChild( 0 ), 'end' );
			} );

			expect( getViewData( view ) ).to.equal( '<p>foo<placeholder></placeholder>[]</p>' );

			// We need to change the DOM content manually because typing algorithm actually does not check
			// `newChildren` and `oldChildren` list but takes them from DOM and model.
			const p = viewRoot.getChild( 0 );
			const domP = editor.editing.view.domConverter.mapViewToDom( p );
			domP.appendChild( document.createTextNode( '!' ) );

			viewDocument.fire( 'mutations', [
				{
					type: 'children',
					oldChildren: [ ...viewRoot.getChild( 0 ).getChildren() ],
					newChildren: [
						new ViewText( 'Foo' ),
						new ViewContainerElement( 'placeholder' ),
						new ViewText( 'f' )
					],
					node: viewRoot.getChild( 0 )
				}
			] );

			expect( getViewData( view ) ).to.equal( '<p>foo<placeholder></placeholder>!{}</p>' );
		} );

		it( 'should handle correctly if some elements are inline', () => {
			editor.model.schema.register( 'placeholder', { allowWhere: '$text', isInline: true } );

			editor.conversion.elementToElement( {
				model: 'placeholder',
				view: 'placeholder'
			} );

			editor.setData( '<p>foo<placeholder></placeholder>bar<placeholder></placeholder>baz</p>' );

			editor.model.change( writer => {
				writer.setSelection( editor.model.document.getRoot().getChild( 0 ), 'end' );
			} );

			// We need to change the DOM content manually because typing algorithm actually does not check
			// `newChildren` and `oldChildren` list but takes them from DOM and model.
			const p = viewRoot.getChild( 0 );
			const domP = editor.editing.view.domConverter.mapViewToDom( p );
			domP.appendChild( document.createTextNode( '!' ) );

			viewDocument.fire( 'mutations', [
				{
					type: 'children',
					oldChildren: [ ...viewRoot.getChild( 0 ).getChildren() ],
					newChildren: [
						new ViewText( 'foo' ),
						new ViewContainerElement( 'placeholder' ),
						new ViewText( 'bar' ),
						new ViewContainerElement( 'placeholder' ),
						new ViewText( 'baz' )
					],
					node: viewRoot.getChild( 0 )
				}
			] );

			expect( getViewData( view ) ).to.equal( '<p>foo<placeholder></placeholder>bar<placeholder></placeholder>baz!{}</p>' );
		} );

		// https://github.com/ckeditor/ckeditor5-typing/issues/181
		it( 'should not crash if the mutation old text is same as new text', () => {
			// It shouldn't matter what data is here, I am putting it like it is in the test scenario, but it is really about
			// what mutations are generated.
			editor.setData( '<p>Foo<strong> </strong>&nbsp;Bar</p>' );

			const p = viewRoot.getChild( 0 );

			viewDocument.fire( 'mutations', [
				{
					type: 'text',
					oldText: ' ',
					newText: ' ',
					node: p.getChild( 1 )
				},
				{
					type: 'text',
					oldText: 'Foo',
					newText: 'Foox',
					node: p.getChild( 0 )
				}
			] );

			expect( getViewData( view ) ).to.equal( '<p>Foox{}<strong> </strong> Bar</p>' );
		} );
	} );

	describe( 'keystroke handling', () => {
		it( 'should remove contents', () => {
			model.change( writer => {
				writer.setSelection(
					writer.createRange(
						writer.createPositionAt( modelRoot.getChild( 0 ), 2 ),
						writer.createPositionAt( modelRoot.getChild( 0 ), 4 )
					)
				);
			} );

			listenter.listenTo( viewDocument, 'keydown', () => {
				expect( getModelData( model ) ).to.equal( '<paragraph>fo[]ar</paragraph>' );
			}, { priority: 'lowest' } );
		} );

		// #97
		it( 'should remove contents and merge blocks', () => {
			setModelData( model, '<paragraph>fo[o</paragraph><paragraph>b]ar</paragraph>' );

			listenter.listenTo( viewDocument, 'keydown', () => {
				expect( getModelData( model ) ).to.equal( '<paragraph>fo[]ar</paragraph>' );

				viewDocument.fire( 'mutations', [
					{
						type: 'text',
						oldText: 'foar',
						newText: 'foyar',
						node: viewRoot.getChild( 0 ).getChild( 0 )
					}
				] );
			}, { priority: 'lowest' } );

			viewDocument.fire( 'keydown', { keyCode: getCode( 'y' ) } );

			expect( getModelData( model ) ).to.equal( '<paragraph>foy[]ar</paragraph>' );
			expect( getViewData( view ) ).to.equal( '<p>foy{}ar</p>' );
		} );

		it( 'should do nothing on arrow key', () => {
			model.change( writer => {
				writer.setSelection(
					writer.createRange(
						writer.createPositionAt( modelRoot.getChild( 0 ), 2 ),
						writer.createPositionAt( modelRoot.getChild( 0 ), 4 )
					)
				);
			} );

			viewDocument.fire( 'keydown', { keyCode: getCode( 'arrowdown' ) } );

			expect( getModelData( model ) ).to.equal( '<paragraph>fo[ob]ar</paragraph>' );
		} );

		it( 'should do nothing on ctrl combinations', () => {
			model.change( writer => {
				writer.setSelection(
					writer.createRange(
						writer.createPositionAt( modelRoot.getChild( 0 ), 2 ),
						writer.createPositionAt( modelRoot.getChild( 0 ), 4 )
					)
				);
			} );

			viewDocument.fire( 'keydown', { ctrlKey: true, keyCode: getCode( 'c' ) } );

			expect( getModelData( model ) ).to.equal( '<paragraph>fo[ob]ar</paragraph>' );
		} );

		it( 'should do nothing on non printable keys', () => {
			model.change( writer => {
				writer.setSelection(
					writer.createRange(
						writer.createPositionAt( modelRoot.getChild( 0 ), 2 ),
						writer.createPositionAt( modelRoot.getChild( 0 ), 4 )
					)
				);
			} );

			viewDocument.fire( 'keydown', { keyCode: 16 } ); // Shift
			viewDocument.fire( 'keydown', { keyCode: 19 } ); // Pause
			viewDocument.fire( 'keydown', { keyCode: 35 } ); // Home
			viewDocument.fire( 'keydown', { keyCode: 112 } ); // F1
			viewDocument.fire( 'keydown', { keyCode: 255 } ); // Display brightness

			// Media control keys
			viewDocument.fire( 'keydown', { keyCode: 173 } ); // Mute
			viewDocument.fire( 'keydown', { keyCode: 174 } ); // Volume up
			viewDocument.fire( 'keydown', { keyCode: 175 } ); // Volume down
			viewDocument.fire( 'keydown', { keyCode: 176 } ); // Next song
			viewDocument.fire( 'keydown', { keyCode: 177 } ); // Previous song
			viewDocument.fire( 'keydown', { keyCode: 179 } ); // Stop

			expect( getModelData( model ) ).to.equal( '<paragraph>fo[ob]ar</paragraph>' );
		} );

		// #69
		it( 'should do nothing on tab key', () => {
			model.change( writer => {
				writer.setSelection(
					writer.createRange(
						writer.createPositionAt( modelRoot.getChild( 0 ), 2 ),
						writer.createPositionAt( modelRoot.getChild( 0 ), 4 )
					)
				);
			} );

			viewDocument.fire( 'keydown', { keyCode: 9 } ); // Tab

			expect( getModelData( model ) ).to.equal( '<paragraph>fo[ob]ar</paragraph>' );
		} );

		it( 'should do nothing if selection is collapsed', () => {
			viewDocument.fire( 'keydown', { ctrlKey: true, keyCode: getCode( 'c' ) } );

			expect( getModelData( model ) ).to.equal( '<paragraph>foo[]bar</paragraph>' );
		} );

		it( 'should lock buffer if selection is not collapsed', () => {
			const buffer = editor.commands.get( 'input' )._buffer;
			const lockSpy = testUtils.sinon.spy( buffer, 'lock' );
			const unlockSpy = testUtils.sinon.spy( buffer, 'unlock' );

			model.change( writer => {
				writer.setSelection(
					writer.createRange(
						writer.createPositionAt( modelRoot.getChild( 0 ), 2 ),
						writer.createPositionAt( modelRoot.getChild( 0 ), 4 )
					)
				);
			} );

			viewDocument.fire( 'keydown', { keyCode: getCode( 'y' ) } );

			expect( lockSpy.calledOnce ).to.be.true;
			expect( unlockSpy.calledOnce ).to.be.true;
		} );

		it( 'should not lock buffer on non printable keys', () => {
			const buffer = editor.commands.get( 'input' )._buffer;
			const lockSpy = testUtils.sinon.spy( buffer, 'lock' );
			const unlockSpy = testUtils.sinon.spy( buffer, 'unlock' );

			viewDocument.fire( 'keydown', { keyCode: 16 } ); // Shift
			viewDocument.fire( 'keydown', { keyCode: 35 } ); // Home
			viewDocument.fire( 'keydown', { keyCode: 112 } ); // F1

			expect( lockSpy.callCount ).to.be.equal( 0 );
			expect( unlockSpy.callCount ).to.be.equal( 0 );
		} );

		it( 'should not lock buffer on collapsed selection', () => {
			const buffer = editor.commands.get( 'input' )._buffer;
			const lockSpy = testUtils.sinon.spy( buffer, 'lock' );
			const unlockSpy = testUtils.sinon.spy( buffer, 'unlock' );

			viewDocument.fire( 'keydown', { keyCode: getCode( 'b' ) } );
			viewDocument.fire( 'keydown', { keyCode: getCode( 'a' ) } );
			viewDocument.fire( 'keydown', { keyCode: getCode( 'z' ) } );

			expect( lockSpy.callCount ).to.be.equal( 0 );
			expect( unlockSpy.callCount ).to.be.equal( 0 );
		} );

		it( 'should not modify document when input command is disabled and selection is collapsed', () => {
			setModelData( model, '<paragraph>foo[]bar</paragraph>' );

			editor.commands.get( 'input' ).isEnabled = false;

			viewDocument.fire( 'keydown', { keyCode: getCode( 'b' ) } );

			expect( getModelData( model ) ).to.equal( '<paragraph>foo[]bar</paragraph>' );
		} );

		it( 'should not modify document when input command is disabled and selection is non-collapsed', () => {
			setModelData( model, '<paragraph>fo[ob]ar</paragraph>' );

			editor.commands.get( 'input' ).isEnabled = false;

			viewDocument.fire( 'keydown', { keyCode: getCode( 'b' ) } );

			expect( getModelData( model ) ).to.equal( '<paragraph>fo[ob]ar</paragraph>' );
		} );

		describe( '#83', () => {
			it( 'should remove contents on composition start key if not during composition', () => {
				model.change( writer => {
					writer.setSelection(
						writer.createRange(
							writer.createPositionAt( modelRoot.getChild( 0 ), 2 ),
							writer.createPositionAt( modelRoot.getChild( 0 ), 4 )
						)
					);
				} );

				viewDocument.fire( 'keydown', { keyCode: 229 } );

				expect( getModelData( model ) ).to.equal( '<paragraph>fo[]ar</paragraph>' );
			} );

			it( 'should not remove contents on composition start key if during composition', () => {
				model.change( writer => {
					writer.setSelection(
						writer.createRange(
							writer.createPositionAt( modelRoot.getChild( 0 ), 2 ),
							writer.createPositionAt( modelRoot.getChild( 0 ), 4 )
						)
					);
				} );

				viewDocument.fire( 'compositionstart' );
				viewDocument.fire( 'keydown', { keyCode: 229 } );

				expect( getModelData( model ) ).to.equal( '<paragraph>fo[ob]ar</paragraph>' );
			} );

			it( 'should not remove contents on compositionstart event if selection is flat', () => {
				editor.setData( '<p><strong>foo</strong> <i>bar</i></p>' );

				model.change( writer => {
					writer.setSelection(
						writer.createRange(
							writer.createPositionAt( modelRoot.getChild( 0 ), 2 ),
							writer.createPositionAt( modelRoot.getChild( 0 ), 5 )
						)
					);
				} );

				viewDocument.fire( 'compositionstart' );

				expect( getModelData( model ) ).to.equal(
					'<paragraph><$text bold="true">fo[o</$text> <$text italic="true">b]ar</$text></paragraph>' );
			} );

			it( 'should not remove contents on compositionstart event if no selection', () => {
				editor.setData( '<p><strong>foo</strong> <i>bar</i></p>' );

				const documentSelection = model.document.selection;

				// Create empty selection.
				model.document.selection = model.createSelection();

				viewDocument.fire( 'compositionstart' );

				expect( getModelData( model ) ).to.equal(
					'<paragraph><$text bold="true">foo</$text> <$text italic="true">bar</$text></paragraph>' );

				// Restore document selection.
				model.document.selection = documentSelection;
			} );

			it( 'should remove contents on compositionstart event if selection is not flat', () => {
				editor.setData( '<p><strong>foo</strong></p><p><i>bar</i></p>' );

				model.change( writer => {
					writer.setSelection(
						writer.createRange(
							writer.createPositionAt( modelRoot.getChild( 0 ), 2 ),
							writer.createPositionAt( modelRoot.getChild( 1 ), 2 )
						)
					);
				} );

				viewDocument.fire( 'compositionstart' );

				expect( getModelData( model ) ).to.equal(
					'<paragraph><$text bold="true">fo[]</$text><$text italic="true">r</$text></paragraph>' );
			} );

			it( 'should not remove contents on keydown event after compositionend event if selection did not change', () => {
				editor.setData( '<p><strong>foo</strong></p><p><i>bar</i></p>' );

				model.change( writer => {
					writer.setSelection(
						writer.createRange(
							writer.createPositionAt( modelRoot.getChild( 0 ), 2 ),
							writer.createPositionAt( modelRoot.getChild( 1 ), 2 )
						)
					);
				} );

				viewDocument.fire( 'compositionend' );
				viewDocument.fire( 'keydown', { keyCode: 229 } );

				expect( getModelData( model ) ).to.equal(
					'<paragraph><$text bold="true">fo[o</$text></paragraph><paragraph><$text italic="true">ba]r</$text></paragraph>' );
			} );

			it( 'should remove contents on keydown event after compositionend event if selection have changed', () => {
				editor.setData( '<p><strong>foo</strong></p><p><i>bar</i></p>' );

				model.change( writer => {
					writer.setSelection(
						writer.createRange(
							writer.createPositionAt( modelRoot.getChild( 0 ), 2 ),
							writer.createPositionAt( modelRoot.getChild( 1 ), 2 )
						)
					);
				} );

				viewDocument.fire( 'compositionend' );

				model.change( writer => {
					writer.setSelection(
						writer.createRange(
							writer.createPositionAt( modelRoot.getChild( 0 ), 2 ),
							writer.createPositionAt( modelRoot.getChild( 1 ), 1 )
						)
					);
				} );

				viewDocument.fire( 'keydown', { keyCode: 229 } );

				expect( getModelData( model ) ).to.equal(
					'<paragraph><$text bold="true">fo[]</$text><$text italic="true">ar</$text></paragraph>' );
			} );
		} );
	} );
} );
/**
 * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
 * For licensing, see LICENSE.md.
 */

import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
import ImageStyleEditing from '../../src/imagestyle/imagestyleediting';
import ImageEditing from '../../src/image/imageediting';
import ImageStyleCommand from '../../src/imagestyle/imagestylecommand';

import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';

import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';

testUtils.createSinonSandbox();

describe( 'ImageStyleEditing', () => {
	let editor, model, document, viewDocument;

	afterEach( () => {
		editor.destroy();
	} );

	describe( 'plugin', () => {
		beforeEach( () => {
			return VirtualTestEditor
				.create( {
					plugins: [ ImageStyleEditing ],
				} )
				.then( newEditor => {
describe( 'UnlinkCommand', () => {
	let editor, model, document, command;

	testUtils.createSinonSandbox();

	beforeEach( () => {
		return ModelTestEditor.create()
			.then( newEditor => {
				editor = newEditor;
				model = editor.model;
				document = model.document;
				command = new UnlinkCommand( editor );

				model.schema.extend( '$text', {
					allowIn: '$root',
					allowAttributes: 'linkHref'
				} );

				model.schema.register( 'p', { inheritAllFrom: '$block' } );
			} );
	} );

	afterEach( () => {
		return editor.destroy();
	} );

	describe( 'isEnabled', () => {
		it( 'should be true when selection has `linkHref` attribute', () => {
			model.change( writer => {
				writer.setSelectionAttribute( 'linkHref', 'value' );
			} );

			expect( command.isEnabled ).to.true;
		} );

		it( 'should be false when selection doesn\'t have `linkHref` attribute', () => {
			model.change( writer => {
				writer.removeSelectionAttribute( 'linkHref' );
			} );

			expect( command.isEnabled ).to.false;
		} );
	} );

	describe( 'execute()', () => {
		describe( 'non-collapsed selection', () => {
			it( 'should remove `linkHref` attribute from selected text', () => {
				setData( model, '<$text linkHref="url">f[ooba]r</$text>' );

				command.execute();

				expect( getData( model ) ).to.equal( '<$text linkHref="url">f</$text>[ooba]<$text linkHref="url">r</$text>' );
			} );

			it( 'should remove `linkHref` attribute from selected text and do not modified other attributes', () => {
				setData( model, '<$text bold="true" linkHref="url">f[ooba]r</$text>' );

				command.execute();

				const assertAll = () => {
					expect( getData( model ) ).to.equal(
						'<$text bold="true" linkHref="url">f</$text>' +
						'[<$text bold="true">ooba</$text>]' +
						'<$text bold="true" linkHref="url">r</$text>'
					);
				};

				const assertEdge = () => {
					expect( getData( model ) ).to.equal(
						'<$text bold="true" linkHref="url">f</$text>' +
						'[<$text bold="true">ooba]<$text linkHref="url">r</$text></$text>'
					);
				};

				testUtils.checkAssertions( assertAll, assertEdge );
			} );

			it( 'should remove `linkHref` attribute from selected text when attributes have different value', () => {
				setData( model, '[<$text linkHref="url">foo</$text><$text linkHref="other url">bar</$text>]' );

				command.execute();

				expect( getData( model ) ).to.equal( '[foobar]' );
			} );

			it( 'should remove `linkHref` attribute from selection', () => {
				setData( model, '<$text linkHref="url">f[ooba]r</$text>' );

				command.execute();

				expect( document.selection.hasAttribute( 'linkHref' ) ).to.false;
			} );
		} );

		describe( 'collapsed selection', () => {
			it( 'should remove `linkHref` attribute from selection siblings with the same attribute value', () => {
				setData( model, '<$text linkHref="url">foo[]bar</$text>' );

				command.execute();

				expect( getData( model ) ).to.equal( 'foo[]bar' );
			} );

			it( 'should remove `linkHref` attribute from selection siblings with the same attribute value and do not modify ' +
				'other attributes', () => {
				setData(
					model,
					'<$text linkHref="other url">fo</$text>' +
					'<$text linkHref="url">o[]b</$text>' +
					'<$text linkHref="other url">ar</$text>'
				);

				command.execute();

				expect( getData( model ) ).to.equal(
					'<$text linkHref="other url">fo</$text>' +
					'o[]b' +
					'<$text linkHref="other url">ar</$text>'
				);
			} );

			it( 'should do nothing with nodes with the same `linkHref` value when there is a node with different value `linkHref` ' +
				'attribute between', () => {
				setData(
					model,
					'<$text linkHref="same url">f</$text>' +
					'<$text linkHref="other url">o</$text>' +
					'<$text linkHref="same url">o[]b</$text>' +
					'<$text linkHref="other url">a</$text>' +
					'<$text linkHref="same url">r</$text>'
				);

				command.execute();

				expect( getData( model ) )
					.to.equal(
						'<$text linkHref="same url">f</$text>' +
						'<$text linkHref="other url">o</$text>' +
						'o[]b' +
						'<$text linkHref="other url">a</$text>' +
						'<$text linkHref="same url">r</$text>'
					);
			} );

			it( 'should remove `linkHref` attribute from selection siblings with the same attribute value ' +
				'and do nothing with other attributes',
			() => {
				setData(
					model,
					'<$text linkHref="url">f</$text>' +
					'<$text bold="true" linkHref="url">o</$text>' +
					'<$text linkHref="url">o[]b</$text>' +
					'<$text bold="true" linkHref="url">a</$text>' +
					'<$text linkHref="url">r</$text>'
				);

				command.execute();

				expect( getData( model ) ).to.equal(
					'f' +
					'<$text bold="true">o</$text>' +
					'o[]b' +
					'<$text bold="true">a</$text>' +
					'r'
				);
			} );

			it( 'should remove `linkHref` attribute from selection siblings only in the same parent as selection parent', () => {
				setData(
					model,
					'<p><$text linkHref="url">bar</$text></p>' +
					'<p><$text linkHref="url">fo[]o</$text></p>' +
					'<p><$text linkHref="url">bar</$text></p>'
				);

				command.execute();

				expect( getData( model ) ).to.equal(
					'<p><$text linkHref="url">bar</$text></p>' +
					'<p>fo[]o</p>' +
					'<p><$text linkHref="url">bar</$text></p>'
				);
			} );

			it( 'should remove `linkHref` attribute from selection siblings when selection is at the end of link', () => {
				setData( model, '<$text linkHref="url">foobar</$text>[]' );

				command.execute();

				expect( getData( model ) ).to.equal( 'foobar[]' );
			} );

			it( 'should remove `linkHref` attribute from selection siblings when selection is at the beginning of link', () => {
				setData( model, '[]<$text linkHref="url">foobar</$text>' );

				command.execute();

				expect( getData( model ) ).to.equal( '[]foobar' );
			} );

			it( 'should remove `linkHref` attribute from selection siblings on the left side when selection is between two elements with ' +
				'different `linkHref` attributes',
			() => {
				setData( model, '<$text linkHref="url">foo</$text>[]<$text linkHref="other url">bar</$text>' );

				command.execute();

				expect( getData( model ) ).to.equal( 'foo[]<$text linkHref="other url">bar</$text>' );
			} );

			it( 'should remove `linkHref` attribute from selection', () => {
				setData( model, '<$text linkHref="url">foo[]bar</$text>' );

				command.execute();

				expect( document.selection.hasAttribute( 'linkHref' ) ).to.false;
			} );
		} );
	} );
} );
Beispiel #15
0
describe( 'scrollAncestorsToShowTarget()', () => {
	let target, element, firstAncestor, secondAncestor;

	testUtils.createSinonSandbox();

	beforeEach( () => {
		element = document.createElement( 'p' );
		firstAncestor = document.createElement( 'blockquote' );
		secondAncestor = document.createElement( 'div' );

		document.body.appendChild( secondAncestor );
		secondAncestor.appendChild( firstAncestor );
		firstAncestor.appendChild( element );

		// Make the element immune to the border-width-* styles in the test environment.
		testUtils.sinon.stub( window, 'getComputedStyle' ).returns( {
			borderTopWidth: '0px',
			borderRightWidth: '0px',
			borderBottomWidth: '0px',
			borderLeftWidth: '0px'
		} );

		stubRect( firstAncestor, {
			top: 0, right: 100, bottom: 100, left: 0, width: 100, height: 100
		}, {
			scrollLeft: 100, scrollTop: 100
		} );

		stubRect( secondAncestor, {
			top: -100, right: 0, bottom: 0, left: -100, width: 100, height: 100
		}, {
			scrollLeft: 100, scrollTop: 100
		} );

		stubRect( document.body, {
			top: 1000, right: 2000, bottom: 1000, left: 1000, width: 1000, height: 1000
		}, {
			scrollLeft: 1000, scrollTop: 1000
		} );
	} );

	afterEach( () => {
		secondAncestor.remove();
	} );

	describe( 'for an HTMLElement', () => {
		beforeEach( () => {
			target = element;
		} );

		test();
	} );

	describe( 'for a DOM Range', () => {
		beforeEach( () => {
			target = document.createRange();
			target.setStart( firstAncestor, 0 );
			target.setEnd( firstAncestor, 0 );
		} );

		test();

		it( 'should set #scrollTop and #scrollLeft of the ancestor to show the target (above, attached to the Text)', () => {
			const text = new Text( 'foo' );
			firstAncestor.appendChild( text );
			target.setStart( text, 1 );
			target.setEnd( text, 2 );

			stubRect( target, { top: -100, right: 75, bottom: 0, left: 25, width: 50, height: 100 } );

			scrollAncestorsToShowTarget( target );
			assertScrollPosition( firstAncestor, { scrollTop: 0, scrollLeft: 100 } );
		} );
	} );

	function test() {
		it( 'should not touch the #scrollTop #scrollLeft of the ancestor if target is visible', () => {
			stubRect( target, { top: 25, right: 75, bottom: 75, left: 25, width: 50, height: 50 } );

			scrollAncestorsToShowTarget( target );
			assertScrollPosition( firstAncestor, { scrollLeft: 100, scrollTop: 100 } );
		} );

		it( 'should not touch the #scrollTop #scrollLeft of the document.body', () => {
			stubRect( target, { top: 25, right: 75, bottom: 75, left: 25, width: 50, height: 50 } );

			scrollAncestorsToShowTarget( target );
			assertScrollPosition( document.body, { scrollLeft: 1000, scrollTop: 1000 } );
		} );

		it( 'should set #scrollTop and #scrollLeft of the ancestor to show the target (above)', () => {
			stubRect( target, { top: -100, right: 75, bottom: 0, left: 25, width: 50, height: 100 } );

			scrollAncestorsToShowTarget( target );
			assertScrollPosition( firstAncestor, { scrollTop: 0, scrollLeft: 100 } );
		} );

		it( 'should set #scrollTop and #scrollLeft of the ancestor to show the target (below)', () => {
			stubRect( target, { top: 200, right: 75, bottom: 300, left: 25, width: 50, height: 100 } );

			scrollAncestorsToShowTarget( target );
			assertScrollPosition( firstAncestor, { scrollTop: 300, scrollLeft: 100 } );
		} );

		it( 'should set #scrollTop and #scrollLeft of the ancestor to show the target (left of)', () => {
			stubRect( target, { top: 0, right: 0, bottom: 100, left: -100, width: 100, height: 100 } );

			scrollAncestorsToShowTarget( target );
			assertScrollPosition( firstAncestor, { scrollTop: 100, scrollLeft: 0 } );
		} );

		it( 'should set #scrollTop and #scrollLeft of the ancestor to show the target (right of)', () => {
			stubRect( target, { top: 0, right: 200, bottom: 100, left: 100, width: 100, height: 100 } );

			scrollAncestorsToShowTarget( target );
			assertScrollPosition( firstAncestor, { scrollTop: 100, scrollLeft: 200 } );
		} );

		it( 'should set #scrollTop and #scrollLeft of all the ancestors', () => {
			stubRect( target, { top: 0, right: 200, bottom: 100, left: 100, width: 100, height: 100 } );

			scrollAncestorsToShowTarget( target );
			assertScrollPosition( firstAncestor, { scrollTop: 100, scrollLeft: 200 } );
			// Note: Because everything is a mock, scrolling the firstAncestor doesn't really change
			// the getBoundingClientRect geometry of the target. That's why scrolling secondAncestor
			// works like the target remained in the original position and hence scrollLeft is 300 instead
			// of 200.
			assertScrollPosition( secondAncestor, { scrollTop: 200, scrollLeft: 300 } );
		} );
	}
} );
Beispiel #16
0
describe( 'ToolbarView', () => {
	let locale, view;

	testUtils.createSinonSandbox();

	beforeEach( () => {
		locale = {};
		view = new ToolbarView( locale );
		view.render();
	} );

	afterEach( () => {
		view.destroy();
	} );

	describe( 'constructor()', () => {
		it( 'should set view#locale', () => {
			expect( view.locale ).to.equal( locale );
		} );

		it( 'should set view#isVertical', () => {
			expect( view.isVertical ).to.be.false;
		} );

		it( 'should create view#children collection', () => {
			expect( view.items ).to.be.instanceOf( ViewCollection );
		} );

		it( 'creates #focusTracker instance', () => {
			expect( view.focusTracker ).to.be.instanceOf( FocusTracker );
		} );

		it( 'creates #keystrokes instance', () => {
			expect( view.keystrokes ).to.be.instanceOf( KeystrokeHandler );
		} );

		it( 'creates #_focusCycler instance', () => {
			expect( view._focusCycler ).to.be.instanceOf( FocusCycler );
		} );
	} );

	describe( 'template', () => {
		it( 'should create element from template', () => {
			expect( view.element.classList.contains( 'ck' ) ).to.true;
			expect( view.element.classList.contains( 'ck-toolbar' ) ).to.true;
		} );

		describe( 'event listeners', () => {
			it( 'prevent default on #mousedown', () => {
				const evt = new Event( 'mousedown', { bubbles: true } );
				const spy = sinon.spy( evt, 'preventDefault' );

				view.element.dispatchEvent( evt );
				sinon.assert.calledOnce( spy );
			} );
		} );
	} );

	describe( 'element bindings', () => {
		describe( 'class', () => {
			it( 'reacts on view#isVertical', () => {
				view.isVertical = false;
				expect( view.element.classList.contains( 'ck-toolbar_vertical' ) ).to.be.false;

				view.isVertical = true;
				expect( view.element.classList.contains( 'ck-toolbar_vertical' ) ).to.be.true;
			} );

			it( 'reacts on view#class', () => {
				view.class = 'foo';
				expect( view.element.classList.contains( 'foo' ) ).to.be.true;

				view.class = 'bar';
				expect( view.element.classList.contains( 'bar' ) ).to.be.true;

				view.class = false;
				expect( view.element.classList.contains( 'foo' ) ).to.be.false;
				expect( view.element.classList.contains( 'bar' ) ).to.be.false;
			} );
		} );
	} );

	describe( 'render()', () => {
		it( 'registers #items in #focusTracker', () => {
			const view = new ToolbarView( locale );
			const spyAdd = sinon.spy( view.focusTracker, 'add' );
			const spyRemove = sinon.spy( view.focusTracker, 'remove' );

			view.items.add( focusable() );
			view.items.add( focusable() );
			sinon.assert.notCalled( spyAdd );

			view.render();
			sinon.assert.calledTwice( spyAdd );

			view.items.remove( 1 );
			sinon.assert.calledOnce( spyRemove );

			view.destroy();
		} );

		it( 'starts listening for #keystrokes coming from #element', () => {
			const view = new ToolbarView();
			const spy = sinon.spy( view.keystrokes, 'listenTo' );

			view.render();
			sinon.assert.calledOnce( spy );
			sinon.assert.calledWithExactly( spy, view.element );

			view.destroy();
		} );

		describe( 'activates keyboard navigation for the toolbar', () => {
			it( 'so "arrowup" focuses previous focusable item', () => {
				const keyEvtData = {
					keyCode: keyCodes.arrowup,
					preventDefault: sinon.spy(),
					stopPropagation: sinon.spy()
				};

				// No children to focus.
				view.keystrokes.press( keyEvtData );
				sinon.assert.calledOnce( keyEvtData.preventDefault );
				sinon.assert.calledOnce( keyEvtData.stopPropagation );

				view.items.add( nonFocusable() );
				view.items.add( nonFocusable() );

				// No focusable children.
				view.keystrokes.press( keyEvtData );
				sinon.assert.calledTwice( keyEvtData.preventDefault );
				sinon.assert.calledTwice( keyEvtData.stopPropagation );

				view.items.add( focusable() );
				view.items.add( nonFocusable() );
				view.items.add( focusable() );

				// Mock the last item is focused.
				view.focusTracker.isFocused = true;
				view.focusTracker.focusedElement = view.items.get( 4 ).element;

				const spy = sinon.spy( view.items.get( 2 ), 'focus' );
				view.keystrokes.press( keyEvtData );

				sinon.assert.calledThrice( keyEvtData.preventDefault );
				sinon.assert.calledThrice( keyEvtData.stopPropagation );
				sinon.assert.calledOnce( spy );
			} );

			it( 'so "arrowleft" focuses previous focusable item', () => {
				const keyEvtData = {
					keyCode: keyCodes.arrowleft,
					preventDefault: sinon.spy(),
					stopPropagation: sinon.spy()
				};

				view.items.add( focusable() );
				view.items.add( nonFocusable() );
				view.items.add( focusable() );

				// Mock the last item is focused.
				view.focusTracker.isFocused = true;
				view.focusTracker.focusedElement = view.items.get( 2 ).element;

				const spy = sinon.spy( view.items.get( 0 ), 'focus' );

				view.keystrokes.press( keyEvtData );
				sinon.assert.calledOnce( spy );
			} );

			it( 'so "arrowdown" focuses next focusable item', () => {
				const keyEvtData = {
					keyCode: keyCodes.arrowdown,
					preventDefault: sinon.spy(),
					stopPropagation: sinon.spy()
				};

				// No children to focus.
				view.keystrokes.press( keyEvtData );
				sinon.assert.calledOnce( keyEvtData.preventDefault );
				sinon.assert.calledOnce( keyEvtData.stopPropagation );

				view.items.add( nonFocusable() );
				view.items.add( nonFocusable() );

				// No focusable children.
				view.keystrokes.press( keyEvtData );
				sinon.assert.calledTwice( keyEvtData.preventDefault );
				sinon.assert.calledTwice( keyEvtData.stopPropagation );

				view.items.add( focusable() );
				view.items.add( nonFocusable() );
				view.items.add( focusable() );

				// Mock the last item is focused.
				view.focusTracker.isFocused = true;
				view.focusTracker.focusedElement = view.items.get( 4 ).element;

				const spy = sinon.spy( view.items.get( 2 ), 'focus' );
				view.keystrokes.press( keyEvtData );

				sinon.assert.calledThrice( keyEvtData.preventDefault );
				sinon.assert.calledThrice( keyEvtData.stopPropagation );
				sinon.assert.calledOnce( spy );
			} );

			it( 'so "arrowright" focuses next focusable item', () => {
				const keyEvtData = {
					keyCode: keyCodes.arrowright,
					preventDefault: sinon.spy(),
					stopPropagation: sinon.spy()
				};

				view.items.add( focusable() );
				view.items.add( nonFocusable() );
				view.items.add( focusable() );

				// Mock the last item is focused.
				view.focusTracker.isFocused = true;
				view.focusTracker.focusedElement = view.items.get( 0 ).element;

				const spy = sinon.spy( view.items.get( 2 ), 'focus' );

				view.keystrokes.press( keyEvtData );
				sinon.assert.calledOnce( spy );
			} );
		} );
	} );

	describe( 'focus()', () => {
		it( 'focuses the first focusable item in DOM', () => {
			// No children to focus.
			view.focus();

			// The second child is focusable.
			view.items.add( nonFocusable() );
			view.items.add( focusable() );
			view.items.add( nonFocusable() );

			const spy = sinon.spy( view.items.get( 1 ), 'focus' );
			view.focus();

			sinon.assert.calledOnce( spy );
		} );
	} );

	describe( 'focusLast()', () => {
		it( 'focuses the last focusable item in DOM', () => {
			// No children to focus.
			view.focusLast();

			// The second child is focusable.
			view.items.add( nonFocusable() );
			view.items.add( focusable() );
			view.items.add( focusable() );
			view.items.add( focusable() );
			view.items.add( nonFocusable() );

			const spy = sinon.spy( view.items.get( 3 ), 'focus' );
			view.focusLast();

			sinon.assert.calledOnce( spy );
		} );
	} );

	describe( 'fillFromConfig()', () => {
		let factory;

		beforeEach( () => {
			factory = new ComponentFactory( {} );

			factory.add( 'foo', namedFactory( 'foo' ) );
			factory.add( 'bar', namedFactory( 'bar' ) );
		} );

		it( 'expands the config into collection', () => {
			view.fillFromConfig( [ 'foo', 'bar', '|', 'foo' ], factory );

			const items = view.items;

			expect( items ).to.have.length( 4 );
			expect( items.get( 0 ).name ).to.equal( 'foo' );
			expect( items.get( 1 ).name ).to.equal( 'bar' );
			expect( items.get( 2 ) ).to.be.instanceOf( ToolbarSeparatorView );
			expect( items.get( 3 ).name ).to.equal( 'foo' );
		} );

		it( 'warns if there is no such component in the factory', () => {
			const items = view.items;
			testUtils.sinon.stub( log, 'warn' );

			view.fillFromConfig( [ 'foo', 'bar', 'baz' ], factory );

			expect( items ).to.have.length( 2 );
			expect( items.get( 0 ).name ).to.equal( 'foo' );
			expect( items.get( 1 ).name ).to.equal( 'bar' );

			sinon.assert.calledOnce( log.warn );
			sinon.assert.calledWithExactly( log.warn,
				sinon.match( /^toolbarview-item-unavailable/ ),
				{ name: 'baz' }
			);
		} );
	} );
} );
Beispiel #17
0
describe( 'scrollViewportToShowTarget()', () => {
	let target, firstAncestor, element;
	const viewportOffset = 30;

	testUtils.createSinonSandbox();

	beforeEach( () => {
		element = document.createElement( 'p' );
		firstAncestor = document.createElement( 'blockquote' );

		document.body.appendChild( firstAncestor );
		firstAncestor.appendChild( element );

		stubRect( firstAncestor, {
			top: 0, right: 100, bottom: 100, left: 0, width: 100, height: 100
		}, {
			scrollLeft: 100, scrollTop: 100
		} );

		testUtils.sinon.stub( window, 'innerWidth' ).value( 1000 );
		testUtils.sinon.stub( window, 'innerHeight' ).value( 500 );
		testUtils.sinon.stub( window, 'scrollX' ).value( 100 );
		testUtils.sinon.stub( window, 'scrollY' ).value( 100 );
		testUtils.sinon.stub( window, 'scrollTo' );
		testUtils.sinon.stub( window, 'getComputedStyle' ).returns( {
			borderTopWidth: '0px',
			borderRightWidth: '0px',
			borderBottomWidth: '0px',
			borderLeftWidth: '0px'
		} );

		// Assuming 20px v- and h-scrollbars here.
		testUtils.sinon.stub( window.document, 'documentElement' ).value( {
			clientWidth: 980,
			clientHeight: 480
		} );
	} );

	afterEach( () => {
		firstAncestor.remove();
	} );

	describe( 'for an HTMLElement', () => {
		beforeEach( () => {
			target = element;
		} );

		testNoOffset();

		describe( 'with a viewportOffset', () => {
			testWithOffset();
		} );
	} );

	describe( 'for a DOM Range', () => {
		beforeEach( () => {
			target = document.createRange();
			target.setStart( firstAncestor, 0 );
			target.setEnd( firstAncestor, 0 );
		} );

		testNoOffset();

		describe( 'with a viewportOffset', () => {
			testWithOffset();
		} );
	} );

	describe( 'in an iframe', () => {
		let iframe, iframeWindow, iframeAncestor, target, targetAncestor;

		beforeEach( done => {
			iframe = document.createElement( 'iframe' );
			iframeAncestor = document.createElement( 'div' );

			iframe.addEventListener( 'load', () => {
				iframeWindow = iframe.contentWindow;

				testUtils.sinon.stub( iframeWindow, 'innerWidth' ).value( 1000 );
				testUtils.sinon.stub( iframeWindow, 'innerHeight' ).value( 500 );
				testUtils.sinon.stub( iframeWindow, 'scrollX' ).value( 100 );
				testUtils.sinon.stub( iframeWindow, 'scrollY' ).value( 100 );
				testUtils.sinon.stub( iframeWindow, 'scrollTo' );
				testUtils.sinon.stub( iframeWindow, 'getComputedStyle' ).returns( {
					borderTopWidth: '0px',
					borderRightWidth: '0px',
					borderBottomWidth: '0px',
					borderLeftWidth: '0px'
				} );

				// Assuming 20px v- and h-scrollbars here.
				testUtils.sinon.stub( iframeWindow.document, 'documentElement' ).value( {
					clientWidth: 980,
					clientHeight: 480
				} );

				target = iframeWindow.document.createElement( 'p' );
				targetAncestor = iframeWindow.document.createElement( 'div' );
				iframeWindow.document.body.appendChild( targetAncestor );
				targetAncestor.appendChild( target );

				done();
			} );

			iframeAncestor.appendChild( iframe );
			document.body.appendChild( iframeAncestor );
		} );

		afterEach( () => {
			// Safari fails because of "afterEach()" hook tries to restore values from removed element.
			// We need to restore these values manually.
			testUtils.sinon.restore();
			iframeAncestor.remove();
		} );

		it( 'does not scroll the viewport when the target is fully visible', () => {
			stubRect( target,
				{ top: 100, right: 200, bottom: 200, left: 100, width: 100, height: 100 } );
			stubRect( targetAncestor,
				{ top: 100, right: 300, bottom: 400, left: 0, width: 300, height: 300 },
				{ scrollLeft: 200, scrollTop: -100 } );
			stubRect( iframe,
				{ top: 200, right: 400, bottom: 400, left: 200, width: 200, height: 200 } );
			stubRect( iframeAncestor,
				{ top: 0, right: 400, bottom: 400, left: 0, width: 400, height: 400 },
				{ scrollLeft: 100, scrollTop: 100 } );

			scrollViewportToShowTarget( { target } );
			assertScrollPosition( targetAncestor, { scrollLeft: 200, scrollTop: -100 } );
			assertScrollPosition( iframeAncestor, { scrollTop: 100, scrollLeft: 100 } );
			sinon.assert.notCalled( iframeWindow.scrollTo );
			sinon.assert.notCalled( window.scrollTo );
		} );

		it( 'scrolls the viewport to show the target (above)', () => {
			stubRect( target,
				{ top: -200, right: 200, bottom: -100, left: 100, width: 100, height: 100 } );
			stubRect( targetAncestor,
				{ top: 200, right: 300, bottom: 400, left: 0, width: 300, height: 100 },
				{ scrollLeft: 200, scrollTop: -100 } );
			stubRect( iframe,
				{ top: 2000, right: 2000, bottom: 2500, left: 2500, width: 500, height: 500 } );
			stubRect( iframeAncestor,
				{ top: 0, right: 100, bottom: 100, left: 0, width: 100, height: 100 },
				{ scrollLeft: 100, scrollTop: 100 } );

			scrollViewportToShowTarget( { target } );
			assertScrollPosition( targetAncestor, { scrollTop: -500, scrollLeft: 200 } );
			assertScrollPosition( iframeAncestor, { scrollTop: 1900, scrollLeft: 2700 } );
			sinon.assert.calledWithExactly( iframeWindow.scrollTo, 100, -100 );
			sinon.assert.calledWithExactly( window.scrollTo, 1820, 1520 );
		} );

		// https://github.com/ckeditor/ckeditor5/issues/930
		it( 'should not throw if the child frame has no access to the #frameElement of the parent', () => {
			sinon.stub( iframeWindow, 'frameElement' ).get( () => null );

			expect( () => {
				scrollViewportToShowTarget( { target } );
			} ).to.not.throw();
		} );
	} );

	// Note: Because everything is a mock, scrolling the firstAncestor doesn't really change
	// the getBoundingClientRect geometry of the target. That's why scrolling the viewport
	// works like the target remained in the original position. It's tricky but much faster
	// and still shows that the whole thing works as expected.
	//
	// Note: Negative scrollTo arguments make no sense in reality, but in mocks with arbitrary
	// initial geometry and scroll position they give the right, relative picture of what's going on.
	function testNoOffset() {
		it( 'does not scroll the viewport when the target is fully visible', () => {
			stubRect( target, { top: 0, right: 200, bottom: 100, left: 100, width: 100, height: 100 } );

			scrollViewportToShowTarget( { target } );
			assertScrollPosition( firstAncestor, { scrollTop: 100, scrollLeft: 200 } );
			sinon.assert.notCalled( window.scrollTo );
		} );

		it( 'scrolls the viewport to show the target (above)', () => {
			stubRect( target, { top: -200, right: 200, bottom: -100, left: 100, width: 100, height: 100 } );

			scrollViewportToShowTarget( { target } );
			assertScrollPosition( firstAncestor, { scrollTop: -100, scrollLeft: 200 } );
			sinon.assert.calledWithExactly( window.scrollTo, 100, -100 );
		} );

		it( 'scrolls the viewport to show the target (partially above)', () => {
			stubRect( target, { top: -50, right: 200, bottom: 50, left: 100, width: 100, height: 100 } );

			scrollViewportToShowTarget( { target } );
			assertScrollPosition( firstAncestor, { scrollTop: 50, scrollLeft: 200 } );
			sinon.assert.calledWithExactly( window.scrollTo, 100, 50 );
		} );

		it( 'scrolls the viewport to show the target (below)', () => {
			stubRect( target, { top: 600, right: 200, bottom: 700, left: 100, width: 100, height: 100 } );

			scrollViewportToShowTarget( { target } );
			assertScrollPosition( firstAncestor, { scrollTop: 700, scrollLeft: 200 } );
			sinon.assert.calledWithExactly( window.scrollTo, 100, 320 );
		} );

		it( 'scrolls the viewport to show the target (partially below)', () => {
			stubRect( target, { top: 450, right: 200, bottom: 550, left: 100, width: 100, height: 100 } );

			scrollViewportToShowTarget( { target } );
			assertScrollPosition( firstAncestor, { scrollTop: 550, scrollLeft: 200 } );
			sinon.assert.calledWithExactly( window.scrollTo, 100, 170 );
		} );

		it( 'scrolls the viewport to show the target (to the left)', () => {
			stubRect( target, { top: 0, right: -100, bottom: 100, left: -200, width: 100, height: 100 } );

			scrollViewportToShowTarget( { target } );
			assertScrollPosition( firstAncestor, { scrollTop: 100, scrollLeft: -100 } );
			sinon.assert.calledWithExactly( window.scrollTo, -100, 100 );
		} );

		it( 'scrolls the viewport to show the target (partially to the left)', () => {
			stubRect( target, { top: 0, right: 50, bottom: 100, left: -50, width: 100, height: 100 } );

			scrollViewportToShowTarget( { target } );
			assertScrollPosition( firstAncestor, { scrollTop: 100, scrollLeft: 50 } );
			sinon.assert.calledWithExactly( window.scrollTo, 50, 100 );
		} );

		it( 'scrolls the viewport to show the target (to the right)', () => {
			stubRect( target, { top: 0, right: 1200, bottom: 100, left: 1100, width: 100, height: 100 } );

			scrollViewportToShowTarget( { target } );
			assertScrollPosition( firstAncestor, { scrollTop: 100, scrollLeft: 1200 } );
			sinon.assert.calledWithExactly( window.scrollTo, 320, 100 );
		} );

		it( 'scrolls the viewport to show the target (partially to the right)', () => {
			stubRect( target, { top: 0, right: 1050, bottom: 100, left: 950, width: 100, height: 100 } );

			scrollViewportToShowTarget( { target } );
			assertScrollPosition( firstAncestor, { scrollTop: 100, scrollLeft: 1050 } );
			sinon.assert.calledWithExactly( window.scrollTo, 170, 100 );
		} );
	}

	// Note: Because everything is a mock, scrolling the firstAncestor doesn't really change
	// the getBoundingClientRect geometry of the target. That's why scrolling the viewport
	// works like the target remained in the original position. It's tricky but much faster
	// and still shows that the whole thing works as expected.
	//
	// Note: Negative scrollTo arguments make no sense in reality, but in mocks with arbitrary
	// initial geometry and scroll position they give the right, relative picture of what's going on.
	function testWithOffset() {
		it( 'does not scroll the viewport when the target is fully visible', () => {
			stubRect( target, { top: 50, right: 200, bottom: 150, left: 100, width: 100, height: 100 } );

			scrollViewportToShowTarget( { target, viewportOffset } );
			assertScrollPosition( firstAncestor, { scrollTop: 150, scrollLeft: 200 } );
			sinon.assert.notCalled( window.scrollTo );
		} );

		it( 'scrolls the viewport to show the target (above)', () => {
			stubRect( target, { top: -200, right: 200, bottom: -100, left: 100, width: 100, height: 100 } );

			scrollViewportToShowTarget( { target, viewportOffset } );
			assertScrollPosition( firstAncestor, { scrollTop: -100, scrollLeft: 200 } );
			sinon.assert.calledWithExactly( window.scrollTo, 100, -130 );
		} );

		it( 'scrolls the viewport to show the target (partially above)', () => {
			stubRect( target, { top: -50, right: 200, bottom: 50, left: 100, width: 100, height: 100 } );

			scrollViewportToShowTarget( { target, viewportOffset } );
			assertScrollPosition( firstAncestor, { scrollTop: 50, scrollLeft: 200 } );
			sinon.assert.calledWithExactly( window.scrollTo, 100, 20 );
		} );

		it( 'scrolls the viewport to show the target (below)', () => {
			stubRect( target, { top: 600, right: 200, bottom: 700, left: 100, width: 100, height: 100 } );

			scrollViewportToShowTarget( { target, viewportOffset } );
			assertScrollPosition( firstAncestor, { scrollTop: 700, scrollLeft: 200 } );
			sinon.assert.calledWithExactly( window.scrollTo, 100, 350 );
		} );

		it( 'scrolls the viewport to show the target (partially below)', () => {
			stubRect( target, { top: 450, right: 200, bottom: 550, left: 100, width: 100, height: 100 } );

			scrollViewportToShowTarget( { target, viewportOffset } );
			assertScrollPosition( firstAncestor, { scrollTop: 550, scrollLeft: 200 } );
			sinon.assert.calledWithExactly( window.scrollTo, 100, 200 );
		} );

		it( 'scrolls the viewport to show the target (to the left)', () => {
			stubRect( target, { top: 0, right: -100, bottom: 100, left: -200, width: 100, height: 100 } );

			scrollViewportToShowTarget( { target, viewportOffset } );
			assertScrollPosition( firstAncestor, { scrollTop: 100, scrollLeft: -100 } );
			sinon.assert.calledWithExactly( window.scrollTo, -130, 70 );
		} );

		it( 'scrolls the viewport to show the target (partially to the left)', () => {
			stubRect( target, { top: 0, right: 50, bottom: 100, left: -50, width: 100, height: 100 } );

			scrollViewportToShowTarget( { target, viewportOffset } );
			assertScrollPosition( firstAncestor, { scrollTop: 100, scrollLeft: 50 } );
			sinon.assert.calledWithExactly( window.scrollTo, 20, 70 );
		} );

		it( 'scrolls the viewport to show the target (to the right)', () => {
			stubRect( target, { top: 0, right: 1200, bottom: 100, left: 1100, width: 100, height: 100 } );

			scrollViewportToShowTarget( { target, viewportOffset } );
			assertScrollPosition( firstAncestor, { scrollTop: 100, scrollLeft: 1200 } );
			sinon.assert.calledWithExactly( window.scrollTo, 350, 70 );
		} );

		it( 'scrolls the viewport to show the target (partially to the right)', () => {
			stubRect( target, { top: 0, right: 1050, bottom: 100, left: 950, width: 100, height: 100 } );

			scrollViewportToShowTarget( { target, viewportOffset } );
			assertScrollPosition( firstAncestor, { scrollTop: 100, scrollLeft: 1050 } );
			sinon.assert.calledWithExactly( window.scrollTo, 200, 70 );
		} );
	}
} );
Beispiel #18
0
describe( 'getOptimalPosition()', () => {
	testUtils.createSinonSandbox();

	beforeEach( () => {
		testUtils.sinon.stub( window, 'getComputedStyle' );

		stubWindow( {
			innerWidth: 10000,
			innerHeight: 10000,
			scrollX: 0,
			scrollY: 0
		} );
	} );

	it( 'should work when the target is a Function', () => {
		setElementTargetPlayground();

		assertPosition( {
			element,
			target: () => target,
			positions: [ attachLeft ]
		}, {
			top: 100,
			left: 80,
			name: 'left'
		} );
	} );

	it( 'should work when the target is a Rect', () => {
		setElementTargetPlayground();

		assertPosition( {
			element,
			target: new Rect( target ),
			positions: [ attachLeft ]
		}, {
			top: 100,
			left: 80,
			name: 'left'
		} );
	} );

	describe( 'for single position', () => {
		beforeEach( setElementTargetPlayground );

		it( 'should return coordinates', () => {
			assertPosition( { element, target, positions: [ attachLeft ] }, {
				top: 100,
				left: 80,
				name: 'left'
			} );
		} );

		it( 'should return coordinates (window scroll)', () => {
			stubWindow( {
				innerWidth: 10000,
				innerHeight: 10000,
				scrollX: 100,
				scrollY: 100,
			} );

			assertPosition( { element, target, positions: [ attachLeft ] }, {
				top: 200,
				left: 180,
				name: 'left'
			} );
		} );

		describe( 'positioned element parent', () => {
			let parent;

			it( 'should return coordinates', () => {
				stubWindow( {
					innerWidth: 10000,
					innerHeight: 10000,
					scrollX: 1000,
					scrollY: 1000
				} );

				parent = getElement( {
					top: 1000,
					right: 1010,
					bottom: 1010,
					left: 1000,
					width: 10,
					height: 10
				}, {
					position: 'absolute'
				} );

				element.parentElement = parent;

				assertPosition( { element, target, positions: [ attachLeft ] }, {
					top: -900,
					left: -920,
					name: 'left'
				} );
			} );

			it( 'should return coordinates (scroll and border)', () => {
				stubWindow( {
					innerWidth: 10000,
					innerHeight: 10000,
					scrollX: 1000,
					scrollY: 1000
				} );

				parent = getElement( {
					top: 0,
					right: 10,
					bottom: 10,
					left: 0,
					width: 10,
					height: 10,
					scrollTop: 100,
					scrollLeft: 200
				}, {
					position: 'absolute',
					borderLeftWidth: '20px',
					borderTopWidth: '40px',
				} );

				element.parentElement = parent;

				assertPosition( { element, target, positions: [ attachLeft ] }, {
					top: 160,
					left: 260,
					name: 'left'
				} );
			} );
		} );
	} );

	describe( 'for multiple positions', () => {
		beforeEach( setElementTargetPlayground );

		it( 'should return coordinates', () => {
			assertPosition( {
				element, target,
				positions: [ attachLeft, attachRight ]
			}, {
				top: 100,
				left: 80,
				name: 'left'
			} );
		} );

		it( 'should return coordinates (position preference order)', () => {
			assertPosition( {
				element, target,
				positions: [ attachRight, attachLeft ]
			}, {
				top: 100,
				left: 110,
				name: 'right'
			} );
		} );
	} );

	describe( 'with a limiter', () => {
		beforeEach( setElementTargetLimiterPlayground );

		it( 'should work when the limiter is a Function', () => {
			assertPosition( {
				element, target,
				limiter: () => limiter,
				positions: [ attachLeft, attachRight ]
			}, {
				top: 100,
				left: -20,
				name: 'left'
			} );
		} );

		it( 'should work when the limiter is a Rect', () => {
			assertPosition( {
				element, target,
				limiter: new Rect( limiter ),
				positions: [ attachLeft, attachRight ]
			}, {
				top: 100,
				left: -20,
				name: 'left'
			} );
		} );

		it( 'should return coordinates (#1)', () => {
			assertPosition( {
				element, target, limiter,
				positions: [ attachLeft, attachRight ]
			}, {
				top: 100,
				left: -20,
				name: 'left'
			} );
		} );

		it( 'should return coordinates (#2)', () => {
			assertPosition( {
				element, target, limiter,
				positions: [ attachRight, attachLeft ]
			}, {
				top: 100,
				left: -20,
				name: 'left'
			} );
		} );

		// https://github.com/ckeditor/ckeditor5-utils/issues/148
		it( 'should return coordinates (#3)', () => {
			limiter.parentNode = getElement( {
				top: 100,
				left: 0,
				bottom: 110,
				right: 10,
				width: 10,
				height: 10
			} );

			assertPosition( {
				element, target, limiter,
				positions: [ attachRight, attachLeft ]
			}, {
				top: 100,
				left: 10,
				name: 'right'
			} );
		} );
	} );

	describe( 'with fitInViewport on', () => {
		beforeEach( setElementTargetLimiterPlayground );

		it( 'should return coordinates (#1)', () => {
			assertPosition( {
				element, target,
				positions: [ attachLeft, attachRight ],
				fitInViewport: true
			}, {
				top: 100,
				left: 10,
				name: 'right'
			} );
		} );

		it( 'should return coordinates (#2)', () => {
			assertPosition( {
				element, target,
				positions: [ attachRight, attachLeft ],
				fitInViewport: true
			}, {
				top: 100,
				left: 10,
				name: 'right'
			} );
		} );

		it( 'should return coordinates (#3)', () => {
			assertPosition( {
				element, target,
				positions: [ attachLeft, attachBottom, attachRight ],
				fitInViewport: true
			}, {
				top: 110,
				left: 0,
				name: 'bottom'
			} );
		} );
	} );

	describe( 'with limiter and fitInViewport on', () => {
		beforeEach( setElementTargetLimiterPlayground );

		it( 'should return coordinates (#1)', () => {
			assertPosition( {
				element, target, limiter,
				positions: [ attachLeft, attachRight ],
				fitInViewport: true
			}, {
				top: 100,
				left: 10,
				name: 'right'
			} );
		} );

		it( 'should return coordinates (#2)', () => {
			assertPosition( {
				element, target, limiter,
				positions: [ attachRight, attachLeft ],
				fitInViewport: true
			}, {
				top: 100,
				left: 10,
				name: 'right'
			} );
		} );

		it( 'should return coordinates (#3)', () => {
			assertPosition( {
				element, target, limiter,
				positions: [ attachRight, attachLeft, attachBottom ],
				fitInViewport: true
			}, {
				top: 110,
				left: 0,
				name: 'bottom'
			} );
		} );

		it( 'should return coordinates (#4)', () => {
			assertPosition( {
				element, target, limiter,
				positions: [ attachTop, attachRight ],
				fitInViewport: true
			}, {
				top: 100,
				left: 10,
				name: 'right'
			} );
		} );

		it( 'should return the very first coordinates if no fitting position with a positive intersection has been found', () => {
			assertPosition( {
				element, target, limiter,
				positions: [
					() => ( {
						left: -10000,
						top: -10000,
						name: 'no-intersect-position'
					} )
				],
				fitInViewport: true
			}, {
				left: -10000,
				top: -10000,
				name: 'no-intersect-position'
			} );
		} );

		it( 'should return the very first coordinates if limiter does not fit into the viewport', () => {
			limiter = getElement( {
				top: -100,
				right: -80,
				bottom: -80,
				left: -100,
				width: 20,
				height: 20
			} );

			assertPosition( {
				element, target, limiter,
				positions: [ attachRight, attachTop ],
				fitInViewport: true
			}, {
				top: 100,
				left: 10,
				name: 'right'
			} );
		} );
	} );
} );
describe( 'Observable', () => {
	testUtils.createSinonSandbox();

	class Observable {
		constructor( properties ) {
			if ( properties ) {
				this.set( properties );
			}
		}
	}
	mix( Observable, ObservableMixin );

	let Car, car;

	beforeEach( () => {
		Car = class extends Observable {};

		car = new Car( {
			color: 'red',
			year: 2015
		} );
	} );

	it( 'should set properties on creation', () => {
		expect( car ).to.have.property( 'color', 'red' );
		expect( car ).to.have.property( 'year', 2015 );
	} );

	it( 'should get correctly after set', () => {
		car.color = 'blue';

		expect( car.color ).to.equal( 'blue' );
	} );

	describe( 'set()', () => {
		it( 'should work when passing an object', () => {
			car.set( {
				color: 'blue',	// Override
				wheels: 4,
				seats: 5
			} );

			expect( car ).to.deep.equal( {
				color: 'blue',
				year: 2015,
				wheels: 4,
				seats: 5
			} );
		} );

		it( 'should work when passing a key/value pair', () => {
			car.set( 'color', 'blue' );
			car.set( 'wheels', 4 );

			expect( car ).to.deep.equal( {
				color: 'blue',
				year: 2015,
				wheels: 4
			} );
		} );

		it( 'should fire the "change" event', () => {
			const spy = sinon.spy();
			const spyColor = sinon.spy();
			const spyYear = sinon.spy();
			const spyWheels = sinon.spy();

			car.on( 'change', spy );
			car.on( 'change:color', spyColor );
			car.on( 'change:year', spyYear );
			car.on( 'change:wheels', spyWheels );

			// Set property in all possible ways.
			car.color = 'blue';
			car.set( { year: 2003 } );
			car.set( 'wheels', 4 );

			// Check number of calls.
			sinon.assert.calledThrice( spy );
			sinon.assert.calledOnce( spyColor );
			sinon.assert.calledOnce( spyYear );
			sinon.assert.calledOnce( spyWheels );

			// Check context.
			sinon.assert.alwaysCalledOn( spy, car );
			sinon.assert.calledOn( spyColor, car );
			sinon.assert.calledOn( spyYear, car );
			sinon.assert.calledOn( spyWheels, car );

			// Check params.
			sinon.assert.calledWithExactly( spy, sinon.match.instanceOf( EventInfo ), 'color', 'blue', 'red' );
			sinon.assert.calledWithExactly( spy, sinon.match.instanceOf( EventInfo ), 'year', 2003, 2015 );
			sinon.assert.calledWithExactly( spy, sinon.match.instanceOf( EventInfo ), 'wheels', 4, sinon.match.typeOf( 'undefined' ) );
			sinon.assert.calledWithExactly( spyColor, sinon.match.instanceOf( EventInfo ), 'color', 'blue', 'red' );
			sinon.assert.calledWithExactly( spyYear, sinon.match.instanceOf( EventInfo ), 'year', 2003, 2015 );
			sinon.assert.calledWithExactly(
				spyWheels, sinon.match.instanceOf( EventInfo ),
				'wheels', 4, sinon.match.typeOf( 'undefined' )
			);
		} );

		it( 'should not fire the "change" event for the same property value', () => {
			const spy = sinon.spy();
			const spyColor = sinon.spy();

			car.on( 'change', spy );
			car.on( 'change:color', spyColor );

			// Set the "color" property in all possible ways.
			car.color = 'red';
			car.set( 'color', 'red' );
			car.set( { color: 'red' } );

			sinon.assert.notCalled( spy );
			sinon.assert.notCalled( spyColor );
		} );

		it( 'should fire the "set" event', () => {
			const spy = sinon.spy();
			const spyColor = sinon.spy();
			const spyYear = sinon.spy();
			const spyWheels = sinon.spy();

			car.on( 'set', spy );
			car.on( 'set:color', spyColor );
			car.on( 'set:year', spyYear );
			car.on( 'set:wheels', spyWheels );

			// Set property in all possible ways.
			car.color = 'blue';
			car.set( { year: 2003 } );
			car.set( 'wheels', 4 );

			// Check number of calls.
			sinon.assert.calledThrice( spy );
			sinon.assert.calledOnce( spyColor );
			sinon.assert.calledOnce( spyYear );
			sinon.assert.calledOnce( spyWheels );

			// Check context.
			sinon.assert.alwaysCalledOn( spy, car );
			sinon.assert.calledOn( spyColor, car );
			sinon.assert.calledOn( spyYear, car );
			sinon.assert.calledOn( spyWheels, car );

			// Check params.
			sinon.assert.calledWithExactly( spy, sinon.match.instanceOf( EventInfo ), 'color', 'blue', 'red' );
			sinon.assert.calledWithExactly( spy, sinon.match.instanceOf( EventInfo ), 'year', 2003, 2015 );
			sinon.assert.calledWithExactly( spy, sinon.match.instanceOf( EventInfo ), 'wheels', 4, sinon.match.typeOf( 'undefined' ) );
			sinon.assert.calledWithExactly( spyColor, sinon.match.instanceOf( EventInfo ), 'color', 'blue', 'red' );
			sinon.assert.calledWithExactly( spyYear, sinon.match.instanceOf( EventInfo ), 'year', 2003, 2015 );
			sinon.assert.calledWithExactly(
				spyWheels, sinon.match.instanceOf( EventInfo ),
				'wheels', 4, sinon.match.typeOf( 'undefined' )
			);
		} );

		it( 'should use "set" return value as an observable new value', () => {
			car.color = 'blue';

			const spy = sinon.spy();

			car.on( 'set:color', evt => {
				evt.stop();
				evt.return = 'red';
			}, { priority: 'high' } );

			car.on( 'change:color', spy );

			car.color = 'pink';

			sinon.assert.calledWithExactly( spy, sinon.match.instanceOf( EventInfo ), 'color', 'red', 'blue' );
		} );

		it( 'should fire the "set" event for the same property value', () => {
			const spy = sinon.spy();
			const spyColor = sinon.spy();

			car.on( 'set', spy );
			car.on( 'set:color', spyColor );

			// Set the "color" property in all possible ways.
			car.color = 'red';
			car.set( 'color', 'red' );
			car.set( { color: 'red' } );

			sinon.assert.calledThrice( spy );
			sinon.assert.calledThrice( spyColor );
		} );

		it( 'should throw when overriding already existing property', () => {
			car.normalProperty = 1;

			expect( () => {
				car.set( 'normalProperty', 2 );
			} ).to.throw( CKEditorError, /^observable-set-cannot-override/ );

			expect( car ).to.have.property( 'normalProperty', 1 );
		} );

		it( 'should throw when overriding already existing property (in the prototype)', () => {
			class Car extends Observable {
				method() {}
			}

			car = new Car();

			expect( () => {
				car.set( 'method', 2 );
			} ).to.throw( CKEditorError, /^observable-set-cannot-override/ );

			expect( car.method ).to.be.a( 'function' );
		} );

		it( 'should allow setting properties with undefined value', () => {
			const spy = sinon.spy();

			car.on( 'change', spy );
			car.set( 'seats', undefined );

			sinon.assert.calledOnce( spy );
			expect( car ).to.contain.keys( 'seats' );
			expect( car.seats ).to.be.undefined;

			car.set( 'seats', 5 );

			sinon.assert.calledTwice( spy );
			expect( car ).to.have.property( 'seats', 5 );
		} );
	} );

	describe( 'bind()', () => {
		it( 'should chain for a single property', () => {
			expect( car.bind( 'color' ) ).to.contain.keys( 'to' );
		} );

		it( 'should chain for multiple properties', () => {
			expect( car.bind( 'color', 'year' ) ).to.contain.keys( 'to' );
		} );

		it( 'should chain for nonexistent properties', () => {
			expect( car.bind( 'nonexistent' ) ).to.contain.keys( 'to' );
		} );

		it( 'should throw when properties are not strings', () => {
			expect( () => {
				car.bind();
			} ).to.throw( CKEditorError, /observable-bind-wrong-properties/ );

			expect( () => {
				car.bind( new Date() );
			} ).to.throw( CKEditorError, /observable-bind-wrong-properties/ );

			expect( () => {
				car.bind( 'color', new Date() );
			} ).to.throw( CKEditorError, /observable-bind-wrong-properties/ );
		} );

		it( 'should throw when the same property is used than once', () => {
			expect( () => {
				car.bind( 'color', 'color' );
			} ).to.throw( CKEditorError, /observable-bind-duplicate-properties/ );
		} );

		it( 'should throw when binding the same property more than once', () => {
			expect( () => {
				car.bind( 'color' );
				car.bind( 'color' );
			} ).to.throw( CKEditorError, /observable-bind-rebind/ );
		} );

		describe( 'to()', () => {
			it( 'should not chain', () => {
				expect(
					car.bind( 'color' ).to( new Observable( { color: 'red' } ) )
				).to.be.undefined;
			} );

			it( 'should throw when arguments are of invalid type - empty', () => {
				expect( () => {
					car = new Car();

					car.bind( 'color' ).to();
				} ).to.throw( CKEditorError, /observable-bind-to-parse-error/ );
			} );

			it( 'should throw when binding multiple properties to multiple observables', () => {
				let vehicle = new Car();
				const car1 = new Car( { color: 'red', year: 1943 } );
				const car2 = new Car( { color: 'yellow', year: 1932 } );

				expect( () => {
					vehicle.bind( 'color', 'year' ).to( car1, 'color', car2, 'year' );
				} ).to.throw( CKEditorError, /observable-bind-to-no-callback/ );

				expect( () => {
					vehicle = new Car();

					vehicle.bind( 'color', 'year' ).to( car1, car2 );
				} ).to.throw( CKEditorError, /observable-bind-to-no-callback/ );

				expect( () => {
					vehicle = new Car();

					vehicle.bind( 'color', 'year' ).to( car1, car2, 'year' );
				} ).to.throw( CKEditorError, /observable-bind-to-no-callback/ );

				expect( () => {
					vehicle = new Car();

					vehicle.bind( 'color', 'year' ).to( car1, 'color', car2 );
				} ).to.throw( CKEditorError, /observable-bind-to-no-callback/ );

				expect( () => {
					vehicle = new Car();

					vehicle.bind( 'color', 'year', 'custom' ).to( car, car );
				} ).to.throw( CKEditorError, /observable-bind-to-no-callback/ );
			} );

			it( 'should throw when binding multiple properties but passed a callback', () => {
				let vehicle = new Car();

				expect( () => {
					vehicle.bind( 'color', 'year' ).to( car, () => {} );
				} ).to.throw( CKEditorError, /observable-bind-to-extra-callback/ );

				expect( () => {
					vehicle = new Car();

					vehicle.bind( 'color', 'year' ).to( car, car, () => {} );
				} ).to.throw( CKEditorError, /observable-bind-to-extra-callback/ );
			} );

			it( 'should throw when binding a single property but multiple callbacks', () => {
				let vehicle = new Car();

				expect( () => {
					vehicle.bind( 'color' ).to( car, () => {}, () => {} );
				} ).to.throw( CKEditorError, /observable-bind-to-parse-error/ );

				expect( () => {
					vehicle = new Car();

					vehicle.bind( 'color' ).to( car, car, () => {}, () => {} );
				} ).to.throw( CKEditorError, /observable-bind-to-parse-error/ );
			} );

			it( 'should throw when a number of properties does not match', () => {
				let vehicle = new Car();

				expect( () => {
					vehicle.bind( 'color' ).to( car, 'color', 'year' );
				} ).to.throw( CKEditorError, /observable-bind-to-properties-length/ );

				expect( () => {
					vehicle = new Car();

					vehicle.bind( 'color', 'year' ).to( car, 'color' );
				} ).to.throw( CKEditorError, /observable-bind-to-properties-length/ );

				expect( () => {
					vehicle = new Car();

					vehicle.bind( 'color' ).to( car, 'color', 'year', () => {} );
				} ).to.throw( CKEditorError, /observable-bind-to-properties-length/ );

				expect( () => {
					vehicle = new Car();

					vehicle.bind( 'color' ).to( car, 'color', car, 'color', 'year', () => {} );
				} ).to.throw( CKEditorError, /observable-bind-to-properties-length/ );
			} );

			it( 'should work when properties don\'t exist in to() observable #1', () => {
				const vehicle = new Car();

				vehicle.bind( 'color' ).to( car, 'nonexistent in car' );

				assertBinding( vehicle,
					{ color: undefined, year: undefined },
					[
						[ car, { 'nonexistent in car': 'foo', year: 1969 } ]
					],
					{ color: 'foo', year: undefined }
				);
			} );

			it( 'should work when properties don\'t exist in to() observable #2', () => {
				const vehicle = new Car();

				vehicle.bind( 'nonexistent in car' ).to( car );

				assertBinding( vehicle,
					{ 'nonexistent in car': undefined, year: undefined },
					[
						[ car, { 'nonexistent in car': 'foo', year: 1969 } ]
					],
					{ 'nonexistent in car': 'foo', year: undefined }
				);
			} );

			it( 'should work when properties don\'t exist in to() observable #3', () => {
				const vehicle = new Car();

				vehicle.bind( 'year' ).to( car, 'color', car, 'nonexistent in car', ( a, b ) => a + b );

				assertBinding( vehicle,
					{ color: undefined, year: car.color + undefined },
					[
						[ car, { color: 'blue', year: 1969 } ]
					],
					{ color: undefined, year: 'blueundefined' }
				);
			} );

			it( 'should set new observable properties', () => {
				const car = new Car( { color: 'green', year: 2001, type: 'pickup' } );
				const vehicle = new Car( { 'not involved': true } );

				vehicle.bind( 'color', 'year', 'type' ).to( car );

				expect( vehicle ).to.have.property( 'color' );
				expect( vehicle ).to.have.property( 'year' );
				expect( vehicle ).to.have.property( 'type' );
				expect( vehicle ).to.have.property( 'not involved' );
			} );

			it( 'should work when no property specified #1', () => {
				const vehicle = new Car();

				vehicle.bind( 'color' ).to( car );

				assertBinding( vehicle,
					{ color: car.color, year: undefined },
					[
						[ car, { color: 'blue', year: 1969 } ]
					],
					{ color: 'blue', year: undefined }
				);
			} );

			it( 'should work for a single property', () => {
				const vehicle = new Car();

				vehicle.bind( 'color' ).to( car, 'color' );

				assertBinding( vehicle,
					{ color: car.color, year: undefined },
					[
						[ car, { color: 'blue', year: 1969 } ]
					],
					{ color: 'blue', year: undefined }
				);
			} );

			it( 'should work for multiple properties', () => {
				const vehicle = new Car();

				vehicle.bind( 'color', 'year' ).to( car, 'color', 'year' );

				assertBinding( vehicle,
					{ color: car.color, year: car.year },
					[
						[ car, { color: 'blue', year: 1969 } ]
					],
					{ color: 'blue', year: 1969 }
				);
			} );

			it( 'should work for properties that don\'t exist in the observable', () => {
				const vehicle = new Car();

				vehicle.bind( 'nonexistent in vehicle' ).to( car, 'color' );

				assertBinding( vehicle,
					{ 'nonexistent in vehicle': car.color, color: undefined },
					[
						[ car, { color: 'blue', year: 1969 } ]
					],
					{ 'nonexistent in vehicle': 'blue', color: undefined }
				);
			} );

			it( 'should work when using the same property name more than once', () => {
				const vehicle = new Car();

				vehicle.bind( 'color', 'year' ).to( car, 'year', 'year' );

				assertBinding( vehicle,
					{ color: car.year, year: car.year },
					[
						[ car, { color: 'blue', year: 1969 } ]
					],
					{ color: 1969, year: 1969 }
				);
			} );

			it( 'should work when binding more that once', () => {
				const vehicle = new Car();

				vehicle.bind( 'color' ).to( car, 'color' );
				vehicle.bind( 'year' ).to( car, 'year' );

				assertBinding( vehicle,
					{ color: car.color, year: car.year },
					[
						[ car, { color: 'blue', year: 1969 } ]
					],
					{ color: 'blue', year: 1969 }
				);
			} );

			it( 'should work with callback – set a new observable property', () => {
				const vehicle = new Car();
				const car1 = new Car( { type: 'pickup' } );
				const car2 = new Car( { type: 'truck' } );

				vehicle.bind( 'type' )
					.to( car1, car2, ( ...args ) => args.join( '' ) );

				expect( vehicle ).to.have.property( 'type' );
			} );

			it( 'should work with callback #1', () => {
				const vehicle = new Car();
				const car1 = new Car( { color: 'black' } );
				const car2 = new Car( { color: 'brown' } );

				vehicle.bind( 'color' )
					.to( car1, car2, ( ...args ) => args.join( '' ) );

				assertBinding( vehicle,
					{ color: car1.color + car2.color, year: undefined },
					[
						[ car1, { color: 'black', year: 1930 } ],
						[ car2, { color: 'green', year: 1950 } ]
					],
					{ color: 'blackgreen', year: undefined }
				);
			} );

			it( 'should work with callback #2', () => {
				const vehicle = new Car();
				const car1 = new Car( { color: 'black' } );
				const car2 = new Car( { color: 'brown' } );

				vehicle.bind( 'color' )
					.to( car1, 'color', car2, 'color', ( ...args ) => args.join( '' ) );

				assertBinding( vehicle,
					{ color: car1.color + car2.color, year: undefined },
					[
						[ car1, { color: 'black', year: 1930 } ],
						[ car2, { color: 'green', year: 1950 } ]
					],
					{ color: 'blackgreen', year: undefined }
				);
			} );

			it( 'should work with callback #3', () => {
				const vehicle = new Car();
				const car1 = new Car( { color: 'black' } );
				const car2 = new Car( { color: 'brown' } );
				const car3 = new Car( { color: 'yellow' } );

				vehicle.bind( 'color' )
					.to( car1, car2, car3, ( ...args ) => args.join( '' ) );

				assertBinding( vehicle,
					{ color: car1.color + car2.color + car3.color, year: undefined },
					[
						[ car1, { color: 'black', year: 1930 } ],
						[ car2, { color: 'green', year: 1950 } ]
					],
					{ color: 'blackgreenyellow', year: undefined }
				);
			} );

			it( 'should work with callback #4', () => {
				const vehicle = new Car();
				const car1 = new Car( { color: 'black' } );
				const car2 = new Car( { lightness: 'bright' } );
				const car3 = new Car( { color: 'yellow' } );

				vehicle.bind( 'color' )
					.to( car1, car2, 'lightness', car3, ( ...args ) => args.join( '' ) );

				assertBinding( vehicle,
					{ color: car1.color + car2.lightness + car3.color, year: undefined },
					[
						[ car1, { color: 'black', year: 1930 } ],
						[ car2, { color: 'green', year: 1950 } ]
					],
					{ color: 'blackbrightyellow', year: undefined }
				);
			} );

			it( 'should work with callback #5', () => {
				const vehicle = new Car();
				const car1 = new Car( { hue: 'reds' } );
				const car2 = new Car( { lightness: 'bright' } );

				vehicle.bind( 'color' )
					.to( car1, 'hue', car2, 'lightness', ( ...args ) => args.join( '' ) );

				assertBinding( vehicle,
					{ color: car1.hue + car2.lightness, year: undefined },
					[
						[ car1, { hue: 'greens', year: 1930 } ],
						[ car2, { lightness: 'dark', year: 1950 } ]
					],
					{ color: 'greensdark', year: undefined }
				);
			} );

			it( 'should work with callback #6', () => {
				const vehicle = new Car();
				const car1 = new Car( { hue: 'reds' } );

				vehicle.bind( 'color' )
					.to( car1, 'hue', h => h.toUpperCase() );

				assertBinding( vehicle,
					{ color: car1.hue.toUpperCase(), year: undefined },
					[
						[ car1, { hue: 'greens', year: 1930 } ]
					],
					{ color: 'GREENS', year: undefined }
				);
			} );

			it( 'should work with callback #7', () => {
				const vehicle = new Car();
				const car1 = new Car( { color: 'red', year: 1943 } );
				const car2 = new Car( { color: 'yellow', year: 1932 } );

				vehicle.bind( 'custom' )
					.to( car1, 'color', car2, 'year', ( ...args ) => args.join( '/' ) );

				assertBinding( vehicle,
					{ color: undefined, year: undefined, 'custom': car1.color + '/' + car2.year },
					[
						[ car1, { color: 'blue', year: 2100 } ],
						[ car2, { color: 'violet', year: 1969 } ]
					],
					{ color: undefined, year: undefined, 'custom': 'blue/1969' }
				);
			} );

			it( 'should work with callback #8', () => {
				const vehicle = new Car();
				const car1 = new Car( { color: 'red', year: 1943 } );
				const car2 = new Car( { color: 'yellow', year: 1932 } );
				const car3 = new Car( { hue: 'reds' } );

				vehicle.bind( 'custom' )
					.to( car1, 'color', car2, 'year', car3, 'hue', ( ...args ) => args.join( '/' ) );

				assertBinding( vehicle,
					{ color: undefined, year: undefined, hue: undefined, 'custom': car1.color + '/' + car2.year + '/' + car3.hue },
					[
						[ car1, { color: 'blue', year: 2100 } ],
						[ car2, { color: 'violet', year: 1969 } ]
					],
					{ color: undefined, year: undefined, hue: undefined, 'custom': 'blue/1969/reds' }
				);
			} );

			it( 'should work with callback – binding more that once #1', () => {
				const vehicle = new Car();
				const car1 = new Car( { hue: 'reds', produced: 1920 } );
				const car2 = new Car( { lightness: 'bright', sold: 1921 } );

				vehicle.bind( 'color' )
					.to( car1, 'hue', car2, 'lightness', ( ...args ) => args.join( '' ) );

				vehicle.bind( 'year' )
					.to( car1, 'produced', car2, 'sold', ( ...args ) => args.join( '/' ) );

				assertBinding( vehicle,
					{ color: car1.hue + car2.lightness, year: car1.produced + '/' + car2.sold },
					[
						[ car1, { hue: 'greens', produced: 1930 } ],
						[ car2, { lightness: 'dark', sold: 2000 } ]
					],
					{ color: 'greensdark', year: '1930/2000' }
				);
			} );

			it( 'should work with callback – binding more that once #2', () => {
				const vehicle = new Car();
				const car1 = new Car( { hue: 'reds', produced: 1920 } );
				const car2 = new Car( { lightness: 'bright', sold: 1921 } );

				vehicle.bind( 'color' )
					.to( car1, 'hue', car2, 'lightness', ( ...args ) => args.join( '' ) );

				vehicle.bind( 'year' )
					.to( car1, 'produced', car2, 'sold', ( ...args ) => args.join( '/' ) );

				vehicle.bind( 'mix' )
					.to( car1, 'hue', car2, 'sold', ( ...args ) => args.join( '+' ) );

				assertBinding( vehicle,
					{
						color: car1.hue + car2.lightness,
						year: car1.produced + '/' + car2.sold,
						mix: car1.hue + '+' + car2.sold
					},
					[
						[ car1, { hue: 'greens', produced: 1930 } ],
						[ car2, { lightness: 'dark', sold: 2000 } ]
					],
					{
						color: 'greensdark',
						year: '1930/2000',
						mix: 'greens+2000'
					}
				);
			} );

			it( 'should work with callback – binding more that once #3', () => {
				const vehicle = new Car();
				const car1 = new Car( { hue: 'reds', produced: 1920 } );
				const car2 = new Car( { lightness: 'bright', sold: 1921 } );

				vehicle.bind( 'color' )
					.to( car1, 'hue', car2, 'lightness', ( ...args ) => args.join( '' ) );

				vehicle.bind( 'custom1' ).to( car1, 'hue' );

				vehicle.bind( 'year' )
					.to( car1, 'produced', car2, 'sold', ( ...args ) => args.join( '/' ) );

				vehicle.bind( 'custom2', 'custom3' ).to( car1, 'produced', 'hue' );

				assertBinding( vehicle,
					{
						color: car1.hue + car2.lightness,
						year: car1.produced + '/' + car2.sold,
						custom1: car1.hue,
						custom2: car1.produced,
						custom3: car1.hue
					},
					[
						[ car1, { hue: 'greens', produced: 1930 } ],
						[ car2, { lightness: 'dark', sold: 2000 } ]
					],
					{
						color: 'greensdark',
						year: '1930/2000',
						custom1: 'greens',
						custom2: 1930,
						custom3: 'greens'
					}
				);
			} );

			it( 'should fire a single change event per bound property', () => {
				const vehicle = new Car();
				const car = new Car( { color: 'red', year: 1943 } );
				const spy = sinon.spy();

				vehicle.on( 'change', spy );

				vehicle.bind( 'color', 'year' ).to( car );

				car.color = 'violet';
				car.custom = 'foo';
				car.year = 2001;

				expect( spy.args.map( args => args[ 1 ] ) )
					.to.have.members( [ 'color', 'year', 'color', 'year' ] );
			} );
		} );

		describe( 'toMany()', () => {
			let Wheel;

			beforeEach( () => {
				Wheel = class extends Observable {
				};
			} );

			it( 'should not chain', () => {
				expect(
					car.bind( 'color' ).toMany( [ new Observable( { color: 'red' } ) ], 'color', () => {} )
				).to.be.undefined;
			} );

			it( 'should throw when binding multiple properties', () => {
				let vehicle = new Car();

				expect( () => {
					vehicle.bind( 'color', 'year' ).toMany( [ car ], 'foo', () => {} );
				} ).to.throw( CKEditorError, /observable-bind-to-many-not-one-binding/ );

				expect( () => {
					vehicle = new Car();

					vehicle.bind( 'color', 'year' ).to( car, car, () => {} );
				} ).to.throw( CKEditorError, /observable-bind-to-extra-callback/ );
			} );

			it( 'binds observable property to collection property using callback', () => {
				const wheels = [
					new Wheel( { isTyrePressureOK: true } ),
					new Wheel( { isTyrePressureOK: true } ),
					new Wheel( { isTyrePressureOK: true } ),
					new Wheel( { isTyrePressureOK: true } )
				];

				car.bind( 'showTyrePressureWarning' ).toMany( wheels, 'isTyrePressureOK', ( ...areEnabled ) => {
					// Every tyre must have OK pressure.
					return !areEnabled.every( isTyrePressureOK => isTyrePressureOK );
				} );

				expect( car.showTyrePressureWarning ).to.be.false;

				wheels[ 0 ].isTyrePressureOK = false;

				expect( car.showTyrePressureWarning ).to.be.true;

				wheels[ 0 ].isTyrePressureOK = true;

				expect( car.showTyrePressureWarning ).to.be.false;

				wheels[ 1 ].isTyrePressureOK = false;

				expect( car.showTyrePressureWarning ).to.be.true;
			} );
		} );
	} );

	describe( 'unbind()', () => {
		it( 'should not fail when unbinding a fresh observable', () => {
			const observable = new Observable();

			observable.unbind();
		} );

		it( 'should not fail when unbinding property that is not bound', () => {
			const observable = new Observable();

			observable.bind( 'foo' ).to( car, 'color' );

			expect( () => observable.unbind( 'bar' ) ).to.not.throw();
		} );

		it( 'should throw when non-string property is passed', () => {
			expect( () => {
				car.unbind( new Date() );
			} ).to.throw( CKEditorError, /observable-unbind-wrong-properties/ );
		} );

		it( 'should remove all bindings', () => {
			const vehicle = new Car();

			vehicle.bind( 'color', 'year' ).to( car, 'color', 'year' );
			vehicle.unbind();

			assertBinding( vehicle,
				{ color: 'red', year: 2015 },
				[
					[ car, { color: 'blue', year: 1969 } ]
				],
				{ color: 'red', year: 2015 }
			);
		} );

		it( 'should remove bindings of certain properties', () => {
			const vehicle = new Car();
			const car = new Car( { color: 'red', year: 2000, torque: 160 } );

			vehicle.bind( 'color', 'year', 'torque' ).to( car );
			vehicle.unbind( 'year', 'torque' );

			assertBinding( vehicle,
				{ color: 'red', year: 2000, torque: 160 },
				[
					[ car, { color: 'blue', year: 1969, torque: 220 } ]
				],
				{ color: 'blue', year: 2000, torque: 160 }
			);
		} );

		it( 'should remove bindings of certain properties, callback', () => {
			const vehicle = new Car();
			const car1 = new Car( { color: 'red' } );
			const car2 = new Car( { color: 'blue' } );

			vehicle.bind( 'color' ).to( car1, car2, ( c1, c2 ) => c1 + c2 );
			vehicle.unbind( 'color' );

			assertBinding( vehicle,
				{ color: 'redblue' },
				[
					[ car1, { color: 'green' } ],
					[ car2, { color: 'violet' } ]
				],
				{ color: 'redblue' }
			);
		} );

		it( 'should be able to unbind two properties from a single source observable property', () => {
			const vehicle = new Car();

			vehicle.bind( 'color' ).to( car, 'color' );
			vehicle.bind( 'interiorColor' ).to( car, 'color' );
			vehicle.unbind( 'color' );
			vehicle.unbind( 'interiorColor' );

			assertBinding( vehicle,
				{ color: 'red', interiorColor: 'red' },
				[
					[ car, { color: 'blue' } ]
				],
				{ color: 'red', interiorColor: 'red' }
			);
		} );
	} );

	describe( 'decorate()', () => {
		it( 'makes the method fire an event', () => {
			const spy = sinon.spy();

			class Foo extends Observable {
				method() {}
			}

			const foo = new Foo();

			foo.decorate( 'method' );

			foo.on( 'method', spy );

			foo.method( 1, 2 );

			expect( spy.calledOnce ).to.be.true;
			expect( spy.args[ 0 ][ 1 ] ).to.deep.equal( [ 1, 2 ] );
		} );

		it( 'executes the original method in a listener with the default priority', () => {
			const calls = [];

			class Foo extends Observable {
				method() {
					calls.push( 'original' );
				}
			}

			const foo = new Foo();

			foo.decorate( 'method' );

			foo.on( 'method', () => calls.push( 'high' ), { priority: 'high' } );
			foo.on( 'method', () => calls.push( 'low' ), { priority: 'low' } );

			foo.method();

			expect( calls ).to.deep.equal( [ 'high', 'original', 'low' ] );
		} );

		it( 'supports overriding return values', () => {
			class Foo extends Observable {
				method() {
					return 1;
				}
			}

			const foo = new Foo();

			foo.decorate( 'method' );

			foo.on( 'method', evt => {
				expect( evt.return ).to.equal( 1 );

				evt.return = 2;
			} );

			expect( foo.method() ).to.equal( 2 );
		} );

		it( 'supports overriding arguments', () => {
			class Foo extends Observable {
				method( a ) {
					expect( a ).to.equal( 2 );
				}
			}

			const foo = new Foo();

			foo.decorate( 'method' );

			foo.on( 'method', ( evt, args ) => {
				args[ 0 ] = 2;
			}, { priority: 'high' } );

			foo.method( 1 );
		} );

		it( 'supports stopping the event (which prevents execution of the orignal method', () => {
			class Foo extends Observable {
				method() {
					throw new Error( 'this should not be executed' );
				}
			}

			const foo = new Foo();

			foo.decorate( 'method' );

			foo.on( 'method', evt => {
				evt.stop();
			}, { priority: 'high' } );

			foo.method();
		} );

		it( 'throws when trying to decorate non existing method', () => {
			class Foo extends Observable {}

			const foo = new Foo();

			expect( () => {
				foo.decorate( 'method' );
			} ).to.throw( CKEditorError, /^observablemixin-cannot-decorate-undefined:/ );
		} );
	} );
} );