%PDF- %PDF-
Mini Shell

Mini Shell

Direktori : /www/varak.net/wiki.varak.net/extensions/VisualEditor/lib/ve/tests/ce/
Upload File :
Create Path :
Current File : /www/varak.net/wiki.varak.net/extensions/VisualEditor/lib/ve/tests/ce/ve.ce.Surface.test.js

/*!
 * VisualEditor ContentEditable Surface tests.
 *
 * @copyright 2011-2016 VisualEditor Team and others; see http://ve.mit-license.org
 */

QUnit.module( 've.ce.Surface' );

/* Tests */

ve.test.utils.runSurfaceHandleSpecialKeyTest = function ( assert, htmlOrDoc, rangeOrSelection, keys, expectedData, expectedRangeOrSelection, msg, forceSelection, fullEvents ) {
	var i, e, selection, expectedSelection, key,
		view = typeof htmlOrDoc === 'string' ?
			ve.test.utils.createSurfaceViewFromHtml( htmlOrDoc ) :
			( htmlOrDoc instanceof ve.ce.Surface ? htmlOrDoc : ve.test.utils.createSurfaceViewFromDocument( htmlOrDoc || ve.dm.example.createExampleDocument() ) ),
		model = view.getModel(),
		data = ve.copy( model.getDocument().getFullData() );

	// TODO: model.getSelection() should be consistent after it has been
	// changed but appears to behave differently depending on the browser.
	// The selection from the select event is still consistent.
	selection = ve.test.utils.selectionFromRangeOrSelection( model.getDocument(), rangeOrSelection );

	model.on( 'select', function ( s ) {
		selection = s;
	} );

	model.setSelection( selection );
	for ( i = 0; i < keys.length; i++ ) {
		key = keys[ i ].split( '+' );
		e = {
			keyCode: OO.ui.Keys[ key.pop() ],
			shiftKey: key.indexOf( 'SHIFT' ) !== -1,
			ctrlKey: key.indexOf( 'CTRL' ) !== -1,
			preventDefault: function () {},
			stopPropagation: function () {}
		};
		if ( fullEvents ) {
			// Some key handlers do things like schedule after-event handlers,
			// and so we want to fake the full sequence.
			// TODO: Could probably switch to using this for every test, but it
			// would need the faked testing surface to be improved.
			view.eventSequencer.onEvent( 'keydown', $.Event( 'keydown', e ) );
			if ( forceSelection ) {
				view.showSelectionState( view.getSelectionState( forceSelection ) );
			}
			view.eventSequencer.runPendingCalls( 'keydown' );
			view.eventSequencer.onEvent( 'keypress', $.Event( 'keypress', e ) );
			view.eventSequencer.runPendingCalls( 'keypress' );
			view.eventSequencer.onEvent( 'keyup', $.Event( 'keyup', e ) );
			view.eventSequencer.runPendingCalls( 'keyup' );
		} else {
			if ( forceSelection ) {
				view.showSelectionState( view.getSelectionState( forceSelection ) );
			}
			ve.ce.keyDownHandlerFactory.executeHandlersForKey(
				e.keyCode, selection.getName(), view, e
			);
		}
	}
	expectedData( data );

	expectedSelection = ve.dm.Selection.static.newFromJSON( model.getDocument(), expectedRangeOrSelection instanceof ve.Range ?
		{ type: 'linear', range: expectedRangeOrSelection } :
		expectedRangeOrSelection
	);

	assert.equalLinearData( model.getDocument().getFullData(), data, msg + ': data' );
	assert.equalHash( selection, expectedSelection, msg + ': selection' );
	view.destroy();
};

ve.test.utils.TestEvent = function TestEvent( data ) {
	data = data || {};
	this.originalEvent = {
		clipboardData: {
			getData: function ( prop ) {
				return data[ prop ];
			},
			setData: function ( prop, val ) {
				data[ prop ] = val;
				return true;
			},
			items: []
		}
	};
	this.preventDefault = this.stopPropagation = function () {};
};

QUnit.test( 'special key down: backspace/delete', function ( assert ) {
	var i,
		emptyList = '<ul><li><p></p></li></ul>',
		mergedCellsDoc = ve.dm.example.createExampleDocument( 'mergedCells' ),
		cases = [
			{
				rangeOrSelection: new ve.Range( 1, 4 ),
				keys: [ 'BACKSPACE' ],
				expectedData: function ( data ) {
					data.splice( 1, 3 );
				},
				expectedRangeOrSelection: new ve.Range( 1 ),
				msg: 'Selection deleted by backspace'
			},
			{
				rangeOrSelection: new ve.Range( 1, 4 ),
				keys: [ 'DELETE' ],
				expectedData: function ( data ) {
					data.splice( 1, 3 );
				},
				expectedRangeOrSelection: new ve.Range( 1 ),
				msg: 'Selection deleted by delete'
			},
			{
				rangeOrSelection: new ve.Range( 4 ),
				keys: [ 'CTRL+BACKSPACE' ],
				expectedData: function ( data ) {
					data.splice( 1, 3 );
				},
				expectedRangeOrSelection: new ve.Range( 1 ),
				msg: 'Whole word deleted by modified backspace'
			},
			{
				rangeOrSelection: new ve.Range( 1 ),
				keys: [ 'CTRL+DELETE' ],
				expectedData: function ( data ) {
					data.splice( 1, 3 );
				},
				expectedRangeOrSelection: new ve.Range( 1 ),
				msg: 'Whole word deleted by modified delete'
			},
			{
				rangeOrSelection: new ve.Range( 56, 57 ),
				keys: [ 'DELETE', 'DELETE' ],
				expectedData: function ( data ) {
					data.splice( 55, 3 );
				},
				expectedRangeOrSelection: new ve.Range( 56 ),
				msg: 'Empty node deleted by delete; selection goes to nearest content offset'
			},
			{
				rangeOrSelection: new ve.Range( 41 ),
				keys: [ 'BACKSPACE' ],
				expectedData: function () {},
				expectedRangeOrSelection: new ve.Range( 39, 41 ),
				msg: 'Focusable node selected but not deleted by backspace'
			},
			{
				rangeOrSelection: new ve.Range( 39 ),
				keys: [ 'DELETE' ],
				expectedData: function () {},
				expectedRangeOrSelection: new ve.Range( 39, 41 ),
				msg: 'Focusable node selected but not deleted by delete'
			},
			{
				rangeOrSelection: new ve.Range( 39, 41 ),
				keys: [ 'DELETE' ],
				expectedData: function ( data ) {
					data.splice( 39, 2 );
				},
				expectedRangeOrSelection: new ve.Range( 39 ),
				msg: 'Focusable node deleted if selected first'
			},
			{
				rangeOrSelection: new ve.Range( 38 ),
				keys: [ 'BACKSPACE' ],
				expectedData: function () {},
				expectedRangeOrSelection: {
					type: 'table',
					tableRange: new ve.Range( 5, 37 ),
					fromCol: 0,
					fromRow: 0
				},
				msg: 'Table cell selected but not deleted by backspace'
			},
			{
				rangeOrSelection: new ve.Range( 4 ),
				keys: [ 'DELETE' ],
				expectedData: function () {},
				expectedRangeOrSelection: {
					type: 'table',
					tableRange: new ve.Range( 5, 37 ),
					fromCol: 0,
					fromRow: 0
				},
				msg: 'Table cell selected but not deleted by delete'
			},
			{
				htmlOrDoc: '<p>a</p>' + emptyList + '<p>b</p>',
				rangeOrSelection: new ve.Range( 6 ),
				keys: [ 'DELETE' ],
				expectedData: function ( data ) {
					data.splice( 3, 6 );
				},
				expectedRangeOrSelection: new ve.Range( 4 ),
				msg: 'Empty list node deleted by delete from inside'
			},
			{
				htmlOrDoc: '<p>a</p>' + emptyList + '<p>b</p>',
				rangeOrSelection: new ve.Range( 6 ),
				keys: [ 'BACKSPACE' ],
				expectedData: function ( data ) {
					data.splice( 3, 6 );
				},
				expectedRangeOrSelection: new ve.Range( 2 ),
				msg: 'Empty list node deleted by backspace from inside'
			},
			{
				htmlOrDoc: '<p>a</p>' + emptyList + '<p>b</p>',
				rangeOrSelection: new ve.Range( 2 ),
				keys: [ 'DELETE' ],
				expectedData: function ( data ) {
					data.splice( 3, 6 );
				},
				expectedRangeOrSelection: new ve.Range( 2 ),
				msg: 'Empty list node deleted by delete from before'
			},
			{
				htmlOrDoc: '<p>a</p>' + emptyList + '<p>b</p>',
				rangeOrSelection: new ve.Range( 10 ),
				keys: [ 'BACKSPACE' ],
				expectedData: function ( data ) {
					data.splice( 3, 6 );
				},
				expectedRangeOrSelection: new ve.Range( 4 ),
				msg: 'Empty list node deleted by backspace from after'
			},
			{
				htmlOrDoc: '<ul><li><p></p>' + emptyList + '</li></ul>',
				rangeOrSelection: new ve.Range( 7 ),
				keys: [ 'BACKSPACE' ],
				expectedData: function ( data ) {
					data.splice( 2, 2 );
				},
				expectedRangeOrSelection: new ve.Range( 5 ),
				msg: 'Selection is not lost inside block slug after backspace'
			},
			{
				rangeOrSelection: new ve.Range( 0, 63 ),
				keys: [ 'BACKSPACE' ],
				expectedData: function ( data ) {
					data.splice( 0, 61,
							{ type: 'paragraph' },
							{ type: '/paragraph' }
						);
				},
				expectedRangeOrSelection: new ve.Range( 1 ),
				msg: 'Backspace after select all spanning entire document creates empty paragraph'
			},
			{
				htmlOrDoc: emptyList + '<p>foo</p>',
				rangeOrSelection: new ve.Range( 3 ),
				keys: [ 'BACKSPACE' ],
				expectedData: function ( data ) {
					data.splice( 0, 2 );
					data.splice( 2, 2 );
				},
				expectedRangeOrSelection: new ve.Range( 1 ),
				msg: 'List at start of document unwrapped by backspace'
			},
			{
				htmlOrDoc: '<p>foo</p>' + emptyList,
				rangeOrSelection: new ve.Range( 8 ),
				keys: [ 'DELETE' ],
				expectedData: function ( data ) {
					data.splice( 5, 2 );
					data.splice( 7, 2 );
				},
				expectedRangeOrSelection: new ve.Range( 6 ),
				msg: 'Empty list at end of document unwrapped by delete'
			},
			{
				htmlOrDoc: '<p>foo</p><ul><li><p>bar</p></li></ul>',
				rangeOrSelection: new ve.Range( 11 ),
				keys: [ 'DELETE' ],
				expectedData: function ( data ) {
					data.splice( 5, 2 );
					data.splice( 10, 2 );
				},
				expectedRangeOrSelection: new ve.Range( 6 ),
				msg: 'Non-empty list at end of document unwrapped by delete'
			},
			{
				htmlOrDoc: '<p>foo</p><ul><li><p>bar</p></li><li><p>baz</p></li></ul>',
				rangeOrSelection: new ve.Range( 18 ),
				keys: [ 'DELETE' ],
				expectedData: function ( data ) {
					var paragraph = data.splice( 14, 5 );
					data.splice( 13, 2 ); // remove the empty listItem
					data.splice.apply( data, [ 14, 0 ].concat( paragraph, { type: 'list', attributes: { style: 'bullet' } }, { type: '/list' } ) );
				},
				expectedRangeOrSelection: new ve.Range( 15 ),
				msg: 'Non-empty multi-item list at end of document unwrapped by delete'
			},
			{
				htmlOrDoc: '<p>foo</p>',
				rangeOrSelection: new ve.Range( 4 ),
				keys: [ 'DELETE' ],
				expectedData: function () {
				},
				expectedRangeOrSelection: new ve.Range( 4 ),
				msg: 'Delete at end of last paragraph does nothing'
			},
			{
				htmlOrDoc: '<p>foo</p><p>bar</p><p></p>',
				rangeOrSelection: new ve.Range( 11 ),
				keys: [ 'DELETE' ],
				expectedData: function () {
				},
				expectedRangeOrSelection: new ve.Range( 11 ),
				msg: 'Delete at end of last empty paragraph does nothing'
			},
			{
				htmlOrDoc: '<div rel="ve:Alien">foo</div><p>bar</p>',
				rangeOrSelection: new ve.Range( 2 ),
				keys: [ 'BACKSPACE' ],
				expectedData: function () {
				},
				expectedRangeOrSelection: new ve.Range( 0, 2 ),
				msg: 'Backspace after an alien just selects it'
			},
			{
				htmlOrDoc: '<p>bar</p><div rel="ve:Alien">foo</div>',
				rangeOrSelection: new ve.Range( 4 ),
				keys: [ 'DELETE' ],
				expectedData: function () {
				},
				expectedRangeOrSelection: new ve.Range( 5, 7 ),
				msg: 'Delete before an alien just selects it'
			},
			{
				htmlOrDoc: '<div rel="ve:Alien">foo</div><ul><li><p>foo</p></li></ul>',
				rangeOrSelection: new ve.Range( 5 ),
				keys: [ 'BACKSPACE' ],
				expectedData: function ( data ) {
					data.splice( 2, 2 );
					data.splice( 7, 2 );
				},
				expectedRangeOrSelection: new ve.Range( 3 ),
				msg: 'List after an alien unwrapped by backspace'
			},
			{
				htmlOrDoc: '<p></p><div rel="ve:Alien">foo</div>',
				rangeOrSelection: new ve.Range( 2, 4 ),
				keys: [ 'BACKSPACE' ],
				expectedData: function ( data ) {
					data.splice( 2, 2 );
				},
				expectedRangeOrSelection: new ve.Range( 1 ),
				msg: 'Backspace with an alien selected deletes it'
			},
			{
				htmlOrDoc: '<p></p><div rel="ve:Alien">foo</div>',
				rangeOrSelection: new ve.Range( 2, 4 ),
				keys: [ 'DELETE' ],
				expectedData: function ( data ) {
					data.splice( 2, 2 );
				},
				expectedRangeOrSelection: new ve.Range( 1 ),
				msg: 'Delete with an alien selected deletes it'
			},
			{
				htmlOrDoc: '<div rel="ve:Alien">foo</div><div rel="ve:Alien">foo</div>',
				rangeOrSelection: new ve.Range( 2, 4 ),
				keys: [ 'BACKSPACE' ],
				expectedData: function ( data ) {
					data.splice( 2, 2 );
				},
				expectedRangeOrSelection: new ve.Range( 2 ),
				msg: 'Backspace with an alien selected deletes it, with only aliens in the document'
			},
			{
				htmlOrDoc: '<div rel="ve:Alien">foo</div><div rel="ve:Alien">foo</div>',
				rangeOrSelection: new ve.Range( 2, 4 ),
				keys: [ 'DELETE' ],
				expectedData: function ( data ) {
					data.splice( 2, 2 );
				},
				expectedRangeOrSelection: new ve.Range( 2 ),
				msg: 'Delete with an alien selected deletes it, with only aliens in the document'
			},
			{
				htmlOrDoc: '<div rel="ve:Alien">foo</div>',
				rangeOrSelection: new ve.Range( 0, 2 ),
				keys: [ 'BACKSPACE' ],
				expectedData: function ( data ) {
					data.splice( 0, 2,
						{ type: 'paragraph' },
						{ type: '/paragraph' }
					);
				},
				expectedRangeOrSelection: new ve.Range( 1 ),
				msg: 'Backspace with an alien selected deletes it and replaces it with a paragraph, when the alien is the entire document'
			},
			{
				htmlOrDoc: mergedCellsDoc,
				rangeOrSelection: {
					type: 'table',
					tableRange: new ve.Range( 0, 171 ),
					fromCol: 0,
					fromRow: 0,
					toCol: 2,
					toRow: 1
				},
				keys: [ 'BACKSPACE' ],
				expectedData: function ( data ) {
					data.splice( 4, 3,
						{ type: 'paragraph', internal: { generated: 'wrapper' } },
						{ type: '/paragraph' }
					);
					data.splice( 8, 3,
						{ type: 'paragraph', internal: { generated: 'wrapper' } },
						{ type: '/paragraph' }
					);
					data.splice( 12, 3,
						{ type: 'paragraph', internal: { generated: 'wrapper' } },
						{ type: '/paragraph' }
					);
					data.splice( 33, 3,
						{ type: 'paragraph', internal: { generated: 'wrapper' } },
						{ type: '/paragraph' }
					);
					data.splice( 37, 3,
						{ type: 'paragraph', internal: { generated: 'wrapper' } },
						{ type: '/paragraph' }
					);
				},
				expectedRangeOrSelection: {
					type: 'table',
					tableRange: new ve.Range( 0, 166 ),
					fromCol: 0,
					fromRow: 0,
					toCol: 2,
					toRow: 1
				},
				msg: 'Table cells emptied by backspace'
			}
		];

	QUnit.expect( cases.length * 2 );

	for ( i = 0; i < cases.length; i++ ) {
		ve.test.utils.runSurfaceHandleSpecialKeyTest(
			assert, cases[ i ].htmlOrDoc, cases[ i ].rangeOrSelection, cases[ i ].keys,
			cases[ i ].expectedData, cases[ i ].expectedRangeOrSelection, cases[ i ].msg
		);
	}
} );

QUnit.test( 'special key down: table cells', function ( assert ) {
	var i,
		mergedCellsDoc = ve.dm.example.createExampleDocument( 'mergedCells' ),
		cases = [
			{
				htmlOrDoc: mergedCellsDoc,
				rangeOrSelection: {
					type: 'table',
					tableRange: new ve.Range( 0, 171 ),
					fromCol: 1,
					fromRow: 0
				},
				keys: [ 'ENTER' ],
				expectedData: function () {},
				expectedRangeOrSelection: new ve.Range( 11 ),
				msg: 'Enter to edit a table cell'
			},
			{
				htmlOrDoc: mergedCellsDoc,
				rangeOrSelection: {
					type: 'table',
					tableRange: new ve.Range( 0, 171 ),
					fromCol: 1,
					fromRow: 0
				},
				keys: [ 'ENTER', 'ESCAPE' ],
				expectedData: function () {},
				expectedRangeOrSelection: {
					type: 'table',
					tableRange: new ve.Range( 0, 171 ),
					fromCol: 1,
					fromRow: 0
				},
				msg: 'Escape to leave a table cell'
			},
			{
				htmlOrDoc: mergedCellsDoc,
				rangeOrSelection: {
					type: 'table',
					tableRange: new ve.Range( 0, 171 ),
					fromCol: 1,
					fromRow: 0
				},
				keys: [ 'ENTER', 'TAB' ],
				expectedData: function () {},
				expectedRangeOrSelection: {
					type: 'table',
					tableRange: new ve.Range( 0, 171 ),
					fromCol: 2,
					fromRow: 0
				},
				msg: 'Tab while in a table cell moves to the next cell'
			},
			{
				htmlOrDoc: mergedCellsDoc,
				rangeOrSelection: {
					type: 'table',
					tableRange: new ve.Range( 0, 171 ),
					fromCol: 1,
					fromRow: 0
				},
				keys: [ 'ENTER', 'SHIFT+TAB' ],
				expectedData: function () {},
				expectedRangeOrSelection: {
					type: 'table',
					tableRange: new ve.Range( 0, 171 ),
					fromCol: 0,
					fromRow: 0
				},
				msg: 'Shift+tab while in a table cell moves to the previous cell'
			},
			{
				// Create a full surface and return the view, as the UI surface is required for the insert action
				htmlOrDoc: ve.test.utils.createSurfaceFromDocument( ve.dm.example.createExampleDocument( 'mergedCells' ) ).view,
				rangeOrSelection: {
					type: 'table',
					tableRange: new ve.Range( 0, 171 ),
					fromCol: 5,
					fromRow: 6
				},
				keys: [ 'TAB' ],
				expectedData: function ( data ) {
					var tableCell = [
						{ type: 'tableCell', attributes: { style: 'data', colspan: 1, rowspan: 1 } },
						{ type: 'paragraph', internal: { generated: 'wrapper' } },
						{ type: '/paragraph' },
						{ type: '/tableCell' }
					];
					data.splice.apply( data, [ 169, 0 ].concat( { type: 'tableRow' }, tableCell, tableCell, tableCell, tableCell, tableCell, tableCell, { type: '/tableRow' } ) );
				},
				expectedRangeOrSelection: {
					type: 'table',
					tableRange: new ve.Range( 0, 197 ),
					fromCol: 0,
					fromRow: 7
				},
				msg: 'Tab at end of table inserts new row'
			},
			{
				// Create a full surface and return the view, as the UI surface is required for the insert action
				htmlOrDoc: mergedCellsDoc,
				rangeOrSelection: {
					type: 'table',
					tableRange: new ve.Range( 0, 171 ),
					fromCol: 2,
					fromRow: 0
				},
				keys: [ 'UP' ],
				expectedData: function () {},
				expectedRangeOrSelection: new ve.Range( 0 ),
				msg: 'Up in first row of table moves out of table'
			},
			{
				// Create a full surface and return the view, as the UI surface is required for the insert action
				htmlOrDoc: mergedCellsDoc,
				rangeOrSelection: {
					type: 'table',
					tableRange: new ve.Range( 0, 171 ),
					fromCol: 2,
					fromRow: 6
				},
				keys: [ 'DOWN' ],
				expectedData: function () {},
				expectedRangeOrSelection: new ve.Range( 171 ),
				msg: 'Down in last row of table moves out of table'
			}
		];

	QUnit.expect( cases.length * 2 );

	for ( i = 0; i < cases.length; i++ ) {
		ve.test.utils.runSurfaceHandleSpecialKeyTest(
			assert, cases[ i ].htmlOrDoc, cases[ i ].rangeOrSelection, cases[ i ].keys,
			cases[ i ].expectedData, cases[ i ].expectedRangeOrSelection, cases[ i ].msg
		);
	}
} );

QUnit.test( 'special key down: linear arrow keys', function ( assert ) {
	var i,
		blockImageDoc = ve.dm.example.createExampleDocumentFromData(
			[ { type: 'paragraph' }, 'F', 'o', 'o', { type: '/paragraph' } ].concat(
				ve.dm.example.blockImage.data.slice()
			).concat(
				[ { type: 'paragraph' }, 'B', 'a', 'r', { type: '/paragraph' } ]
			)
		),
		cases = [
			// Within normal text. NOTE: these tests manually force the cursor to
			// move, because we rely on native browser actions for that.
			// As such, these are mostly testing to make sure that other
			// behavior doesn't trigger when it shouldn't.
			{
				htmlOrDoc: blockImageDoc,
				rangeOrSelection: new ve.Range( 2 ),
				keys: [ 'LEFT' ],
				forceSelection: new ve.Range( 1 ),
				expectedData: function () {},
				expectedRangeOrSelection: new ve.Range( 1 ),
				msg: 'Cursor left in text'
			},
			{
				htmlOrDoc: blockImageDoc,
				rangeOrSelection: new ve.Range( 2 ),
				keys: [ 'RIGHT' ],
				forceSelection: new ve.Range( 3 ),
				expectedData: function () {},
				expectedRangeOrSelection: new ve.Range( 3 ),
				msg: 'Cursor right in text'
			},
			{
				htmlOrDoc: blockImageDoc,
				rangeOrSelection: new ve.Range( 4 ),
				keys: [ 'UP' ],
				forceSelection: new ve.Range( 1 ),
				expectedData: function () {},
				expectedRangeOrSelection: new ve.Range( 1 ),
				msg: 'Cursor up in text'
			},
			{
				htmlOrDoc: blockImageDoc,
				rangeOrSelection: new ve.Range( 20 ),
				keys: [ 'DOWN' ],
				forceSelection: new ve.Range( 22 ),
				expectedData: function () {},
				expectedRangeOrSelection: new ve.Range( 22 ),
				msg: 'Cursor down in text'
			},
			// Cursor with shift held to adjust selection
			{
				htmlOrDoc: blockImageDoc,
				rangeOrSelection: new ve.Range( 2 ),
				keys: [ 'SHIFT+LEFT' ],
				forceSelection: new ve.Range( 1 ),
				expectedData: function () {},
				expectedRangeOrSelection: new ve.Range( 2, 1 ),
				msg: 'Cursor left in text with shift'
			},
			{
				htmlOrDoc: blockImageDoc,
				rangeOrSelection: new ve.Range( 2 ),
				keys: [ 'SHIFT+RIGHT' ],
				forceSelection: new ve.Range( 3 ),
				expectedData: function () {},
				expectedRangeOrSelection: new ve.Range( 2, 3 ),
				msg: 'Cursor right in text with shift'
			},
			{
				htmlOrDoc: blockImageDoc,
				rangeOrSelection: new ve.Range( 4 ),
				keys: [ 'SHIFT+UP' ],
				forceSelection: new ve.Range( 1 ),
				expectedData: function () {},
				expectedRangeOrSelection: new ve.Range( 4, 1 ),
				msg: 'Cursor up in text with shift'
			},
			{
				htmlOrDoc: blockImageDoc,
				rangeOrSelection: new ve.Range( 20 ),
				keys: [ 'SHIFT+DOWN' ],
				forceSelection: new ve.Range( 22 ),
				expectedData: function () {},
				expectedRangeOrSelection: new ve.Range( 20, 22 ),
				msg: 'Cursor down in text with shift'
			},
			// While focusing a block node
			{
				htmlOrDoc: blockImageDoc,
				rangeOrSelection: new ve.Range( 5, 18 ),
				keys: [ 'LEFT' ],
				expectedData: function () {},
				expectedRangeOrSelection: new ve.Range( 4 ),
				msg: 'Cursor left off a block node'
			},
			{
				htmlOrDoc: blockImageDoc,
				rangeOrSelection: new ve.Range( 5, 18 ),
				keys: [ 'UP' ],
				expectedData: function () {},
				expectedRangeOrSelection: new ve.Range( 4 ),
				msg: 'Cursor up off a block node'
			},
			{
				htmlOrDoc: blockImageDoc,
				rangeOrSelection: new ve.Range( 5, 18 ),
				keys: [ 'RIGHT' ],
				expectedData: function () {},
				expectedRangeOrSelection: new ve.Range( 19 ),
				msg: 'Cursor right off a block node'
			},
			{
				htmlOrDoc: blockImageDoc,
				rangeOrSelection: new ve.Range( 5, 18 ),
				keys: [ 'DOWN' ],
				expectedData: function () {},
				expectedRangeOrSelection: new ve.Range( 19 ),
				msg: 'Cursor down off a block node'
			},
			// Cursoring onto a block node, which should focus it
			// Again, these are forcibly moving the cursor, so it's not a perfect
			// test; it's more checking how we fix up the selection afterwards.
			{
				htmlOrDoc: blockImageDoc,
				rangeOrSelection: new ve.Range( 4 ),
				keys: [ 'RIGHT' ],
				forceSelection: new ve.Range( 8 ), // cursor moves into the caption
				expectedData: function () {},
				expectedRangeOrSelection: new ve.Range( 5, 18 ),
				msg: 'Cursor right onto a block node'
			},
			{
				htmlOrDoc: blockImageDoc,
				rangeOrSelection: new ve.Range( 19 ),
				keys: [ 'LEFT' ],
				forceSelection: new ve.Range( 17 ),
				expectedData: function () {},
				expectedRangeOrSelection: new ve.Range( 18, 5 ),
				msg: 'Cursor left onto a block node'
			},
			{
				htmlOrDoc: blockImageDoc,
				rangeOrSelection: new ve.Range( 4 ),
				keys: [ 'DOWN' ],
				forceSelection: new ve.Range( 14 ), // cursor moves into the caption
				expectedData: function () {},
				expectedRangeOrSelection: new ve.Range( 5, 18 ),
				msg: 'Cursor down onto a block node'
			},
			{
				htmlOrDoc: blockImageDoc,
				rangeOrSelection: new ve.Range( 20 ),
				keys: [ 'UP' ],
				forceSelection: new ve.Range( 14 ), // cursor moves into the caption
				expectedData: function () {},
				expectedRangeOrSelection: new ve.Range( 18, 5 ),
				msg: 'Cursor up onto a block node'
			}
		];

	QUnit.expect( cases.length * 2 );

	for ( i = 0; i < cases.length; i++ ) {
		ve.test.utils.runSurfaceHandleSpecialKeyTest(
			assert, cases[ i ].htmlOrDoc, cases[ i ].rangeOrSelection, cases[ i ].keys,
			cases[ i ].expectedData, cases[ i ].expectedRangeOrSelection, cases[ i ].msg,
			cases[ i ].forceSelection, true
		);
	}
} );

QUnit.test( 'special key down: linear enter', function ( assert ) {
	var i,
		emptyList = '<ul><li><p></p></li></ul>',
		cases = [
			{
				rangeOrSelection: new ve.Range( 57 ),
				keys: [ 'ENTER' ],
				expectedData: function ( data ) {
					data.splice(
						57, 0,
						{ type: '/paragraph' },
						{ type: 'paragraph' }
					);
				},
				expectedRangeOrSelection: new ve.Range( 59 ),
				msg: 'End of paragraph split by enter'
			},
			{
				rangeOrSelection: new ve.Range( 57 ),
				keys: [ 'SHIFT+ENTER' ],
				expectedData: function ( data ) {
					data.splice(
						57, 0,
						{ type: '/paragraph' },
						{ type: 'paragraph' }
					);
				},
				expectedRangeOrSelection: new ve.Range( 59 ),
				msg: 'End of paragraph split by shift+enter'
			},
			{
				rangeOrSelection: new ve.Range( 56 ),
				keys: [ 'ENTER' ],
				expectedData: function ( data ) {
					data.splice(
						56, 0,
						{ type: '/paragraph' },
						{ type: 'paragraph' }
					);
				},
				expectedRangeOrSelection: new ve.Range( 58 ),
				msg: 'Start of paragraph split by enter'
			},
			{
				rangeOrSelection: new ve.Range( 3 ),
				keys: [ 'ENTER' ],
				expectedData: function ( data ) {
					data.splice(
						3, 0,
						{ type: '/heading' },
						{ type: 'heading', attributes: { level: 1 } }
					);
				},
				expectedRangeOrSelection: new ve.Range( 5 ),
				msg: 'Heading split by enter'
			},
			{
				rangeOrSelection: new ve.Range( 2, 3 ),
				keys: [ 'ENTER' ],
				expectedData: function ( data ) {
					data.splice(
						2, 1,
						{ type: '/heading' },
						{ type: 'heading', attributes: { level: 1 } }
					);
				},
				expectedRangeOrSelection: new ve.Range( 4 ),
				msg: 'Selection in heading removed, then split by enter'
			},
			{
				rangeOrSelection: new ve.Range( 1 ),
				keys: [ 'ENTER' ],
				expectedData: function ( data ) {
					data.splice(
						0, 0,
						{ type: 'paragraph' },
						{ type: '/paragraph' }
					);
				},
				expectedRangeOrSelection: new ve.Range( 3 ),
				msg: 'Start of heading split into a plain paragraph'
			},
			{
				rangeOrSelection: new ve.Range( 4 ),
				keys: [ 'ENTER' ],
				expectedData: function ( data ) {
					data.splice(
						5, 0,
						{ type: 'paragraph' },
						{ type: '/paragraph' }
					);
				},
				expectedRangeOrSelection: new ve.Range( 6 ),
				msg: 'End of heading split into a plain paragraph'
			},
			{
				rangeOrSelection: new ve.Range( 16 ),
				keys: [ 'ENTER' ],
				expectedData: function ( data ) {
					data.splice(
						16, 0,
						{ type: '/paragraph' },
						{ type: '/listItem' },
						{ type: 'listItem' },
						{ type: 'paragraph' }
					);
				},
				expectedRangeOrSelection: new ve.Range( 20 ),
				msg: 'List item split by enter'
			},
			{
				rangeOrSelection: new ve.Range( 16 ),
				keys: [ 'SHIFT+ENTER' ],
				expectedData: function ( data ) {
					data.splice(
						16, 0,
						{ type: '/paragraph' },
						{ type: 'paragraph' }
					);
				},
				expectedRangeOrSelection: new ve.Range( 18 ),
				msg: 'List item not split by shift+enter'
			},
			{
				rangeOrSelection: new ve.Range( 21 ),
				keys: [ 'ENTER', 'ENTER' ],
				expectedData: function ( data ) {
					data.splice(
						24, 0,
						{ type: 'paragraph' },
						{ type: '/paragraph' }
					);
				},
				expectedRangeOrSelection: new ve.Range( 25 ),
				msg: 'Two enters breaks out of a list and starts a new paragraph'
			},
			{
				htmlOrDoc: '<p>foo</p>' + emptyList + '<p>bar</p>',
				rangeOrSelection: new ve.Range( 8 ),
				keys: [ 'ENTER' ],
				expectedData: function ( data ) {
					data.splice( 5, 6 );
				},
				expectedRangeOrSelection: new ve.Range( 6 ),
				msg: 'Enter in an empty list destroys it and moves to next paragraph'
			},
			{
				htmlOrDoc: '<p>foo</p>' + emptyList,
				rangeOrSelection: new ve.Range( 8 ),
				keys: [ 'ENTER' ],
				expectedData: function ( data ) {
					data.splice( 5, 6 );
				},
				expectedRangeOrSelection: new ve.Range( 4 ),
				msg: 'Enter in an empty list at end of document destroys it and moves to previous paragraph'
			},
			{
				htmlOrDoc: emptyList + '<p>bar</p>',
				rangeOrSelection: new ve.Range( 3 ),
				keys: [ 'ENTER' ],
				expectedData: function ( data ) {
					data.splice( 0, 6 );
				},
				expectedRangeOrSelection: new ve.Range( 1 ),
				msg: 'Enter in an empty list at start of document destroys it and moves to next paragraph'
			},
			{
				htmlOrDoc: emptyList,
				rangeOrSelection: new ve.Range( 3 ),
				keys: [ 'ENTER' ],
				expectedData: function ( data ) {
					data.splice(
						0, 6,
						{ type: 'paragraph' },
						{ type: '/paragraph' }
					);
				},
				expectedRangeOrSelection: new ve.Range( 1 ),
				msg: 'Enter in an empty list with no adjacent content destroys it and creates a paragraph'
			}
		];

	QUnit.expect( cases.length * 2 );

	for ( i = 0; i < cases.length; i++ ) {
		ve.test.utils.runSurfaceHandleSpecialKeyTest(
			assert, cases[ i ].htmlOrDoc, cases[ i ].rangeOrSelection, cases[ i ].keys,
			cases[ i ].expectedData, cases[ i ].expectedRangeOrSelection, cases[ i ].msg
		);
	}
} );

QUnit.test( 'handleObservedChanges (content changes)', function ( assert ) {
	var i,
		linkIndex = 'h4601de4ee174fedd',
		cases = [
			{
				prevHtml: '<p></p>',
				prevRange: new ve.Range( 1 ),
				nextHtml: '<p>A</p>',
				nextRange: new ve.Range( 2 ),
				expectedOps: [
					[
						{ type: 'retain', length: 1 },
						{
							type: 'replace',
							insert: [ 'A' ],
							remove: [],
							insertedDataOffset: 0,
							insertedDataLength: 1
						},
						{ type: 'retain', length: 3 }
					]
				],
				msg: 'Simple insertion into empty paragraph'
			},
			{
				prevHtml: '<p>A</p>',
				prevRange: new ve.Range( 1, 2 ),
				nextHtml: '<p>B</p>',
				nextRange: new ve.Range( 2 ),
				expectedOps: [
					[
						{ type: 'retain', length: 1 },
						{
							type: 'replace',
							insert: [ 'B' ],
							remove: [ 'A' ],
							insertedDataLength: 1,
							insertedDataOffset: 0
						},
						{ type: 'retain', length: 3 }
					]
				],
				msg: 'Simple replace'
			},
			{
				prevHtml: '<p><a href="Foo">A</a><a href="Bar">FooX?</a></p>',
				prevRange: new ve.Range( 5, 6 ),
				nextHtml: '<p><a href="Foo">A</a><a href="Bar">FooB?</a></p>',
				nextRange: new ve.Range( 6 ),
				expectedOps: [
					[
						{ type: 'retain', length: 5 },
						{
							type: 'replace',
							insert: [ [ 'B', [ linkIndex ] ] ],
							remove: [ [ 'X', [ linkIndex ] ] ],
							insertedDataLength: 1,
							insertedDataOffset: 0
						},
						{ type: 'retain', length: 4 }
					]
				],
				msg: 'Replace into non-zero annotation next to word break'
			},
			{
				prevHtml: '<p><b>X</b></p>',
				prevRange: new ve.Range( 2 ),
				nextHtml: '<p><b>XY</b></p>',
				nextRange: new ve.Range( 3 ),
				expectedOps: [
					[
						{ type: 'retain', length: 2 },
						{
							type: 'replace',
							insert: [ [ 'Y', [ 'h3f03d2abae6ddc0d' ] ] ],
							remove: [],
							insertedDataOffset: 0,
							insertedDataLength: 1
						},
						{ type: 'retain', length: 3 }
					]
				],
				msg: 'Append into bold'
			},
			{
				prevHtml: '<p><b>X</b></p>',
				prevRange: new ve.Range( 2 ),
				prevFocusIsAfterAnnotationBoundary: true,
				nextHtml: '<p><b>X</b>Y</p>',
				nextRange: new ve.Range( 3 ),
				expectedOps: [
					[
						{ type: 'retain', length: 2 },
						{
							type: 'replace',
							insert: [ 'Y' ],
							remove: [],
							insertedDataOffset: 0,
							insertedDataLength: 1
						},
						{ type: 'retain', length: 3 }
					]
				],
				msg: 'Append after bold'
			},
			{
				prevHtml: '<p>Foo</p>',
				prevRange: new ve.Range( 4 ),
				nextHtml: '<p>Foo </p>',
				nextRange: new ve.Range( 5 ),
				expectedOps: [
					[
						{ type: 'retain', length: 4 },
						{
							type: 'replace',
							insert: [ ' ' ],
							remove: [],
							insertedDataOffset: 0,
							insertedDataLength: 1
						},
						{ type: 'retain', length: 3 }
					]
				],
				expectsBreakpoint: true, // adding a word break triggers a breakpoint
				msg: 'Inserting a word break'
			},
			{
				prevHtml: '<p>Foo</p>',
				prevRange: new ve.Range( 4 ),
				nextHtml: '<p>Fo</p>',
				nextRange: new ve.Range( 3 ),
				expectedOps: [
					[
						{ type: 'retain', length: 3 },
						{
							type: 'replace',
							insert: [],
							remove: [ 'o' ]
						},
						{ type: 'retain', length: 3 }
					]
				],
				expectsBreakpoint: true, // any delete triggers a breakpoint
				msg: 'Deleting text'
			},
			{
				prevHtml: '<p>Foo</p>',
				prevRange: new ve.Range( 4 ),
				nextHtml: '<p>Foo</p>',
				nextRange: new ve.Range( 1 ),
				expectedOps: [],
				expectsBreakpoint: false,
				msg: 'Just moving the selection'
			}
		];

	QUnit.expect( cases.length * 3 );

	function testRunner( prevHtml, prevRange, prevFocusIsAfterAnnotationBoundary, nextHtml, nextRange, expectedOps, expectedRangeOrSelection, expectsBreakpoint, msg ) {
		var txs, i, ops,
			delayed = [],
			view = ve.test.utils.createSurfaceViewFromHtml( prevHtml ),
			model = view.getModel(),
			node = view.getDocument().getDocumentNode().children[ 0 ],
			prevNode = $( prevHtml )[ 0 ],
			nextNode = $( nextHtml )[ 0 ],
			prev = {
				node: node,
				text: ve.ce.getDomText( prevNode ),
				textState: new ve.ce.TextState( prevNode ),
				veRange: prevRange,
				focusIsAfterAnnotationBoundary: prevFocusIsAfterAnnotationBoundary || false
			},
			next = {
				node: node,
				text: ve.ce.getDomText( nextNode ),
				textState: new ve.ce.TextState( nextNode ),
				veRange: nextRange,
				selectionChanged: !nextRange.equals( prevRange ),
				contentChanged: true
			},
			initialBreakpoints = model.undoStack.length;

		view.afterRenderLock = function ( callback ) {
			delayed.push( callback );
		};

		// Set model linear selection, so that insertion annotations are primed correctly
		model.setLinearSelection( prevRange );
		view.handleObservedChanges( prev, next );
		for ( i = 0; i < delayed.length; i++ ) {
			delayed[ i ]();
		}
		txs = ( model.getHistory()[ 0 ] || {} ).transactions || [];
		ops = [];
		for ( i = 0; i < txs.length; i++ ) {
			ops.push( txs[ i ].getOperations() );
		}
		assert.deepEqual( ops, expectedOps, msg + ': keys' );
		assert.equalRange( model.getSelection().getRange(), expectedRangeOrSelection, msg + ': range' );
		assert.equal( initialBreakpoints !== model.undoStack.length, !!expectsBreakpoint, msg + ': breakpoint' );

		view.destroy();
	}

	for ( i = 0; i < cases.length; i++ ) {
		testRunner(
			cases[ i ].prevHtml, cases[ i ].prevRange, cases[ i ].prevFocusIsAfterAnnotationBoundary || false,
			cases[ i ].nextHtml, cases[ i ].nextRange,
			cases[ i ].expectedOps, cases[ i ].expectedRangeOrSelection || cases[ i ].nextRange, cases[ i ].expectsBreakpoint, cases[ i ].msg
		);
	}

} );

QUnit.test( 'handleDataTransfer/handleDataTransferItems', function ( assert )  {
	var i,
		surface = ve.test.utils.createViewOnlySurfaceFromHtml( '' ),
		view = surface.getView(),
		model = surface.getModel(),
		linkAction = ve.ui.actionFactory.create( 'link', surface ),
		link = linkAction.getLinkAnnotation( 'http://foo.com' ),
		// Don't hard-code link index as it may depend on the LinkAction used
		linkIndex = model.getDocument().getStore().indexOfValue( link ),
		fragment = model.getLinearFragment( new ve.Range( 1 ) ),
		cases = [
			{
				msg: 'URL',
				dataTransfer: {
					items: [
						{
							kind: 'string',
							type: 'text/uri-list'
						}
					],
					getData: function ( type ) {
						return type === 'text/uri-list' ? '#comment\nhttp://foo.com\n' : '';
					}
				},
				isPaste: true,
				expectedData: [
					[ 'h', [ linkIndex ] ],
					[ 't', [ linkIndex ] ],
					[ 't', [ linkIndex ] ],
					[ 'p', [ linkIndex ] ],
					[ ':', [ linkIndex ] ],
					[ '/', [ linkIndex ] ],
					[ '/', [ linkIndex ] ],
					[ 'f', [ linkIndex ] ],
					[ 'o', [ linkIndex ] ],
					[ 'o', [ linkIndex ] ],
					[ '.', [ linkIndex ] ],
					[ 'c', [ linkIndex ] ],
					[ 'o', [ linkIndex ] ],
					[ 'm', [ linkIndex ] ]
				]
			}
		];

	QUnit.expect( cases.length );

	for ( i = 0; i < cases.length; i++ ) {
		fragment.select();
		view.handleDataTransfer( cases[ i ].dataTransfer, cases[ i ].isPaste );
		assert.equalLinearData( model.getDocument().getFullData( fragment.getSelection().getRange() ), cases[ i ].expectedData, cases[ i ].msg );
		model.undo();
	}
} );

QUnit.test( 'getClipboardHash', 1, function ( assert ) {
	assert.strictEqual(
		ve.ce.Surface.static.getClipboardHash(
			$( '  <p class="foo"> B<b>a</b>r </p>\n\t<span class="baz"></span> Quux <h1><span></span>Whee</h1>' )
		),
		'BarQuuxWhee',
		'Simple usage'
	);
} );

QUnit.test( 'onCopy', function ( assert ) {
	var i,
		count = 0,
		cases = [
			{
				rangeOrSelection: new ve.Range( 27, 32 ),
				expectedData: [
					{ type: 'list', attributes: { style: 'number' } },
					{ type: 'listItem' },
					{ type: 'paragraph' },
					'g',
					{ type: '/paragraph' },
					{ type: '/listItem' },
					{ type: '/list' },
					{ type: 'internalList' },
					{ type: '/internalList' }
				],
				expectedOriginalRange: new ve.Range( 1, 6 ),
				expectedBalancedRange: new ve.Range( 1, 6 ),
				expectedHtml: '<ol><li><p>g</p></li></ol>',
				expectedText: 'g\n\n',
				msg: 'Copy list item'
			},
			{
				doc: ve.dm.example.RDFaDoc,
				rangeOrSelection: new ve.Range( 0, 5 ),
				expectedData: ve.dm.example.RDFaDoc.data.data.slice(),
				expectedOriginalRange: new ve.Range( 0, 5 ),
				expectedBalancedRange: new ve.Range( 0, 5 ),
				expectedHtml:
					'<p content="b" datatype="c" property="d" rel="e" resource="f" rev="g" typeof="h" class="i" ' +
						'data-ve-attributes="{&quot;typeof&quot;:&quot;h&quot;,&quot;rev&quot;:&quot;g&quot;,' +
						'&quot;resource&quot;:&quot;f&quot;,&quot;rel&quot;:&quot;e&quot;,&quot;property&quot;:&quot;d&quot;,' +
						'&quot;datatype&quot;:&quot;c&quot;,&quot;content&quot;:&quot;b&quot;}">' +
						'Foo' +
					'</p>',
				expectedText: 'Foo\n\n',
				msg: 'RDFa attributes encoded into data-ve-attributes'
			},
			{
				rangeOrSelection: new ve.Range( 0, 61 ),
				expectedText: 'abc\n\nd\n\ne\n\nf\n\ng\n\nhi\nj\n\nk\n\nl\n\nm\n\n',
				msg: 'Plain text of entire document'
			}
		];

	for ( i = 0; i < cases.length; i++ ) {
		count += 3;
		if ( cases[ i ].expectedData ) {
			count++;
		}
		if ( cases[ i ].expectedHtml ) {
			count++;
		}
		if ( cases[ i ].expectedText ) {
			count++;
		}
	}

	QUnit.expect( count );

	function testRunner( doc, rangeOrSelection, expectedData, expectedOriginalRange, expectedBalancedRange, expectedHtml, expectedText, msg ) {
		var slice,
			testEvent = new ve.test.utils.TestEvent(),
			clipboardData = testEvent.originalEvent.clipboardData,
			view = ve.test.utils.createSurfaceViewFromDocument( doc || ve.dm.example.createExampleDocument() ),
			model = view.getModel();

		// Paste sequence
		model.setSelection( ve.test.utils.selectionFromRangeOrSelection( model.getDocument(), rangeOrSelection ) );
		view.onCopy( testEvent );

		slice = view.clipboard.slice;

		assert.equalRange( slice.originalRange, expectedOriginalRange || rangeOrSelection, msg + ': originalRange' );
		assert.equalRange( slice.balancedRange, expectedBalancedRange || rangeOrSelection, msg + ': balancedRange' );
		if ( expectedData ) {
			assert.equalLinearData( slice.data.data, expectedData, msg + ': data' );
		}
		if ( expectedHtml ) {
			assert.equalDomElement(
				$( '<div>' ).html( clipboardData.getData( 'text/html' ) )[ 0 ],
				$( '<div>' ).html( expectedHtml )[ 0 ],
				msg + ': html'
			);
		}
		if ( expectedText ) {
			assert.strictEqual( clipboardData.getData( 'text/plain' ), expectedText, msg + ': text' );
		}
		assert.strictEqual( clipboardData.getData( 'text/xcustom' ), view.clipboardId + '-' + view.clipboardIndex, msg + ': clipboardId set' );

		view.destroy();
	}

	for ( i = 0; i < cases.length; i++ ) {
		testRunner(
			cases[ i ].doc, cases[ i ].rangeOrSelection, cases[ i ].expectedData,
			cases[ i ].expectedOriginalRange, cases[ i ].expectedBalancedRange,
			cases[ i ].expectedHtml, cases[ i ].expectedText, cases[ i ].msg
		);
	}

} );

QUnit.test( 'beforePaste/afterPaste', function ( assert ) {
	var i,
		layout = $.client.profile().layout,
		expected = 0,
		exampleDoc = '<p id="foo"></p><p>Foo</p><h2> Baz </h2><table><tbody><tr><td></td></tbody></table><p><b>Quux</b></p>',
		exampleSurface = ve.test.utils.createSurfaceViewFromHtml( exampleDoc ),
		docLen = 30,
		bold = ve.dm.example.bold,
		cases = [
			{
				rangeOrSelection: new ve.Range( 1 ),
				pasteHtml: 'Foo',
				expectedRangeOrSelection: new ve.Range( 4 ),
				expectedOps: [
					[
						{ type: 'retain', length: 1 },
						{
							type: 'replace',
							insert: [
								'F', 'o', 'o'
							],
							remove: []
						},
						{ type: 'retain', length: docLen - 1 }
					]
				],
				msg: 'Text into empty paragraph'
			},
			{
				rangeOrSelection: new ve.Range( 4 ),
				pasteHtml: 'Bar',
				expectedRangeOrSelection: new ve.Range( 7 ),
				expectedOps: [
					[
						{ type: 'retain', length: 4 },
						{
							type: 'replace',
							insert: [ 'B', 'a', 'r' ],
							remove: []
						},
						{ type: 'retain', length: docLen - 4 }
					]
				],
				msg: 'Text into paragraph'
			},
			{
				rangeOrSelection: new ve.Range( 4, 5 ),
				pasteHtml: 'Bar',
				expectedRangeOrSelection: new ve.Range( 7 ),
				expectedOps: [
					[
						{ type: 'retain', length: 4 },
						{
							type: 'replace',
							insert: [],
							remove: [ 'o' ]
						},
						{ type: 'retain', length: docLen - 5 }
					],
					[
						{ type: 'retain', length: 4 },
						{
							type: 'replace',
							insert: [ 'B', 'a', 'r' ],
							remove: []
						},
						{ type: 'retain', length: docLen - 5 }
					]
				],
				msg: 'Text into selection'
			},
			{
				rangeOrSelection: new ve.Range( 25 ),
				internalSourceRangeOrSelection: new ve.Range( 3, 6 ),
				expectedRangeOrSelection: new ve.Range( 28 ),
				expectedOps: [
					[
						{ type: 'retain', length: 25 },
						{
							type: 'replace',
							insert: [
								[ 'F', [ bold ] ],
								[ 'o', [ bold ] ],
								[ 'o', [ bold ] ]
							],
							insertedDataLength: 3,
							insertedDataOffset: 0,
							remove: []
						},
						{ type: 'retain', length: docLen - 25 }
					]
				],
				msg: 'Internal text into annotated content'
			},
			{
				rangeOrSelection: new ve.Range( 25 ),
				pasteHtml: 'Foo',
				expectedRangeOrSelection: new ve.Range( 28 ),
				expectedOps: [
					[
						{ type: 'retain', length: 25 },
						{
							type: 'replace',
							insert: [ 'F', 'o', 'o' ],
							remove: []
						},
						{ type: 'retain', length: docLen - 25 }
					],
					[
						{ type: 'retain', length: 25 },
						{
							type: 'annotate',
							method: 'set',
							bias: 'start',
							index: ve.dm.example.annIndex( 'b', 'Quux' )
						},
						{ type: 'retain', length: 3 },
						{
							type: 'annotate',
							method: 'set',
							bias: 'stop',
							index: ve.dm.example.annIndex( 'b', 'Quux' )
						},
						{ type: 'retain', length: docLen - 25 }
					]
				],
				msg: 'External text into annotated content'
			},
			{
				rangeOrSelection: new ve.Range( 23, 27 ),
				pasteHtml: 'Foo',
				expectedRangeOrSelection: new ve.Range( 26 ),
				expectedOps: [
					[
						{ type: 'retain', length: 23 },
						{
							type: 'replace',
							insert: [],
							remove: [
								[ 'Q', [ bold ] ],
								[ 'u', [ bold ] ],
								[ 'u', [ bold ] ],
								[ 'x', [ bold ] ]
							]
						},
						{ type: 'retain', length: docLen - 27 }
					],
					[
						{ type: 'retain', length: 23 },
						{
							type: 'replace',
							insert: [ 'F', 'o', 'o' ],
							remove: []
						},
						{ type: 'retain', length: docLen - 27 }
					],
					[
						{ type: 'retain', length: 23 },
						{
							type: 'annotate',
							method: 'set',
							bias: 'start',
							index: ve.dm.example.annIndex( 'b', 'Quux' )
						},
						{ type: 'retain', length: 3 },
						{
							type: 'annotate',
							method: 'set',
							bias: 'stop',
							index: ve.dm.example.annIndex( 'b', 'Quux' )
						},
						{ type: 'retain', length: docLen - 27 }
					]
				],
				msg: 'External text over annotated content'
			},
			{
				rangeOrSelection: new ve.Range( 4 ),
				pasteHtml: '<span style="color:red;">Foo</span><font style="color:blue;">bar</font>',
				expectedRangeOrSelection: new ve.Range( 10 ),
				expectedOps: [
					[
						{ type: 'retain', length: 4 },
						{
							type: 'replace',
							insert: [ 'F', 'o', 'o', 'b', 'a', 'r' ],
							remove: []
						},
						{ type: 'retain', length: docLen - 4 }
					]
				],
				msg: 'Span and font tags stripped'
			},
			{
				rangeOrSelection: new ve.Range( 4 ),
				pasteHtml: '<span rel="ve:Alien">Foo</span><b>B</b>a<!-- comment --><b>r</b>',
				expectedRangeOrSelection: new ve.Range( 7 ),
				expectedOps: [
					[
						{ type: 'retain', length: 4 },
						{
							type: 'replace',
							insert: [
								[ 'B', [ { type: 'textStyle/bold', attributes: { nodeName: 'b' } } ] ],
								'a',
								[ 'r', [ { type: 'textStyle/bold', attributes: { nodeName: 'b' } } ] ]
							],
							remove: []
						},
						{ type: 'retain', length: docLen - 4 }
					]
				],
				msg: 'Formatted text into paragraph'
			},
			{
				rangeOrSelection: new ve.Range( 4 ),
				pasteHtml: '<span rel="ve:Alien">Foo</span><b>B</b>a<!-- comment --><b>r</b>',
				pasteSpecial: true,
				expectedRangeOrSelection: new ve.Range( 7 ),
				expectedOps: [
					[
						{ type: 'retain', length: 4 },
						{
							type: 'replace',
							insert: [ 'B', 'a', 'r' ],
							remove: []
						},
						{ type: 'retain', length: docLen - 4 }
					]
				],
				msg: 'Formatted text into paragraph with pasteSpecial'
			},
			{
				rangeOrSelection: new ve.Range( 11 ),
				pasteHtml: '<i>Bar</i>',
				pasteSpecial: true,
				expectedRangeOrSelection: new ve.Range( 14 ),
				expectedOps: [
					[
						{ type: 'retain', length: 11 },
						{
							type: 'replace',
							insert: [ 'B', 'a', 'r' ],
							remove: []
						},
						{ type: 'retain', length: docLen - 11 }
					]
				],
				msg: 'Formatted text into heading with pasteSpecial'
			},
			{
				rangeOrSelection: new ve.Range( 4 ),
				pasteHtml: '<p>Bar</p>',
				expectedRangeOrSelection: {
					gecko: new ve.Range( 11 ),
					default: new ve.Range( 7 )
				},
				expectedOps: {
					gecko: [
						[
							{ type: 'retain', length: 4 },
							{
								type: 'replace',
								insert: [
									{ type: '/paragraph' },
									{ type: 'paragraph' },
									'B', 'a', 'r',
									{ type: '/paragraph' },
									{ type: 'paragraph' }
								],
								remove: []
							},
							{ type: 'retain', length: docLen - 4 }
						]
					],
					default: [
						[
							{ type: 'retain', length: 4 },
							{
								type: 'replace',
								insert: [ 'B', 'a', 'r' ],
								remove: []
							},
							{ type: 'retain', length: docLen - 4 }
						]
					]
				},
				msg: 'Paragraph into paragraph'
			},
			{
				rangeOrSelection: new ve.Range( 6 ),
				pasteHtml: '<p>Bar</p>',
				expectedRangeOrSelection: {
					gecko: new ve.Range( 6 ),
					default: new ve.Range( 9 )
				},
				expectedOps: {
					gecko: [
						[
							{ type: 'retain', length: 7 },
							{
								type: 'replace',
								insert: [
									{ type: 'paragraph' },
									'B', 'a', 'r',
									{ type: '/paragraph' }
								],
								remove: []
							},
							{ type: 'retain', length: docLen - 7 }
						]
					],
					default: [
						[
							{ type: 'retain', length: 6 },
							{
								type: 'replace',
								insert: [ 'B', 'a', 'r' ],
								remove: []
							},
							{ type: 'retain', length: docLen - 6 }
						]
					]
				},
				msg: 'Paragraph at end of paragraph'
			},
			{
				rangeOrSelection: new ve.Range( 3 ),
				pasteHtml: '<p>Bar</p>',
				expectedRangeOrSelection: {
					gecko: new ve.Range( 8 ),
					default: new ve.Range( 6 )
				},
				expectedOps: {
					gecko: [
						[
							{ type: 'retain', length: 3 },
							{
								type: 'replace',
								insert: [
									'B', 'a', 'r',
									{ type: '/paragraph' },
									{ type: 'paragraph' }
								],
								remove: []
							},
							{ type: 'retain', length: docLen - 3 }
						]
					],
					default: [
						[
							{ type: 'retain', length: 3 },
							{
								type: 'replace',
								insert: [ 'B', 'a', 'r' ],
								remove: []
							},
							{ type: 'retain', length: docLen - 3 }
						]
					]
				},
				msg: 'Paragraph at start of paragraph'
			},
			{
				rangeOrSelection: new ve.Range( 11 ),
				pasteHtml: '<h2>Quux</h2>',
				expectedRangeOrSelection: {
					gecko: new ve.Range( 11 ),
					default: new ve.Range( 15 )
				},
				expectedOps: {
					gecko: [
						[
							{ type: 'retain', length: 12 },
							{
								type: 'replace',
								insert: [
									{ type: 'heading', attributes: { level: 2 } },
									'Q', 'u', 'u', 'x',
									{ type: '/heading' }
								],
								remove: []
							},
							{ type: 'retain', length: docLen - 12 }
						]
					],
					default: [
						[
							{ type: 'retain', length: 11 },
							{
								type: 'replace',
								insert: [ 'Q', 'u', 'u', 'x' ],
								remove: []
							},
							{ type: 'retain', length: docLen - 11 }
						]
					]
				},
				msg: 'Heading into heading with whitespace'
			},
			{
				rangeOrSelection: new ve.Range( 17 ),
				pasteHtml: 'Foo',
				expectedRangeOrSelection: new ve.Range( 20 ),
				expectedOps: [
					[
						{ type: 'retain', length: 17 },
						{
							type: 'replace',
							insert: [ 'F', 'o', 'o' ],
							remove: []
						},
						{ type: 'retain', length: docLen - 17 }
					]
				],
				msg: 'Text into wrapper paragraph'
			},
			{
				rangeOrSelection: new ve.Range( 4 ),
				pasteHtml: '☂foo☀',
				expectedRangeOrSelection: new ve.Range( 9 ),
				expectedOps: [
					[
						{ type: 'retain', length: 4 },
						{
							type: 'replace',
							insert: [ '☂', 'f', 'o', 'o', '☀' ],
							remove: []
						},
						{ type: 'retain', length: docLen - 4 }
					]
				],
				msg: 'Left/right placeholder characters'
			},
			{
				rangeOrSelection: new ve.Range( 6 ),
				pasteHtml: '<ul><li>Foo</li></ul>',
				expectedRangeOrSelection: new ve.Range( 16 ),
				expectedOps: [
					[
						{ type: 'retain', length: 7 },
						{
							type: 'replace',
							insert: [
								{ type: 'list', attributes: { style: 'bullet' } },
								{ type: 'listItem' },
								{ type: 'paragraph', internal: { generated: 'wrapper' } },
								'F', 'o', 'o',
								{ type: '/paragraph' },
								{ type: '/listItem' },
								{ type: '/list' }
							],
							remove: []
						},
						{ type: 'retain', length: docLen - 7 }
					]
				],
				msg: 'List at end of paragraph (moves insertion point)'
			},
			{
				rangeOrSelection: new ve.Range( 4 ),
				pasteHtml: '<table><caption>Foo</caption><tr><td>Bar</td></tr></table>',
				expectedRangeOrSelection: new ve.Range( 26 ),
				expectedOps: [
					[
						{ type: 'retain', length: 4 },
						{
							type: 'replace',
							insert: [
								{ type: '/paragraph' },
								{ type: 'table' },
								{ type: 'tableCaption' },
								{ type: 'paragraph', internal: { generated: 'wrapper' } },
								'F', 'o', 'o',
								{ type: '/paragraph' },
								{ type: '/tableCaption' },
								{ type: 'tableSection', attributes: { style: 'body' } },
								{ type: 'tableRow' },
								{ type: 'tableCell', attributes: { style: 'data' } },
								{ type: 'paragraph', internal: { generated: 'wrapper' } },
								'B', 'a', 'r',
								{ type: '/paragraph' },
								{ type: '/tableCell' },
								{ type: '/tableRow' },
								{ type: '/tableSection' },
								{ type: '/table' },
								{ type: 'paragraph' }
							],
							remove: []
						},
						{ type: 'retain', length: docLen - 4 }
					]
				],
				msg: 'Table with caption into paragraph'
			},
			{
				rangeOrSelection: new ve.Range( 0 ),
				pasteHtml:
					'<p about="ignored" class="i" ' +
						'data-ve-attributes="{&quot;typeof&quot;:&quot;h&quot;,&quot;rev&quot;:&quot;g&quot;,' +
						'&quot;resource&quot;:&quot;f&quot;,&quot;rel&quot;:&quot;e&quot;,&quot;property&quot;:&quot;d&quot;,' +
						'&quot;datatype&quot;:&quot;c&quot;,&quot;content&quot;:&quot;b&quot;,&quot;about&quot;:&quot;a&quot;}">' +
						'Foo' +
					'</p>',
				useClipboardData: true,
				expectedRangeOrSelection: new ve.Range( 5 ),
				expectedOps: [
					[
						{
							type: 'replace',
							insert: ve.dm.example.removeOriginalDomElements( ve.dm.example.RDFaDoc.data.data.slice( 0, 5 ) ),
							remove: []
						},
						{ type: 'retain', length: docLen }
					]
				],
				msg: 'RDFa attributes restored/overwritten from data-ve-attributes'
			},
			{
				rangeOrSelection: new ve.Range( 1 ),
				documentHtml: '<p></p>',
				pasteHtml:
					'<span class="ve-pasteProtect" id="meaningful">F</span>' +
					'<span class="ve-pasteProtect" style="color: red;">o</span>' +
					'<span class="ve-pasteProtect meaningful">o</span>',
				fromVe: true,
				expectedRangeOrSelection: new ve.Range( 4 ),
				expectedOps: [
					[
						{ type: 'retain', length: 1 },
						{
							type: 'replace',
							insert: [
								[ 'F', [ { type: 'textStyle/span', attributes: { nodeName: 'span' } } ] ],
								'o',
								[ 'o', [ { type: 'textStyle/span', attributes: { nodeName: 'span' } } ] ]
							],
							remove: []
						},
						{ type: 'retain', length: 3 }
					]
				],
				expectedHtml:
					'<p>' +
						'<span id="meaningful">F</span>' +
						'o' +
						'<span class="meaningful">o</span>' +
					'</p>',
				msg: 'Span cleanups: only meaningful attributes kept'
			},
			{
				rangeOrSelection: new ve.Range( 0 ),
				pasteHtml: 'foo\n<!-- StartFragment --><p>Bar</p><!--EndFragment-->baz',
				useClipboardData: true,
				expectedRangeOrSelection: new ve.Range( 5 ),
				expectedOps: [
					[
						{
							type: 'replace',
							insert: [
								{ type: 'paragraph' },
								'B', 'a', 'r',
								{ type: '/paragraph' }
							],
							remove: []
						},
						{ type: 'retain', length: docLen }
					]
				],
				msg: 'Start/EndFragment comments trimmed from clipboardData'
			},
			{
				rangeOrSelection: new ve.Range( 1 ),
				documentHtml: '<p></p>',
				pasteHtml: '<blockquote><div rel="ve:Alien"><p>Foo</p><div><br></div></div></blockquote>',
				expectedOps: [],
				expectedRangeOrSelection: new ve.Range( 1 ),
				msg: 'Pasting block content that is fully stripped does nothing'
			},
			{
				rangeOrSelection: new ve.Range( 1 ),
				pasteHtml: '<b>Foo</b>',
				pasteTargetHtml: '<p>Foo</p>',
				fromVe: true,
				expectedOps: [
					[
						{ type: 'retain', length: 1 },
						{
							type: 'replace',
							insert: [
								'F', 'o', 'o'
							],
							remove: []
						},
						{ type: 'retain', length: docLen - 1 }
					]
				],
				expectedRangeOrSelection: new ve.Range( 4 ),
				msg: 'Paste target HTML used if nothing important dropped'
			},
			{
				rangeOrSelection: new ve.Range( 1 ),
				pasteHtml: '<span rel="ve:Alien">Alien</span>',
				pasteTargetHtml: '<p><span>Alien</span></p>',
				fromVe: true,
				expectedOps: [
					[
						{
							type: 'replace',
							insert: [
								{ type: 'paragraph', internal: { generated: 'wrapper' } },
								{ type: 'alienInline' },
								{ type: '/alienInline' },
								{ type: '/paragraph' }
							],
							remove: []
						},
						{ type: 'retain', length: docLen }
					]
				],
				expectedRangeOrSelection: new ve.Range( 4 ),
				msg: 'Paste API HTML used if important attributes dropped'
			},
			{
				rangeOrSelection: {
					type: 'table',
					tableRange: new ve.Range( 12, 22 ),
					fromCol: 0,
					fromRow: 0
				},
				pasteHtml: '<p>A</p>',
				fromVe: true,
				expectedRangeOrSelection: {
					type: 'table',
					tableRange: new ve.Range( 12, 23 ),
					fromCol: 0,
					fromRow: 0
				},
				expectedOps: [
					[
						{ type: 'retain', length: 16 },
						{
							type: 'replace',
							insert: [],
							remove: [
								{ type: 'paragraph', internal: { generated: 'empty' } },
								{ type: '/paragraph' }
							]
						},
						{ type: 'retain', length: docLen - 18 }
					],
					[
						{ type: 'retain', length: 16 },
						{
							type: 'replace',
							insert: [
								{ type: 'paragraph' },
								'A',
								{ type: '/paragraph' }
							],
							remove: []
						},
						{ type: 'retain', length: docLen - 18 }
					]
				],
				msg: 'Paste paragraph into table cell'
			},
			{
				rangeOrSelection: {
					type: 'table',
					tableRange: new ve.Range( 12, 22 ),
					fromCol: 0,
					fromRow: 0
				},
				pasteHtml: '<table><tbody><tr><td>X</td></tr></table>',
				fromVe: true,
				expectedRangeOrSelection: {
					type: 'table',
					tableRange: new ve.Range( 12, 23 ),
					fromCol: 0,
					fromRow: 0
				},
				expectedOps: [
					[
						{ type: 'retain', length: 16 },
						{
							type: 'replace',
							insert: [],
							remove: [
								{ type: 'paragraph', internal: { generated: 'empty' } },
								{ type: '/paragraph' }
							]
						},
						{ type: 'retain', length: docLen - 18 }
					],
					[
						{ type: 'retain', length: 16 },
						{
							type: 'replace',
							insert: [
								{ type: 'paragraph', internal: { generated: 'wrapper' } },
								'X',
								{ type: '/paragraph' }
							],
							remove: []
						},
						{ type: 'retain', length: docLen - 18 }
					]
				],
				msg: 'Paste table cell onto table cell'
			},
			{
				rangeOrSelection: {
					type: 'table',
					tableRange: new ve.Range( 12, 22 ),
					fromCol: 0,
					fromRow: 0
				},
				pasteHtml: '<table><tbody><tr><th>X</th></tr></table>',
				fromVe: true,
				expectedRangeOrSelection: {
					type: 'table',
					tableRange: new ve.Range( 12, 23 ),
					fromCol: 0,
					fromRow: 0
				},
				expectedOps: [
					[
						{ type: 'retain', length: 16 },
						{
							type: 'replace',
							insert: [],
							remove: [
								{ type: 'paragraph', internal: { generated: 'empty' } },
								{ type: '/paragraph' }
							]
						},
						{ type: 'retain', length: docLen - 18 }
					],
					[
						{ type: 'retain', length: 15 },
						{ type: 'attribute', key: 'style', from: 'data', to: 'header' },
						{ type: 'retain', length: docLen - 17 }
					],
					[
						{ type: 'retain', length: 16 },
						{
							type: 'replace',
							insert: [
								{ type: 'paragraph', internal: { generated: 'wrapper' } },
								'X',
								{ type: '/paragraph' }
							],
							remove: []
						},
						{ type: 'retain', length: docLen - 18 }
					]
				],
				msg: 'Paste table header cell onto table cell'
			},
			{
				rangeOrSelection: {
					type: 'table',
					tableRange: new ve.Range( 12, 22 ),
					fromCol: 0,
					fromRow: 0
				},
				pasteHtml: '<table><tbody><tr><td>X</td><td>Y</td><td>Z</td></tr></table>',
				fromVe: true,
				expectedRangeOrSelection: {
					type: 'table',
					tableRange: new ve.Range( 12, 33 ),
					fromCol: 0,
					fromRow: 0,
					toCol: 2,
					toRow: 0
				},
				expectedOps: [
					[
						{ type: 'retain', length: 19 },
						{
							insert: [
								{
									attributes: {
										colspan: 1,
										rowspan: 1,
										style: 'data'
									},
									type: 'tableCell'
								},
								{
									internal: {
										generated: 'wrapper'
									},
									type: 'paragraph'
								},
								{ type: '/paragraph' },
								{
									type: '/tableCell'
								}
							],
							insertedDataLength: 4,
							insertedDataOffset: 0,
							remove: [],
							type: 'replace'
						},
						{ type: 'retain', length: docLen - 19 }
					],
					[
						{ type: 'retain', length: 23 },
						{
							type: 'replace',
							insert: [
								{
									attributes: {
										colspan: 1,
										rowspan: 1,
										style: 'data'
									},
									type: 'tableCell'
								},
								{
									internal: {
										generated: 'wrapper'
									},
									type: 'paragraph'
								},
								{ type: '/paragraph' },
								{
									type: '/tableCell'
								}
							],
							insertedDataLength: 4,
							insertedDataOffset: 0,
							remove: []
						},
						{ type: 'retain', length: docLen - 19 }
					],
					[
						{ type: 'retain', length: 24 },
						{
							insert: [],
							remove: [
								{
									internal: {
										generated: 'wrapper'
									},
									type: 'paragraph'
								},
								{ type: '/paragraph' }
							],
							type: 'replace'
						},
						{ type: 'retain', length: docLen - 18 }
					],
					[
						{ type: 'retain', length: 24 },
						{
							insert: [
								{
									internal: {
										generated: 'wrapper'
									},
									type: 'paragraph'
								},
								'Z',
								{ type: '/paragraph' }
							],
							remove: [],
							type: 'replace'
						},
						{ type: 'retain', length: docLen - 18 }
					],
					[
						{ type: 'retain', length: 20 },
						{
							insert: [],
							remove: [
								{
									internal: {
										generated: 'wrapper'
									},
									type: 'paragraph'
								},
								{ type: '/paragraph' }
							],
							type: 'replace'
						},
						{ type: 'retain', length: docLen - 13 }
					],
					[
						{ type: 'retain', length: 20 },
						{
							insert: [
								{
									internal: {
										generated: 'wrapper'
									},
									type: 'paragraph'
								},
								'Y',
								{ type: '/paragraph' }
							],
							remove: [],
							type: 'replace'
						},
						{ type: 'retain', length: docLen - 13 }
					],
					[
						{ type: 'retain', length: 16 },
						{
							insert: [],
							remove: [
								{
									internal: {
										generated: 'empty'
									},
									type: 'paragraph'
								},
								{ type: '/paragraph' }
							],
							type: 'replace'
						},
						{ type: 'retain', length: docLen - 8 }
					],
					[
						{ type: 'retain', length: 16 },
						{
							insert: [
								{
									internal: {
										generated: 'wrapper'
									},
									type: 'paragraph'
								},
								'X',
								{ type: '/paragraph' }
							],
							remove: [],
							type: 'replace'
						},
						{ type: 'retain', length: docLen - 8 }
					]
				],
				msg: 'Paste row of table cells onto table cell'
			},
			{
				rangeOrSelection: {
					type: 'table',
					tableRange: new ve.Range( 12, 22 ),
					fromCol: 0,
					fromRow: 0
				},
				pasteHtml: '<table><tbody><tr><td>X</td></tr><tr><td>Y</td></tr><tr><td>Z</td></tr></table>',
				fromVe: true,
				expectedRangeOrSelection: {
					type: 'table',
					tableRange: new ve.Range( 12, 37 ),
					fromCol: 0,
					fromRow: 0,
					toCol: 0,
					toRow: 2
				},
				expectedOps: [
					[
						{ type: 'retain', length: 20 },
						{
							insert: [
								{ type: 'tableRow' },
								{
									attributes: {
										colspan: 1,
										rowspan: 1,
										style: 'data'
									},
									type: 'tableCell'
								},
								{
									internal: {
										generated: 'wrapper'
									},
									type: 'paragraph'
								},
								{ type: '/paragraph' },
								{ type: '/tableCell' },
								{ type: '/tableRow' }
							],
							insertedDataLength: 6,
							insertedDataOffset: 0,
							remove: [],
							type: 'replace'
						},
						{ type: 'retain', length: docLen - 20 }
					],
					[
						{ type: 'retain', length: 26 },
						{
							type: 'replace',
							insert: [
								{ type: 'tableRow' },
								{
									attributes: {
										colspan: 1,
										rowspan: 1,
										style: 'data'
									},
									type: 'tableCell'
								},
								{
									internal: {
										generated: 'wrapper'
									},
									type: 'paragraph'
								},
								{ type: '/paragraph' },
								{ type: '/tableCell' },
								{ type: '/tableRow' }
							],
							insertedDataLength: 6,
							insertedDataOffset: 0,
							remove: []
						},
						{ type: 'retain', length: docLen - 20 }
					],
					[
						{ type: 'retain', length: 28 },
						{
							insert: [],
							remove: [
								{
									internal: {
										generated: 'wrapper'
									},
									type: 'paragraph'
								},
								{ type: '/paragraph' }
							],
							type: 'replace'
						},
						{ type: 'retain', length: docLen - 18 }
					],
					[
						{ type: 'retain', length: 28 },
						{
							insert: [
								{
									internal: {
										generated: 'wrapper'
									},
									type: 'paragraph'
								},
								'Z',
								{ type: '/paragraph' }
							],
							remove: [],
							type: 'replace'
						},
						{ type: 'retain', length: docLen - 18 }
					],
					[
						{ type: 'retain', length: 22 },
						{
							insert: [],
							remove: [
								{
									internal: {
										generated: 'wrapper'
									},
									type: 'paragraph'
								},
								{ type: '/paragraph' }
							],
							type: 'replace'
						},
						{ type: 'retain', length: docLen - 11 }
					],
					[
						{ type: 'retain', length: 22 },
						{
							insert: [
								{
									internal: {
										generated: 'wrapper'
									},
									type: 'paragraph'
								},
								'Y',
								{ type: '/paragraph' }
							],
							remove: [],
							type: 'replace'
						},
						{ type: 'retain', length: docLen - 11 }
					],
					[
						{ type: 'retain', length: 16 },
						{
							insert: [],
							remove: [
								{
									internal: {
										generated: 'empty'
									},
									type: 'paragraph'
								},
								{ type: '/paragraph' }
							],
							type: 'replace'
						},
						{ type: 'retain', length: docLen - 4 }
					],
					[
						{ type: 'retain', length: 16 },
						{
							insert: [
								{
									internal: {
										generated: 'wrapper'
									},
									type: 'paragraph'
								},
								'X',
								{ type: '/paragraph' }
							],
							remove: [],
							type: 'replace'
						},
						{ type: 'retain', length: docLen - 4 }
					]
				],
				msg: 'Paste column of table cells onto table cell'
			},
			{
				rangeOrSelection: {
					type: 'table',
					tableRange: new ve.Range( 12, 22 ),
					fromCol: 0,
					fromRow: 0
				},
				pasteHtml: '<p>Foo</p><table><tbody><tr><td>X</td></tr></table><p>Bar</p>',
				fromVe: true,
				expectedRangeOrSelection: {
					type: 'table',
					tableRange: new ve.Range( 12, 41 ),
					fromCol: 0,
					fromRow: 0
				},
				expectedOps: [
					[
						{ type: 'retain', length: 16 },
						{
							insert: [],
							remove: [
								{
									internal: {
										generated: 'empty'
									},
									type: 'paragraph'
								},
								{ type: '/paragraph' }
							],
							type: 'replace'
						},
						{ type: 'retain', length: docLen - 18 }
					],
					[
						{ type: 'retain', length: 16 },
						{
							type: 'replace',
							insert: [
								{ type: 'paragraph' },
								'F', 'o', 'o',
								{ type: '/paragraph' },
								{ type: 'table' },
								{
									type: 'tableSection',
									attributes: {
										style: 'body'
									}
								},
								{ type: 'tableRow' },
								{
									type: 'tableCell',
									attributes: {
										style: 'data'
									}
								},
								{
									type: 'paragraph',
									internal: {
										generated: 'wrapper'
									}
								},
								'X',
								{ type: '/paragraph' },
								{ type: '/tableCell' },
								{ type: '/tableRow' },
								{ type: '/tableSection' },
								{ type: '/table' },
								{ type: 'paragraph' },
								'B', 'a', 'r',
								{ type: '/paragraph' }
							],
							remove: []
						},
						{ type: 'retain', length: docLen - 18 }
					]
				],
				msg: 'Paste paragraphs and a table into table cell'
			},
			{
				documentHtml: '<p></p>',
				rangeOrSelection: new ve.Range( 1 ),
				pasteHtml: '<img src="null" id="mwAB"><img src="null" id="useful">',
				fromVe: true,
				expectedRangeOrSelection: new ve.Range( 5 ),
				expectedHtml: '<p><img src="null"><img src="null" id="useful"></p>',
				msg: 'Parsoid IDs stripped'
			},
			{
				rangeOrSelection: new ve.Range( 0 ),
				pasteHtml: '<ul><li>A</li><ul><li>B</li></ul></ul>',
				expectedRangeOrSelection: new ve.Range( 14 ),
				expectedOps: [
					[
						{
							type: 'replace',
							insert: [
								{ type: 'list', attributes: { style: 'bullet' } },
								{ type: 'listItem' },
								{ type: 'paragraph', internal: { generated: 'wrapper' } },
								'A',
								{ type: '/paragraph' },
								{ type: 'list', attributes: { style: 'bullet' } },
								{ type: 'listItem' },
								{ type: 'paragraph', internal: { generated: 'wrapper' } },
								'B',
								{ type: '/paragraph' },
								{ type: '/listItem' },
								{ type: '/list' },
								{ type: '/listItem' },
								{ type: '/list' }
							],
							remove: []
						},
						{ type: 'retain', length: docLen }
					]
				],
				msg: 'Broken nested lists (Google Docs style)'
			},
			{
				rangeOrSelection: new ve.Range( 0 ),
				// Write directly to paste target because using execCommand kills one of the <ul>s
				pasteTargetHtml: 'A<ul><ul><li>B</li></ul></ul>C',
				expectedRangeOrSelection: new ve.Range( 17 ),
				expectedOps: [
					[
						{
							type: 'replace',
							insert: [
								{ type: 'paragraph', internal: { generated: 'wrapper' } },
								'A',
								{ type: '/paragraph' },
								{ type: 'list', attributes: { style: 'bullet' } },
								{ type: 'listItem' },
								{ type: 'list', attributes: { style: 'bullet' } },
								{ type: 'listItem' },
								{ type: 'paragraph', internal: { generated: 'wrapper' } },
								'B',
								{ type: '/paragraph' },
								{ type: '/listItem' },
								{ type: '/list' },
								{ type: '/listItem' },
								{ type: '/list' },
								{ type: 'paragraph', internal: { generated: 'wrapper' } },
								'C',
								{ type: '/paragraph' }
							],
							remove: []
						},
						{ type: 'retain', length: docLen }
					]
				],
				msg: 'Double indented lists (Google Docs style)'
			},
			{
				rangeOrSelection: new ve.Range( 0 ),
				pasteTargetHtml: '<p>A</p><p></p><p>B</p>',
				expectedOps: [
					[
						{
							type: 'replace',
							insert: [
								{ type: 'paragraph' },
								'A',
								{ type: '/paragraph' },
								{ type: 'paragraph' },
								'B',
								{ type: '/paragraph' }
							],
							remove: []
						},
						{ type: 'retain', length: docLen }
					]
				],
				msg: 'Empty paragraph stripped from external paste'
			},
			{
				rangeOrSelection: new ve.Range( 8 ),
				documentHtml: '<p>A</p><p></p><p>B</p>',
				internalSourceRangeOrSelection: new ve.Range( 0, 8 ),
				expectedOps: [
					[
						{ type: 'retain', length: 8 },
						{
							type: 'replace',
							insert: [
								{ type: 'paragraph' },
								'A',
								{ type: '/paragraph' },
								{ type: 'paragraph' },
								{ type: '/paragraph' },
								{ type: 'paragraph' },
								'B',
								{ type: '/paragraph' }
							],
							insertedDataLength: 8,
							insertedDataOffset: 0,
							remove: []
						},
						{ type: 'retain', length: 2 }
					]
				],
				msg: 'Empty paragraph kept in internal paste'
			}
		];

	for ( i = 0; i < cases.length; i++ ) {
		if ( cases[ i ].expectedOps ) {
			expected++;
		}
		if ( cases[ i ].expectedRangeOrSelection ) {
			expected++;
		}
		if ( cases[ i ].expectedHtml ) {
			expected++;
		}
	}
	QUnit.expect( expected );

	function testRunner( documentHtml, pasteHtml, internalSourceRangeOrSelection, fromVe, useClipboardData, pasteTargetHtml, rangeOrSelection, pasteSpecial, expectedOps, expectedRangeOrSelection, expectedHtml, store, msg ) {
		var i, j, txs, ops, txops, htmlDoc, expectedSelection, testEvent,
			e = {},
			view = documentHtml ? ve.test.utils.createSurfaceViewFromHtml( documentHtml ) : exampleSurface,
			model = view.getModel(),
			doc = model.getDocument();

		function summary( el ) {
			return ve.getDomElementSummary( el, true );
		}

		function getLayoutSpecific( expected ) {
			if ( $.isPlainObject( expected ) && !expected.type ) {
				return expected[ layout ] || expected.default;
			}
			return expected;
		}

		// Paste sequence
		if ( internalSourceRangeOrSelection ) {
			model.setSelection( ve.test.utils.selectionFromRangeOrSelection( model.getDocument(), internalSourceRangeOrSelection ) );
			testEvent = new ve.test.utils.TestEvent();
			view.onCopy( testEvent );
		} else {
			if ( useClipboardData ) {
				e[ 'text/html' ] = pasteHtml;
				e[ 'text/xcustom' ] = 'useClipboardData-0';
			} else if ( fromVe ) {
				e[ 'text/html' ] = pasteHtml;
				e[ 'text/xcustom' ] = '0.123-0';
			}
			testEvent = new ve.test.utils.TestEvent( e );
		}
		model.setSelection( ve.test.utils.selectionFromRangeOrSelection( model.getDocument(), rangeOrSelection ) );
		view.pasteSpecial = pasteSpecial;
		view.beforePaste( testEvent );
		if ( pasteTargetHtml ) {
			view.$pasteTarget.html( pasteTargetHtml );
		} else {
			document.execCommand( 'insertHTML', false, pasteHtml );
		}
		view.afterPaste( testEvent );

		if ( expectedOps ) {
			expectedOps = getLayoutSpecific( expectedOps );
			ops = [];
			if ( model.getHistory().length ) {
				txs = model.getHistory()[ 0 ].transactions;
				for ( i = 0; i < txs.length; i++ ) {
					txops = ve.copy( txs[ i ].getOperations() );
					for ( j = 0; j < txops.length; j++ ) {
						if ( txops[ j ].remove ) {
							ve.dm.example.postprocessAnnotations( txops[ j ].remove, doc.getStore() );
							ve.dm.example.removeOriginalDomElements( txops[ j ].remove );
						}
						if ( txops[ j ].insert ) {
							ve.dm.example.postprocessAnnotations( txops[ j ].insert, doc.getStore() );
							ve.dm.example.removeOriginalDomElements( txops[ j ].insert );
						}
					}
					ops.push( txops );
				}
			}
			assert.equalLinearData( ops, expectedOps, msg + ': data' );
			if ( store ) {
				for ( i in store ) {
					assert.deepEqual( doc.getStore().value( i ).map( summary ), store[ i ].map( summary ), ': store value ' + i );
				}
			}
		}
		if ( expectedRangeOrSelection ) {
			expectedSelection = ve.test.utils.selectionFromRangeOrSelection( model.getDocument(), getLayoutSpecific( expectedRangeOrSelection ) );
			assert.equalHash( model.getSelection(), expectedSelection, msg +  ': selection' );
		}
		if ( expectedHtml ) {
			htmlDoc = ve.dm.converter.getDomFromModel( doc );
			assert.strictEqual( htmlDoc.body.innerHTML, expectedHtml, msg + ': HTML' );
		}
		if ( view === exampleSurface ) {
			while ( model.hasBeenModified() ) {
				model.undo();
			}
			model.truncateUndoStack();
		} else {
			view.destroy();
		}
	}

	for ( i = 0; i < cases.length; i++ ) {
		testRunner(
			cases[ i ].documentHtml, cases[ i ].pasteHtml, cases[ i ].internalSourceRangeOrSelection, cases[ i ].fromVe, cases[ i ].useClipboardData,
			cases[ i ].pasteTargetHtml, cases[ i ].rangeOrSelection, cases[ i ].pasteSpecial,
			cases[ i ].expectedOps, cases[ i ].expectedRangeOrSelection, cases[ i ].expectedHtml,
			cases[ i ].store, cases[ i ].msg
		);
	}

	exampleSurface.destroy();

} );

QUnit.test( 'special key down: table arrow keys', function ( assert ) {
	var i, offsets, expectedSelectionOffsets, selection, table, view, model,
		fn = function () {},
		tables = {
			mergedCells: {
				view: ve.test.utils.createSurfaceViewFromDocument(
					ve.dm.example.createExampleDocument( 'mergedCells' )
				),
				tableRange: new ve.Range( 0, 171 )
			},
			rtl: {
				view: ve.test.utils.createSurfaceViewFromHtml(
					'<table style="direction: rtl;">' +
						'<tr><td>1</td><td>2</td></tr>' +
						'<tr><td>3</td><td>4</td></tr>' +
					'</table>'
				),
				tableRange: new ve.Range( 0, 28 )
			}
		},
		cases = [
			{
				msg: 'Simple move right',
				key: 'RIGHT',
				selectionOffsets: [ 0, 0 ],
				expectedSelectionOffsets: [ 1, 0 ]
			},
			{
				msg: 'Simple move right with tab',
				key: 'TAB',
				selectionOffsets: [ 0, 0 ],
				expectedSelectionOffsets: [ 1, 0 ]
			},
			{
				msg: 'Move right with tab at end wraps to next line',
				key: 'TAB',
				selectionOffsets: [ 5, 0 ],
				expectedSelectionOffsets: [ 0, 1 ]
			},
			{
				msg: 'Simple move end',
				key: 'END',
				selectionOffsets: [ 0, 0 ],
				expectedSelectionOffsets: [ 5, 0 ]
			},
			{
				msg: 'Simple move down',
				key: 'DOWN',
				selectionOffsets: [ 0, 0 ],
				expectedSelectionOffsets: [ 0, 1 ]
			},
			{
				msg: 'Simple move page down',
				key: 'PAGEDOWN',
				selectionOffsets: [ 0, 0 ],
				expectedSelectionOffsets: [ 0, 6 ]
			},
			{
				msg: 'Simple move left',
				key: 'LEFT',
				selectionOffsets: [ 5, 6 ],
				expectedSelectionOffsets: [ 4, 6 ]
			},
			{
				msg: 'Simple move left with shift+tab',
				key: 'TAB',
				shiftKey: true,
				selectionOffsets: [ 5, 6 ],
				expectedSelectionOffsets: [ 4, 6 ]
			},
			{
				msg: 'Move left with shift+tab at start wraps to previous line',
				key: 'TAB',
				shiftKey: true,
				selectionOffsets: [ 0, 1 ],
				expectedSelectionOffsets: [ 5, 0 ]
			},
			{
				msg: 'Simple move home',
				key: 'HOME',
				selectionOffsets: [ 5, 6 ],
				expectedSelectionOffsets: [ 0, 6 ]
			},
			{
				msg: 'Simple move page up',
				key: 'PAGEUP',
				selectionOffsets: [ 5, 6 ],
				expectedSelectionOffsets: [ 5, 0 ]
			},
			{
				msg: 'Move left at start',
				key: 'LEFT',
				selectionOffsets: [ 0, 0 ],
				expectedSelectionOffsets: [ 0, 0 ]
			},
			{
				msg: 'Move right at end',
				key: 'RIGHT',
				selectionOffsets: [ 5, 6 ],
				expectedSelectionOffsets: [ 5, 6 ]
			},
			{
				msg: 'Move from merged cell to merged cell',
				key: 'RIGHT',
				selectionOffsets: [ 1, 1, 2, 1 ],
				expectedSelectionOffsets: [ 3, 0, 3, 2 ]
			},
			{
				msg: 'Shift-select through merged cells',
				key: 'PAGEDOWN',
				shiftKey: true,
				selectionOffsets: [ 1, 0, 1, 0 ],
				expectedSelectionOffsets: [ 1, 0, 3, 6 ]
			},
			{
				msg: 'Expanded selection collapses',
				key: 'DOWN',
				selectionOffsets: [ 0, 0, 2, 0 ],
				expectedSelectionOffsets: [ 0, 1 ]
			},
			{
				msg: 'Left in RTL table increments column',
				table: 'rtl',
				key: 'LEFT',
				selectionOffsets: [ 0, 0 ],
				expectedSelectionOffsets: [ 1, 0 ]
			}
		];

	QUnit.expect( cases.length );

	for ( i = 0; i < cases.length; i++ ) {
		offsets = cases[ i ].selectionOffsets;
		table = tables[ cases[ i ].table || 'mergedCells' ];
		view = table.view;
		model = view.getModel();
		model.setSelection( new ve.dm.TableSelection(
			model.getDocument(), table.tableRange, offsets[ 0 ], offsets[ 1 ], offsets[ 2 ], offsets[ 3 ] )
		);
		ve.ce.keyDownHandlerFactory.executeHandlersForKey(
			OO.ui.Keys[ cases[ i ].key ], model.getSelection().getName(), view,
			{
				keyCode: OO.ui.Keys[ cases[ i ].key ],
				shiftKey: !!cases[ i ].shiftKey,
				preventDefault: fn,
				stopPropagation: fn
			}
		);
		selection = model.getSelection();
		expectedSelectionOffsets = cases[ i ].expectedSelectionOffsets.length > 2 ?
			cases[ i ].expectedSelectionOffsets :
			cases[ i ].expectedSelectionOffsets.concat( cases[ i ].expectedSelectionOffsets );
		assert.deepEqual(
			[ selection.fromCol, selection.fromRow, selection.toCol, selection.toRow ],
			expectedSelectionOffsets,
			cases[ i ].msg
		);
	}
} );

QUnit.test( 'onDocumentDragStart/onDocumentDrop', function ( assert ) {

	var i,
		selection = new ve.dm.LinearSelection( {}, new ve.Range( 1, 4 ) ),
		expectedSelection = new ve.dm.LinearSelection( {}, new ve.Range( 7, 10 ) ),
		cases = [
			{
				msg: 'Simple drag and drop',
				rangeOrSelection: new ve.Range( 1, 4 ),
				targetOffset: 10,
				expectedTransfer: { 'application-x/VisualEditor': JSON.stringify( selection ) },
				expectedData: function ( data ) {
					var removed = data.splice( 1, 3 );
					data.splice.apply( data, [ 7, 0 ].concat( removed ) );
				},
				expectedSelection: expectedSelection
			},
			{
				msg: 'Simple drag and drop in IE',
				rangeOrSelection: new ve.Range( 1, 4 ),
				targetOffset: 10,
				isIE: true,
				expectedTransfer: { text: '__ve__' + JSON.stringify( selection ) },
				expectedData: function ( data ) {
					var removed = data.splice( 1, 3 );
					data.splice.apply( data, [ 7, 0 ].concat( removed ) );
				},
				expectedSelection: expectedSelection
			},
			{
				msg: 'Invalid target offset',
				rangeOrSelection: new ve.Range( 1, 4 ),
				targetOffset: -1,
				expectedTransfer: { 'application-x/VisualEditor': JSON.stringify( selection ) },
				expectedData: function () {},
				expectedSelection: selection
			}
		];

	QUnit.expect( cases.length * 3 );

	function testRunner( rangeOrSelection, targetOffset, expectedTransfer, expectedData, expectedSelection, isIE, msg ) {
		var view = ve.test.utils.createSurfaceViewFromDocument( ve.dm.example.createExampleDocument() ),
			model = view.getModel(),
			data = ve.copy( model.getDocument().getFullData() ),
			dataTransfer = {},
			mockEvent = {
				originalEvent: {
					dataTransfer: {
						setData: function ( key, value ) {
							if ( isIE && key !== 'text' ) {
								throw new Error( 'IE FAIL' );
							}
							dataTransfer[ key ] = value;
						},
						getData: function ( key ) {
							if ( isIE && key !== 'text' ) {
								throw new Error( 'IE FAIL' );
							}
							return dataTransfer[ key ];
						}
					}
				},
				preventDefault: function () {},
				stopPropagation: function () {}
			};

		// Mock drop coords
		view.getOffsetFromCoords = function () {
			return targetOffset;
		};

		expectedData( data );

		model.setSelection( ve.test.utils.selectionFromRangeOrSelection( model.getDocument(), rangeOrSelection ) );

		view.onDocumentDragStart( mockEvent );
		assert.deepEqual(
			dataTransfer,
			expectedTransfer,
			'dataTransfer data set after drag start'
		);

		view.onDocumentDrop( mockEvent );

		assert.equalLinearData( model.getDocument().getFullData(), data, msg + ': data' );
		assert.equalHash( model.getSelection(), expectedSelection, msg + ': selection' );
		view.destroy();
	}

	for ( i = 0; i < cases.length; i++ ) {
		testRunner(
			cases[ i ].rangeOrSelection, cases[ i ].targetOffset, cases[ i ].expectedTransfer, cases[ i ].expectedData,
			cases[ i ].expectedSelection, cases[ i ].isIE, cases[ i ].msg
		);
	}

} );

QUnit.test( 'getSelectionState', function ( assert ) {
	var i, j, l, view, selection, internalListNode, node, rootElement,
		expect = 0,
		cases = [
			{
				msg: 'Grouped aliens',
				html: '<p>' +
					'Foo' +
					'<span rel="ve:Alien" about="g1">Bar</span>' +
					'<span rel="ve:Alien" about="g1">Baz</span>' +
					'<span rel="ve:Alien" about="g1">Quux</span>' +
					'Whee' +
				'</p>' +
				'<p>' +
					'2<b>n</b>d' +
				'</p>',
				// The offset path of the result of getNodeAndOffset for
				// each offset
				expected: [
					[ 0, 0, 0 ],
					[ 0, 0, 0 ],
					[ 0, 0, 1 ],
					[ 0, 0, 2 ],
					[ 0, 0, 3 ],
					null,
					[ 0, 4, 0 ],
					[ 0, 4, 1 ],
					[ 0, 4, 2 ],
					[ 0, 4, 3 ],
					[ 0, 4, 4 ],
					[ 0, 4, 4 ],
					[ 1, 0, 0 ],
					[ 1, 0, 1 ],
					[ 1, 1, 0, 1 ],
					[ 1, 2, 1 ]
				]
			},
			{
				msg: 'Simple example doc',
				html: ve.dm.example.html,
				expected: ve.dm.example.offsetPaths
			}
		];

	for ( i = 0; i < cases.length; i++ ) {
		expect += cases[ i ].expected.length;
	}

	QUnit.expect( expect );

	for ( i = 0; i < cases.length; i++ ) {
		view = ve.test.utils.createSurfaceViewFromHtml( cases[ i ].html );
		internalListNode = view.getModel().getDocument().getInternalList().getListNode();
		rootElement = view.getDocument().getDocumentNode().$element[ 0 ];
		for ( j = 0, l = internalListNode.getOuterRange().start; j < l; j++ ) {
			node = view.getDocument().getDocumentNode().getNodeFromOffset( j );
			if ( node.isFocusable() ) {
				assert.strictEqual( null, cases[ i ].expected[ j ], 'Focusable node at ' + j );
			} else {
				selection = view.getSelectionState( new ve.Range( j ) );
				assert.deepEqual(
					ve.getOffsetPath( rootElement, selection.anchorNode, selection.anchorOffset ),
					cases[ i ].expected[ j ],
					'Path at ' + j + ' in ' + cases[ i ].msg
				);
			}
		}
		view.destroy();
	}

} );

/* Methods with return values */
// TODO: ve.ce.Surface#getSelection
// TODO: ve.ce.Surface#getSurface
// TODO: ve.ce.Surface#getModel
// TODO: ve.ce.Surface#getDocument
// TODO: ve.ce.Surface#getFocusedNode
// TODO: ve.ce.Surface#isRenderingLocked

/* Methods without return values */
// TODO: ve.ce.Surface#initialize
// TODO: ve.ce.Surface#enable
// TODO: ve.ce.Surface#disable
// TODO: ve.ce.Surface#destroy
// TODO: ve.ce.Surface#focus
// TODO: ve.ce.Surface#onDocumentFocus
// TODO: ve.ce.Surface#onDocumentBlur
// TODO: ve.ce.Surface#onDocumentMouseDown
// TODO: ve.ce.Surface#onDocumentMouseUp
// TODO: ve.ce.Surface#onDocumentMouseMove
// TODO: ve.ce.Surface#onDocumentDragOver
// TODO: ve.ce.Surface#onDocumentDrop
// TODO: ve.ce.Surface#onDocumentKeyDown
// TODO: ve.ce.Surface#onDocumentKeyPress
// TODO: ve.ce.Surface#afterDocumentKeyDown
// TODO: ve.ce.Surface#afterDocumentMouseDown
// TODO: ve.ce.Surface#afterDocumentMouseUp
// TODO: ve.ce.Surface#afterDocumentKeyPress
// TODO: ve.ce.Surface#onDocumentKeyUp
// TODO: ve.ce.Surface#onCut
// TODO: ve.ce.Surface#onPaste
// TODO: ve.ce.Surface#onDocumentCompositionEnd
// TODO: ve.ce.Surface#onChange
// TODO: ve.ce.Surface#onSurfaceObserverSelectionChange
// TODO: ve.ce.Surface#onLock
// TODO: ve.ce.Surface#onUnlock
// TODO: ve.ce.Surface#startRelocation
// TODO: ve.ce.Surface#endRelocation
// TODO: ve.ce.Surface#handleInsertion
// TODO: ve.ce.Surface#showModelSelection
// TODO: ve.ce.Surface#appendHighlights
// TODO: ve.ce.Surface#incRenderLock
// TODO: ve.ce.Surface#decRenderLock

Zerion Mini Shell 1.0