/*!
 * VisualEditor DataModel Transaction tests.
 *
 * @copyright See AUTHORS.txt
 */

QUnit.module( 've.dm.TransactionBuilder' );

/* Helper methods */

ve.test.utils.runTransactionBuilderTests = function ( assert, cases ) {
	cases.forEach( ( caseItem ) => {
		const txBuilder = new ve.dm.TransactionBuilder();
		for ( let i = 0; i < caseItem.calls.length; i++ ) {
			txBuilder[ caseItem.calls[ i ][ 0 ] ]( ...caseItem.calls[ i ].slice( 1 ) );
		}
		assert.deepEqualWithDomElements( txBuilder.getTransaction().getOperations(), caseItem.ops, caseItem.msg + ': operations match' );
	} );
};

ve.test.utils.runTransactionConstructorTests = function ( assert, constructor, cases, testRange ) {
	cases.forEach( ( caseItem ) => {
		const doc = caseItem.args[ 0 ];
		const args = caseItem.args;
		if ( caseItem.ops ) {
			caseItem.ops.forEach( ( op ) => {
				if ( op.remove ) {
					ve.dm.example.preprocessAnnotations( op.remove, doc.getStore() );
				}
				if ( op.insert ) {
					ve.dm.example.preprocessAnnotations( op.insert, doc.getStore() );
				}
			} );
			const tx = constructor( ...args );
			assert.equalLinearDataWithDom( doc.getStore(), tx.getOperations(), caseItem.ops, caseItem.msg + ': operations match' );
			if ( testRange ) {
				assert.equalRange(
					tx.getModifiedRange( doc ),
					caseItem.range || new ve.Range( args[ 1 ], args[ 1 ] + args[ 2 ].length ),
					caseItem.msg + ': range matches'
				);
			}
		} else if ( caseItem.exception ) {
			assert.throws( () => {
				constructor( ...args );
			}, caseItem.exception, caseItem.msg + ': throw exception' );
		}
	} );
};

/* Tests */

QUnit.test( 'newFromInsertion', ( assert ) => {
	const doc = ve.dm.example.createExampleDocument(),
		isolationDoc = ve.dm.example.createExampleDocument( 'isolationData' ),
		complexTableDoc = ve.dm.example.createExampleDocument( 'complexTable' ),
		doc2 = new ve.dm.Document(
			ve.dm.example.preprocessAnnotations( [ { type: 'paragraph' }, { type: '/paragraph' }, { type: 'internalList' }, { type: '/internalList' } ] )
		),
		doc3 = new ve.dm.Document(
			ve.dm.example.preprocessAnnotations( [ { type: 'paragraph' }, ...'Foo', { type: '/paragraph' }, { type: 'internalList' }, { type: '/internalList' } ] )
		),
		cases = [
			{
				msg: 'paragraph before first element',
				args: [ doc, 0, [ { type: 'paragraph' }, '1', { type: '/paragraph' } ] ],
				ops: [
					{
						type: 'replace',
						remove: [],
						insert: [ { type: 'paragraph' }, '1', { type: '/paragraph' } ]
					},
					{ type: 'retain', length: 63 }
				]
			},
			{
				msg: 'paragraph after last element',
				args: [ doc, 61, [ { type: 'paragraph' }, '1', { type: '/paragraph' } ] ],
				ops: [
					{ type: 'retain', length: 61 },
					{
						type: 'replace',
						remove: [],
						insert: [ { type: 'paragraph' }, '1', { type: '/paragraph' } ]
					},
					{ type: 'retain', length: 2 }
				]
			},
			{
				msg: 'split paragraph',
				args: [ doc, 10, [ '1', { type: '/paragraph' }, { type: 'paragraph' } ] ],
				ops: [
					{ type: 'retain', length: 10 },
					{
						type: 'replace',
						remove: [],
						insert: [ '1', { type: '/paragraph' }, { type: 'paragraph' } ]
					},
					{ type: 'retain', length: 53 }
				]
			},
			{
				msg: 'paragraph inside a heading closes and reopens heading',
				args: [ doc, 2, [ { type: 'paragraph' }, ...'FOO', { type: '/paragraph' } ] ],
				ops: [
					{ type: 'retain', length: 2 },
					{
						type: 'replace',
						remove: [],
						insert: [ { type: '/heading' }, { type: 'paragraph' }, ...'FOO', { type: '/paragraph' }, { type: 'heading', attributes: { level: 1 } } ],
						insertedDataOffset: 1,
						insertedDataLength: 5
					},
					{ type: 'retain', length: 61 }
				],
				range: new ve.Range( 3, 8 )
			},
			{
				msg: 'paragraph inside a list moves in front of list',
				args: [ doc, 13, [ { type: 'paragraph' }, ...'FOO', { type: '/paragraph' } ] ],
				ops: [
					{ type: 'retain', length: 12 },
					{
						type: 'replace',
						remove: [],
						insert: [ { type: 'paragraph' }, ...'FOO', { type: '/paragraph' } ]
					},
					{ type: 'retain', length: 51 }
				],
				range: new ve.Range( 12, 17 )
			},
			{
				msg: 'tableCell inside the document is wrapped in a table, tableSection and tableRow',
				args: [ doc, 43, [ { type: 'tableCell', attributes: { style: 'data' } }, { type: 'paragraph' }, ...'FOO', { type: '/paragraph' }, { type: '/tableCell' } ] ],
				ops: [
					{ type: 'retain', length: 43 },
					{
						type: 'replace',
						remove: [],
						insert: [ { type: 'table' }, { type: 'tableSection', attributes: { style: 'body' } }, { type: 'tableRow' }, { type: 'tableCell', attributes: { style: 'data' } }, { type: 'paragraph' }, ...'FOO', { type: '/paragraph' }, { type: '/tableCell' }, { type: '/tableRow' }, { type: '/tableSection' }, { type: '/table' } ],
						insertedDataOffset: 3,
						insertedDataLength: 7
					},
					{ type: 'retain', length: 20 }
				],
				range: new ve.Range( 46, 53 )
			},
			{
				msg: 'tableCell inside a paragraph is wrapped in a table, tableSection and tableRow and moves outside of paragraph',
				args: [ doc, 52, [ { type: 'tableCell', attributes: { style: 'data' } }, { type: 'paragraph' }, ...'FOO', { type: '/paragraph' }, { type: '/tableCell' } ] ],
				ops: [
					{ type: 'retain', length: 53 },
					{
						type: 'replace',
						remove: [],
						insert: [ { type: 'table' }, { type: 'tableSection', attributes: { style: 'body' } }, { type: 'tableRow' }, { type: 'tableCell', attributes: { style: 'data' } }, { type: 'paragraph' }, ...'FOO', { type: '/paragraph' }, { type: '/tableCell' }, { type: '/tableRow' }, { type: '/tableSection' }, { type: '/table' } ],
						insertedDataOffset: 3,
						insertedDataLength: 7
					},
					{ type: 'retain', length: 10 }
				],
				range: new ve.Range( 56, 63 )
			},
			{
				msg: 'text at a structural location in the document is wrapped in a paragraph',
				args: [ doc, 0, [ ...'FOO' ] ],
				ops: [
					{
						type: 'replace',
						remove: [],
						insert: [
							{ type: 'paragraph', internal: { generated: 'wrapper' } },
							...'FOO',
							{ type: '/paragraph' }
						],
						insertedDataOffset: 1,
						insertedDataLength: 3
					},
					{ type: 'retain', length: 63 }
				],
				range: new ve.Range( 1, 4 )
			},
			{
				msg: 'text inside a paragraph is not wrapped in a paragraph',
				args: [ doc, 16, [ ...'FOO' ] ],
				ops: [
					{ type: 'retain', length: 16 },
					{
						type: 'replace',
						remove: [],
						insert: [ ...'FOO' ]
					},
					{ type: 'retain', length: 47 }
				]
			},
			{
				msg: 'text inside a heading is not wrapped in a paragraph',
				args: [ doc, 2, [ ...'FOO' ] ],
				ops: [
					{ type: 'retain', length: 2 },
					{
						type: 'replace',
						remove: [],
						insert: [ ...'FOO' ]
					},
					{ type: 'retain', length: 61 }
				]
			},
			{
				msg: 'paragraph in the middle of an insertion is balanced',
				args: [ doc, 16, [ 'F', { type: 'paragraph' }, 'O', { type: '/paragraph' }, 'O' ] ],
				ops: [
					{ type: 'retain', length: 16 },
					{
						type: 'replace',
						remove: [],
						insert: [
							'F', { type: '/paragraph' },
							{ type: 'paragraph' }, 'O', { type: '/paragraph' },
							{ type: 'paragraph', internal: { generated: 'wrapper' } }, 'O'
						]
					},
					{ type: 'retain', length: 47 }
				],
				range: new ve.Range( 16, 23 )
			},
			{
				msg: 'text inside a tableSection moves all the way to the end of the table and is wrapped in a paragraph',
				args: [ doc, 34, [ ...'FOO' ] ],
				ops: [
					{ type: 'retain', length: 37 },
					{
						type: 'replace',
						remove: [],
						insert: [ { type: 'paragraph', internal: { generated: 'wrapper' } }, ...'FOO', { type: '/paragraph' } ],
						insertedDataOffset: 1,
						insertedDataLength: 3
					},
					{ type: 'retain', length: 26 }
				],
				range: new ve.Range( 38, 41 )
			},
			{
				msg: 'insert two complete paragraphs into start of paragraph moves insertion point left',
				args: [ doc, 10, [ { type: 'paragraph' }, ...'FOO', { type: '/paragraph' }, { type: 'paragraph' }, ...'BAR', { type: '/paragraph' } ] ],
				ops: [
					{ type: 'retain', length: 9 },
					{
						type: 'replace',
						remove: [],
						insert: [ { type: 'paragraph' }, ...'FOO', { type: '/paragraph' }, { type: 'paragraph' }, ...'BAR', { type: '/paragraph' } ]
					},
					{ type: 'retain', length: 54 }
				],
				range: new ve.Range( 9, 19 )
			},
			{
				msg: 'insert text, close paragraph and open heading into end of paragraph moves insertion point right',
				args: [ doc, 57, [ ...'FOO', { type: '/paragraph' }, { type: 'heading', attributes: { level: 1 } }, ...'BAR' ] ],
				ops: [
					{ type: 'retain', length: 58 },
					{
						type: 'replace',
						remove: [],
						insert: [
							{ type: 'paragraph', internal: { generated: 'wrapper' } }, ...'FOO', { type: '/paragraph' },
							{ type: 'heading', attributes: { level: 1 } }, ...'BAR', { type: '/heading' }
						],
						insertedDataOffset: 1,
						insertedDataLength: 8
					},
					{ type: 'retain', length: 5 }
				],
				range: new ve.Range( 59, 67 )
			},
			{
				msg: 'insert heading and incomplete paragraph into heading',
				args: [ doc, 2, [ { type: 'heading', attributes: { level: 1 } }, ...'FOO', { type: '/heading' }, { type: 'paragraph' }, ...'BAR' ] ],
				ops: [
					{ type: 'retain', length: 2 },
					{
						type: 'replace',
						remove: [],
						insert: [
							{ type: '/heading' }, { type: 'heading', attributes: { level: 1 } }, ...'FOO', { type: '/heading' },
							{ type: 'paragraph' }, ...'BAR', { type: '/paragraph' },
							{ type: 'heading', attributes: { level: 1 } }
						],
						insertedDataOffset: 1,
						insertedDataLength: 9
					},
					{ type: 'retain', length: 61 }
				],
				range: new ve.Range( 3, 12 )
			},
			{
				msg: 'inserting two paragraphs into a document with just an empty paragraph',
				args: [ doc2, 1, [ ...'FOO', { type: '/paragraph' }, { type: 'paragraph' }, ...'BAR' ] ],
				ops: [
					{ type: 'retain', length: 1 },
					{
						type: 'replace',
						remove: [],
						insert: [ ...'FOO', { type: '/paragraph' }, { type: 'paragraph' }, ...'BAR' ]
					},
					{ type: 'retain', length: 3 }
				]
			},
			{
				msg: 'inserting three paragraphs into a document with just an empty paragraph',
				args: [ doc2, 1, [ ...'FOO', { type: '/paragraph' }, { type: 'paragraph' }, ...'BAR', { type: '/paragraph' }, { type: 'paragraph' }, ...'BAZ' ] ],
				ops: [
					{ type: 'retain', length: 1 },
					{
						type: 'replace',
						remove: [],
						insert: [ ...'FOO', { type: '/paragraph' }, { type: 'paragraph' }, ...'BAR', { type: '/paragraph' }, { type: 'paragraph' }, ...'BAZ' ]
					},
					{ type: 'retain', length: 3 }
				]
			},
			{
				msg: 'inserting one paragraph into empty paragraph replaces it',
				args: [ doc2, 1, [ { type: 'paragraph' }, ...'FOO', { type: '/paragraph' } ] ],
				ops: [
					{
						type: 'replace',
						remove: [ { type: 'paragraph' }, { type: '/paragraph' } ],
						insert: [ { type: 'paragraph' }, ...'FOO', { type: '/paragraph' } ]
					},
					{ type: 'retain', length: 2 }
				],
				range: new ve.Range( 0, 5 )
			},
			{
				msg: 'inserting paragraph at end of paragraph moves insertion point forward',
				args: [ doc3, 4, [ { type: 'paragraph' }, ...'BAR', { type: '/paragraph' } ] ],
				ops: [
					{ type: 'retain', length: 5 },
					{
						type: 'replace',
						remove: [],
						insert: [ { type: 'paragraph' }, ...'BAR', { type: '/paragraph' } ]
					},
					{ type: 'retain', length: 2 }
				],
				range: new ve.Range( 5, 10 )
			},
			{
				msg: 'inserting paragraph into middle of paragraph splits paragraph',
				args: [ doc3, 2, [ { type: 'paragraph' }, ...'BAR', { type: '/paragraph' } ] ],
				ops: [
					{ type: 'retain', length: 2 },
					{
						type: 'replace',
						remove: [],
						insert: [ { type: '/paragraph' }, { type: 'paragraph' }, ...'BAR', { type: '/paragraph' }, { type: 'paragraph' } ],
						insertedDataOffset: 1,
						insertedDataLength: 5
					},
					{ type: 'retain', length: 5 }
				],
				range: new ve.Range( 3, 8 )
			},
			{
				msg: 'inserting paragraph into middle of list splits list',
				args: [ isolationDoc, 11, [ { type: 'paragraph' }, ...'BAR', { type: '/paragraph' } ] ],
				ops: [
					{ type: 'retain', length: 11 },
					{
						type: 'replace',
						remove: [],
						insert: [ { type: '/list' }, { type: 'paragraph' }, ...'BAR', { type: '/paragraph' }, { type: 'list', attributes: { style: 'bullet' } } ],
						insertedDataOffset: 1,
						insertedDataLength: 5
					},
					{ type: 'retain', length: 235 }
				],
				range: new ve.Range( 12, 17 )
			},
			{
				msg: 'inserting paragraph between table cells splits table, tableSection and tableRow',
				args: [ complexTableDoc, 40, [ { type: 'paragraph' }, ...'BAR', { type: '/paragraph' } ] ],
				ops: [
					{ type: 'retain', length: 40 },
					{
						type: 'replace',
						remove: [],
						insert: [
							{ type: '/tableRow' }, { type: '/tableSection' }, { type: '/table' },
							{ type: 'paragraph' }, ...'BAR', { type: '/paragraph' },
							{ type: 'table' }, { type: 'tableSection', attributes: { style: 'body' } }, { type: 'tableRow' }
						],
						insertedDataOffset: 3,
						insertedDataLength: 5
					},
					{ type: 'retain', length: 13 }
				],
				range: new ve.Range( 43, 48 )
			}
			// TODO test cases for unclosed openings
			// TODO test cases for (currently failing) unopened closings use case
			// TODO analyze other possible cases (substrings of linmod data)
		];

	ve.test.utils.runTransactionConstructorTests( assert, ve.dm.TransactionBuilder.static.newFromInsertion, cases, true );
} );

QUnit.test( 'newFromRemoval', ( assert ) => {
	const doc = ve.dm.example.createExampleDocument( 'data' ),
		alienDoc = ve.dm.example.createExampleDocument( 'alienData' ),
		alienWithEmptyDoc = ve.dm.example.createExampleDocument( 'alienWithEmptyData' ),
		internalDoc = ve.dm.example.createExampleDocument( 'internalData' ),
		cases = [
			{
				msg: 'content in first element',
				args: [ doc, new ve.Range( 1, 3 ) ],
				ops: [
					{ type: 'retain', length: 1 },
					{
						type: 'replace',
						remove: [
							'a',
							[ 'b', [ ve.dm.example.bold ] ]
						],
						insert: []
					},
					{ type: 'retain', length: 60 }
				]
			},
			{
				msg: 'content in last element',
				args: [ doc, new ve.Range( 59, 60 ) ],
				ops: [
					{ type: 'retain', length: 59 },
					{
						type: 'replace',
						remove: [ 'm' ],
						insert: []
					},
					{ type: 'retain', length: 3 }
				]
			},
			{
				msg: 'first element',
				args: [ doc, new ve.Range( 0, 5 ) ],
				ops: [
					{
						type: 'replace',
						remove: [
							{ type: 'heading', attributes: { level: 1 } },
							'a',
							[ 'b', [ ve.dm.example.bold ] ],
							[ 'c', [ ve.dm.example.italic ] ],
							{ type: '/heading' }
						],
						insert: []
					},
					{ type: 'retain', length: 58 }
				]
			},
			{
				msg: 'middle element with image',
				args: [ doc, new ve.Range( 38, 42 ) ],
				ops: [
					{ type: 'retain', length: 38 },
					{
						type: 'replace',
						remove: [
							'h',
							ve.dm.example.image.data,
							{ type: '/inlineImage' },
							'i'
						],
						insert: []
					},
					{ type: 'retain', length: 21 }
				]
			},
			{
				msg: 'extra openings',
				args: [ doc, new ve.Range( 0, 7 ) ],
				ops: [
					{
						type: 'replace',
						remove: [
							{ type: 'heading', attributes: { level: 1 } },
							'a',
							[ 'b', [ ve.dm.example.bold ] ],
							[ 'c', [ ve.dm.example.italic ] ],
							{ type: '/heading' }
						],
						insert: []
					},
					{ type: 'retain', length: 58 }
				]
			},
			{
				msg: 'last element',
				args: [ doc, new ve.Range( 58, 61 ) ],
				ops: [
					{ type: 'retain', length: 58 },
					{
						type: 'replace',
						remove: [ { type: 'paragraph' }, 'm', { type: '/paragraph' } ],
						insert: []
					},
					{ type: 'retain', length: 2 }
				]
			},
			{
				msg: 'extra closings',
				args: [ doc, new ve.Range( 31, 39 ) ],
				ops: [
					{ type: 'retain', length: 38 },
					{
						type: 'replace',
						remove: [ 'h' ],
						insert: []
					},
					{ type: 'retain', length: 24 }
				]
			},
			{
				msg: 'merge last two elements',
				args: [ doc, new ve.Range( 57, 59 ) ],
				ops: [
					{ type: 'retain', length: 57 },
					{
						type: 'replace',
						remove: [ { type: '/paragraph' }, { type: 'paragraph' } ],
						insert: []
					},
					{ type: 'retain', length: 4 }
				]
			},
			{
				msg: 'strip out of paragraph in tableCell and paragraph in listItem',
				args: [ doc, new ve.Range( 10, 16 ) ],
				ops: [
					{ type: 'retain', length: 10 },
					{
						type: 'replace',
						remove: [ 'd' ],
						insert: []
					},
					{ type: 'retain', length: 4 },
					{
						type: 'replace',
						remove: [ 'e' ],
						insert: []
					},
					{ type: 'retain', length: 47 }
				]
			},
			{
				msg: 'over first alien into paragraph',
				args: [ alienDoc, new ve.Range( 0, 4 ) ],
				ops: [
					{
						type: 'replace',
						remove: [ { type: 'alienBlock', originalDomElements: $.parseHTML( '<foobar />' ) }, { type: '/alienBlock' } ],
						insert: []
					},
					{ type: 'retain', length: 1 },
					{
						type: 'replace',
						remove: [ 'a' ],
						insert: []
					},
					{ type: 'retain', length: alienDoc.data.getLength() - 4 }
				]
			},
			{
				msg: 'out of paragraph over last alien',
				args: [ alienDoc, new ve.Range( 6, 10 ) ],
				ops: [
					{ type: 'retain', length: 6 },
					{
						type: 'replace',
						remove: [ 'b' ],
						insert: []
					},
					{ type: 'retain', length: 1 },
					{
						type: 'replace',
						remove: [ { type: 'alienBlock', originalDomElements: $.parseHTML( '<foobar />' ) }, { type: '/alienBlock' } ],
						insert: []
					},
					{ type: 'retain', length: alienDoc.data.getLength() - 10 }
				]
			},
			{
				msg: 'out of empty paragraph over last alien',
				args: [ alienWithEmptyDoc, new ve.Range( 1, 6 ) ],
				ops: [
					{ type: 'retain', length: 1 },
					{
						type: 'replace',
						remove: [ { type: '/paragraph' }, { type: 'paragraph' }, 'a', { type: 'alienInline', originalDomElements: $.parseHTML( '<foobar />' ) }, { type: '/alienInline' } ],
						insert: []
					},
					{ type: 'retain', length: alienWithEmptyDoc.data.getLength() - 6 }
				]
			},
			{
				msg: 'merging two paragraphs inside definitionListItems',
				args: [ doc, new ve.Range( 47, 51 ) ],
				ops: [
					{ type: 'retain', length: 47 },
					{
						type: 'replace',
						remove: [ { type: '/paragraph' }, { type: '/definitionListItem' }, { type: 'definitionListItem', attributes: { style: 'definition' } }, { type: 'paragraph' } ],
						insert: []
					},
					{ type: 'retain', length: 12 }
				]
			},
			{
				msg: 'merging two paragraphs while also deleting some content',
				args: [ doc, new ve.Range( 56, 59 ) ],
				ops: [
					{ type: 'retain', length: 56 },
					{
						type: 'replace',
						remove: [ 'l', { type: '/paragraph' }, { type: 'paragraph' } ],
						insert: []
					},
					{ type: 'retain', length: 4 }
				]
			},
			{
				msg: 'removing from a heading into a paragraph',
				args: [ doc, new ve.Range( 2, 57 ) ],
				ops: [
					{ type: 'retain', length: 2 },
					{
						type: 'replace',
						remove: doc.getData().slice( 2, 4 ),
						insert: []
					},
					{ type: 'retain', length: 1 },
					{
						type: 'replace',
						remove: doc.getData().slice( 5, 55 ),
						insert: []
					},
					{ type: 'retain', length: 1 },
					{
						type: 'replace',
						remove: [ 'l' ],
						insert: []
					},
					{ type: 'retain', length: 6 }
				]
			},
			{
				msg: 'removing content from a paragraph in the middle',
				args: [ doc, new ve.Range( 56, 57 ) ],
				ops: [
					{ type: 'retain', length: 56 },
					{
						type: 'replace',
						remove: [ 'l' ],
						insert: []
					},
					{ type: 'retain', length: 6 }
				]
			},
			{
				msg: 'selection including internal nodes doesn\'t remove them',
				args: [ internalDoc, new ve.Range( 2, 24 ) ],
				ops: [
					{ type: 'retain', length: 2 },
					{
						type: 'replace',
						remove: [
							...'oo',
							{ type: '/paragraph' }
						],
						insert: []
					},
					{ type: 'retain', length: 16 },
					{
						type: 'replace',
						remove: [
							{ type: 'paragraph' },
							...'Qu'
						],
						insert: []
					},
					{ type: 'retain', length: 3 }
				]
			},
			{
				msg: 'selection ending with internal nodes',
				args: [ internalDoc, new ve.Range( 2, 21 ) ],
				ops: [
					{ type: 'retain', length: 2 },
					{
						type: 'replace',
						remove: [
							...'oo'
						],
						insert: []
					},
					{ type: 'retain', length: 23 }
				]
			},
			{
				msg: 'selection starting with internal nodes',
				args: [ internalDoc, new ve.Range( 5, 24 ) ],
				ops: [
					{ type: 'retain', length: 22 },
					{
						type: 'replace',
						remove: [
							...'Qu'
						],
						insert: []
					},
					{ type: 'retain', length: 3 }
				]
			},
			{
				msg: 'selection of just internal nodes returns a no-op transaction',
				args: [ internalDoc, new ve.Range( 5, 21 ) ],
				ops: [
					{ type: 'retain', length: 27 }
				]
			}
		];

	ve.test.utils.runTransactionConstructorTests( assert, ve.dm.TransactionBuilder.static.newFromRemoval, cases );
} );

QUnit.test( 'newFromReplacement', ( assert ) => {
	const metaDoc = ve.dm.example.createExampleDocument( 'withMeta' ),

		cases = [
			{
				msg: 'replace, preserving metadata',
				args: [ metaDoc, new ve.Range( 6, 21 ), [ ...'XY' ] ],
				ops: [
					{ type: 'retain', length: 4 },
					{
						type: 'replace',
						insert: metaDoc.getData( new ve.Range( 6, 21 ) )
							.filter( ( item ) => item.type === 'alienMeta' ||
									item.type === '/alienMeta' ),
						remove: []
					},
					{ type: 'retain', length: 2 },
					{
						type: 'replace',
						insert: [ ...'XY' ],
						remove: metaDoc.getData( new ve.Range( 6, 21 ) )
					},
					{ type: 'retain', length: 14 }
				]
			},
			{
				msg: 'replace, removing metadata',
				args: [ metaDoc, new ve.Range( 6, 21 ), [ ...'XY' ], true ],
				ops: [
					{ type: 'retain', length: 6 },
					{
						type: 'replace',
						insert: [ ...'XY' ],
						remove: metaDoc.getData( new ve.Range( 6, 21 ) )
					},
					{ type: 'retain', length: 14 }
				]
			}
		];

	ve.test.utils.runTransactionConstructorTests( assert, ve.dm.TransactionBuilder.static.newFromReplacement, cases, false );
} );

QUnit.test( 'newFromDocumentInsertion', ( assert ) => {
	const doc = ve.dm.example.createExampleDocument( 'internalData' ),
		bold = ve.dm.example.createAnnotation( ve.dm.example.bold ),
		whee = [ { type: 'paragraph' }, ...'Whee', { type: '/paragraph' } ],
		wheeItem = [ { type: 'internalItem' }, ...whee, { type: '/internalItem' } ],
		cases = [
			{
				msg: 'simple insertion',
				doc: 'internalData',
				offset: 7,
				range: new ve.Range( 7, 12 ),
				modify: ( newDoc ) => {
					// Change "Bar" to "Bazaar"
					newDoc.commit( ve.dm.TransactionBuilder.static.newFromInsertion(
						newDoc, 3, [ ...'zaa' ]
					) );
				},
				removalOps: [
					{ type: 'retain', length: 7 },
					{
						type: 'replace',
						remove: doc.getData( new ve.Range( 7, 12 ) ),
						insert: []
					},
					{ type: 'retain', length: 15 }
				],
				expectedOps: [
					{ type: 'retain', length: 6 },
					{
						type: 'replace',
						remove: [
							...doc.getData( new ve.Range( 6, 7 ) ),
							...doc.getData( new ve.Range( 12, 20 ) )
						],
						insert: [
							...doc.getData( new ve.Range( 6, 10 ) ),
							...'zaa',
							...doc.getData( new ve.Range( 10, 20 ) )
						]
					},
					{ type: 'retain', length: 7 }
				]
			},
			{
				msg: 'simple annotation',
				doc: 'internalData',
				offset: 14,
				range: new ve.Range( 14, 19 ),
				modify: ( newDoc ) => {
					// Bold the first two characters
					newDoc.commit( ve.dm.TransactionBuilder.static.newFromAnnotation(
						newDoc, new ve.Range( 1, 3 ), 'set', bold
					) );
				},
				removalOps: [
					{ type: 'retain', length: 14 },
					{
						type: 'replace',
						remove: doc.getData( new ve.Range( 14, 19 ) ),
						insert: []
					},
					{ type: 'retain', length: 8 }
				],
				expectedOps: [
					{ type: 'retain', length: 6 },
					{
						type: 'replace',
						remove: [
							...doc.getData( new ve.Range( 6, 14 ) ),
							...doc.getData( new ve.Range( 19, 20 ) )
						],
						insert: [
							...doc.getData( new ve.Range( 6, 15 ) ),
							[ doc.data.getData( 15 ), [ ve.dm.example.boldHash ] ],
							[ doc.data.getData( 16 ), [ ve.dm.example.boldHash ] ],
							...doc.getData( new ve.Range( 17, 20 ) )
						]
					},
					{ type: 'retain', length: 7 }
				],
				expectedStoreItems: {
					[ ve.dm.example.boldHash ]: bold
				}
			},
			{
				msg: 'insertion into internal list',
				doc: 'internalData',
				offset: 21,
				range: new ve.Range( 21, 27 ),
				modify: ( newDoc ) => {
					const insertion = newDoc.internalList.getItemInsertion( 'test', 'whee', whee );
					newDoc.commit( insertion.transaction );
				},
				removalOps: [
					{ type: 'retain', length: 21 },
					{
						type: 'replace',
						remove: doc.getData( new ve.Range( 21, 27 ) ),
						insert: []
					}
				],
				expectedOps: [
					{ type: 'retain', length: 6 },
					{
						type: 'replace',
						remove: doc.getData( new ve.Range( 6, 20 ) ),
						insert: [
							...doc.getData( new ve.Range( 6, 20 ) ),
							...wheeItem
						]
					},
					{ type: 'retain', length: 1 },
					{
						type: 'replace',
						remove: [],
						insert: doc.getData( new ve.Range( 21, 27 ) )
					}
				]
			},
			{
				msg: 'change in internal list',
				doc: 'internalData',
				offset: 21,
				range: new ve.Range( 21, 27 ),
				modify: ( newDoc ) => {
					newDoc.commit( ve.dm.TransactionBuilder.static.newFromInsertion(
						newDoc, 12, [ ...'!!!' ]
					) );
				},
				removalOps: [
					{ type: 'retain', length: 21 },
					{
						type: 'replace',
						remove: doc.getData( new ve.Range( 21, 27 ) ),
						insert: []
					}
				],
				expectedOps: [
					{ type: 'retain', length: 6 },
					{
						type: 'replace',
						remove: doc.getData( new ve.Range( 6, 20 ) ),
						insert: [
							...doc.getData( new ve.Range( 6, 11 ) ),
							...'!!!',
							...doc.getData( new ve.Range( 11, 20 ) )
						]
					},
					{ type: 'retain', length: 1 },
					{
						type: 'replace',
						remove: [],
						insert: doc.getData( new ve.Range( 21, 27 ) )
					}
				]
			},
			{
				msg: 'insertion into internal list from slice within internal list',
				doc: 'internalData',
				offset: 7,
				range: new ve.Range( 7, 12 ),
				modify: ( newDoc ) => {
					const insertion = newDoc.internalList.getItemInsertion( 'test', 'whee', whee );
					newDoc.commit( insertion.transaction );
				},
				removalOps: [
					{ type: 'retain', length: 7 },
					{
						type: 'replace',
						remove: doc.getData( new ve.Range( 7, 12 ) ),
						insert: []
					},
					{ type: 'retain', length: 15 }
				],
				expectedOps: [
					{ type: 'retain', length: 6 },
					{
						type: 'replace',
						remove: [
							...doc.getData( new ve.Range( 6, 7 ) ),
							...doc.getData( new ve.Range( 12, 20 ) )
						],
						insert: [
							...doc.getData( new ve.Range( 6, 20 ) ),
							...wheeItem
						]
					},
					{ type: 'retain', length: 7 }
				]
			},
			{
				msg: 'insertion from unrelated document',
				doc: 'internalData',
				offset: 0,
				newDocData: [
					{ type: 'paragraph' }, ...'Foo', { type: '/paragraph' },
					{ type: 'internalList' }, { type: '/internalList' }
				],
				removalOps: [],
				expectedOps: [
					{
						type: 'replace',
						remove: [],
						insert: [ { type: 'paragraph' }, ...'Foo', { type: '/paragraph' } ]
					},
					{ type: 'retain', length: 27 }
				]
			},
			{
				msg: 'insertion from unrelated document with annotation',
				doc: 'internalData',
				offset: 0,
				newDocData: [
					{ type: 'paragraph' }, 'F',
					[ 'o', [ ve.dm.example.bold ] ],
					'o', { type: '/paragraph' },
					{ type: 'internalList' }, { type: '/internalList' }
				],
				removalOps: [],
				expectedOps: [
					{
						type: 'replace',
						remove: [],
						insert: [ { type: 'paragraph' }, 'F', [ 'o', [ ve.dm.example.boldHash ] ], 'o', { type: '/paragraph' } ]
					},
					{ type: 'retain', length: 27 }
				],
				expectedStoreItems: {
					[ ve.dm.example.boldHash ]: bold
				}
			}
		];

	cases.forEach( ( caseItem ) => {
		const caseDoc = ve.dm.example.createExampleDocument( caseItem.doc );
		let doc2, removalOps, store2;
		if ( caseItem.newDocData ) {
			store2 = new ve.dm.HashValueStore();
			doc2 = new ve.dm.Document( ve.dm.example.preprocessAnnotations( caseItem.newDocData, store2 ) );
			removalOps = [];
		} else if ( caseItem.range ) {
			doc2 = caseDoc.cloneFromRange( caseItem.range );
			caseItem.modify( doc2 );
			const removalTx = ve.dm.TransactionBuilder.static.newFromRemoval( caseDoc, caseItem.range );
			caseDoc.commit( removalTx );
			removalOps = removalTx.getOperations();
		}

		assert.deepEqualWithDomElements( removalOps, caseItem.removalOps, caseItem.msg + ': removal' );

		const tx = ve.dm.TransactionBuilder.static.newFromDocumentInsertion( caseDoc, caseItem.offset, doc2 );
		assert.deepEqualWithDomElements( tx.getOperations(), caseItem.expectedOps, caseItem.msg + ': transaction' );

		const actualStoreItems = {};
		const expectedStoreItems = caseItem.expectedStoreItems || {};
		for ( const hash in expectedStoreItems ) {
			actualStoreItems[ hash ] = caseDoc.store.value( hash );
			expectedStoreItems[ hash ].store = store2;
		}
		assert.deepEqual( actualStoreItems, expectedStoreItems, caseItem.msg + ': store items' );
	} );
} );

QUnit.test( 'newFromAttributeChanges', ( assert ) => {
	const doc = ve.dm.example.createExampleDocument(),
		cases = [
			{
				msg: 'first element',
				args: [ doc, 0, { level: 2 } ],
				ops: [
					{
						type: 'attribute',
						key: 'level',
						from: 1,
						to: 2
					},
					{ type: 'retain', length: 63 }
				]
			},
			{
				msg: 'middle element',
				args: [ doc, 17, { style: 'number' } ],
				ops: [
					{ type: 'retain', length: 17 },
					{
						type: 'attribute',
						key: 'style',
						from: 'bullet',
						to: 'number'
					},
					{ type: 'retain', length: 46 }
				]
			},
			{
				msg: 'multiple attributes',
				args: [ doc, 17, { style: 'number', level: 1 } ],
				ops: [
					{ type: 'retain', length: 17 },
					{
						type: 'attribute',
						key: 'style',
						from: 'bullet',
						to: 'number'
					},
					{
						type: 'attribute',
						key: 'level',
						from: undefined,
						to: 1
					},
					{ type: 'retain', length: 46 }
				]
			},
			{
				msg: 'non-element',
				args: [ doc, 1, { level: 2 } ],
				exception: Error
			},
			{
				msg: 'closing element',
				args: [ doc, 4, { level: 2 } ],
				exception: Error
			}
		];

	ve.test.utils.runTransactionConstructorTests( assert, ve.dm.TransactionBuilder.static.newFromAttributeChanges, cases );

	const val = { foo: { bar: 'baz' } };
	const tx = ve.dm.TransactionBuilder.static.newFromAttributeChanges( doc, 0, val );
	val.foo.bar = 'qux';
	assert.deepEqual(
		tx.getOperations(),
		[
			{ type: 'attribute', key: 'foo', from: undefined, to: { bar: 'baz' } },
			{ type: 'retain', length: 63 }
		],
		'Attribute value mutated after transaction built (T193062)'
	);
} );

QUnit.test( 'newFromAnnotation', ( assert ) => {
	const boldAnnotation = ve.dm.example.createAnnotation( ve.dm.example.bold ),
		strong = { type: 'textStyle/bold', attributes: { nodeName: 'strong' } },
		strongAnnotation = ve.dm.example.createAnnotation( strong ),
		doc = ve.dm.example.createExampleDocument(),
		doc2 = ve.dm.example.createExampleDocumentFromData( [
			{ type: 'paragraph' },
			...ve.dm.example.annotateText( 'Foo', ve.dm.example.bold ),
			...ve.dm.example.annotateText( 'Bar', strong ),
			{ type: '/paragraph' },
			{ type: 'internalList' },
			{ type: '/internalList' }
		] ),
		annotationDoc = ve.dm.example.createExampleDocument( 'annotationData' ),
		cases = [
			{
				msg: 'over plain text',
				args: [ doc, new ve.Range( 1, 2 ), 'set', boldAnnotation ],
				ops: [
					{ type: 'retain', length: 1 },
					{
						type: 'replace',
						remove: [ 'a' ],
						insert: [ [ 'a', [ ve.dm.example.boldHash ] ] ]
					},
					{ type: 'retain', length: 61 }
				]
			},
			{
				msg: 'over partially annotated text',
				args: [ doc, new ve.Range( 1, 4 ), 'set', boldAnnotation ],
				ops: [
					{ type: 'retain', length: 1 },
					{
						type: 'replace',
						remove: [ 'a' ],
						insert: [ [ 'a', [ ve.dm.example.boldHash ] ] ]
					},
					{ type: 'retain', length: 1 },
					{
						type: 'replace',
						remove: [ [ 'c', [ ve.dm.example.italicHash ] ] ],
						insert: [ [ 'c', [ ve.dm.example.italicHash, ve.dm.example.boldHash ] ] ]
					},
					{ type: 'retain', length: 59 }
				]
			},
			{
				msg: 'comparable annotation over partially annotated text',
				args: [ doc, new ve.Range( 1, 4 ), 'set', strongAnnotation ],
				ops: [
					{ type: 'retain', length: 1 },
					{
						type: 'replace',
						remove: [ 'a' ],
						insert: [ [ 'a', [ ve.dm.example.strongHash ] ] ]
					},
					{ type: 'retain', length: 1 },
					{
						type: 'replace',
						remove: [ [ 'c', [ ve.dm.example.italicHash ] ] ],
						insert: [ [ 'c', [ ve.dm.example.italicHash, ve.dm.example.strongHash ] ] ]
					},
					{ type: 'retain', length: 59 }
				]
			},
			{
				msg: 'adjacent comparable annotations not cleared together',
				args: [ doc2, new ve.Range( 1, 7 ), 'clear', strongAnnotation ],
				ops: [
					{ type: 'retain', length: 4 },
					{
						type: 'replace',
						remove: [
							...ve.dm.example.annotateText( 'Bar', ve.dm.example.strongHash )
						],
						insert: [ ...'Bar' ]
					},
					{ type: 'retain', length: 3 }
				]
			},
			{
				msg: 'over elements',
				args: [ doc, new ve.Range( 4, 9 ), 'set', boldAnnotation ],
				ops: [
					{ type: 'retain', length: 63 }
				]
			},
			{
				msg: 'over elements and content',
				args: [ doc, new ve.Range( 3, 11 ), 'set', boldAnnotation ],
				ops: [
					{ type: 'retain', length: 3 },
					{
						type: 'replace',
						remove: [ [ 'c', [ ve.dm.example.italicHash ] ] ],
						insert: [ [ 'c', [ ve.dm.example.italicHash, ve.dm.example.boldHash ] ] ]
					},
					{ type: 'retain', length: 6 },
					{
						type: 'replace',
						remove: [ 'd' ],
						insert: [ [ 'd', [ ve.dm.example.boldHash ] ] ]
					},
					{ type: 'retain', length: 52 }
				]
			},
			{
				msg: 'over content and content element (image)',
				args: [ doc, new ve.Range( 38, 42 ), 'set', boldAnnotation ],
				ops: [
					{ type: 'retain', length: 38 },
					{
						type: 'replace',
						remove: [
							'h',
							{ type: 'inlineImage', attributes: {
								src: ve.dm.example.imgSrc,
								alt: 'Example',
								width: 100,
								height: 50
							} },
							{ type: '/inlineImage' },
							'i'
						],
						insert: [
							[ 'h', [ ve.dm.example.boldHash ] ],
							{ type: 'inlineImage', attributes: {
								src: ve.dm.example.imgSrc,
								alt: 'Example',
								width: 100,
								height: 50
							}, annotations: [ ve.dm.example.boldHash ] },
							{ type: '/inlineImage', annotations: [ ve.dm.example.boldHash ] },
							[ 'i', [ ve.dm.example.boldHash ] ]
						]
					},
					{ type: 'retain', length: 21 }
				]
			},
			{
				msg: 'over content and unannotatable content element (unboldable node)',
				args: [ annotationDoc, new ve.Range( 1, 9 ), 'set', boldAnnotation ],
				ops: [
					{ type: 'retain', length: 1 },
					{
						type: 'replace',
						remove: [ ...'Foo' ],
						insert: [
							...ve.dm.example.annotateText( 'Foo', ve.dm.example.boldHash )
						]
					},
					{ type: 'retain', length: 2 },
					{
						type: 'replace',
						remove: [ ...'Bar' ],
						insert: [
							...ve.dm.example.annotateText( 'Bar', ve.dm.example.boldHash )
						]
					},
					{ type: 'retain', length: 21 }
				]
			},
			{
				msg: 'over handles own children nodes',
				args: [ annotationDoc, new ve.Range( 1, 27 ), 'set', boldAnnotation ],
				ops: [
					{ type: 'retain', length: 1 },
					{
						type: 'replace',
						remove: [ ...'Foo' ],
						insert: [
							...ve.dm.example.annotateText( 'Foo', ve.dm.example.boldHash )
						]
					},
					{ type: 'retain', length: 2 },
					{
						type: 'replace',
						remove: [ ...'Bar' ],
						insert: [
							...ve.dm.example.annotateText( 'Bar', ve.dm.example.boldHash )
						]
					},
					{ type: 'retain', length: 15 },
					{
						type: 'replace',
						remove: [ ...'Baz' ],
						insert: [
							...ve.dm.example.annotateText( 'Baz', ve.dm.example.boldHash )
						]
					},
					{ type: 'retain', length: 3 }
				]
			}
		];

	ve.test.utils.runTransactionConstructorTests( assert, ve.dm.TransactionBuilder.static.newFromAnnotation, cases );
} );

QUnit.test( 'newFromContentBranchConversion', ( assert ) => {
	const doc = ve.dm.example.createExampleDocument(),
		doc2 = ve.dm.example.createExampleDocument( 'inlineAtEdges' ),
		cases = [
			{
				msg: 'range inside a heading, convert to paragraph',
				args: [ doc, new ve.Range( 1, 2 ), 'paragraph' ],
				ops: [
					{
						type: 'replace',
						remove: [ { type: 'heading', attributes: { level: 1 } } ],
						insert: [ { type: 'paragraph' } ]
					},
					{ type: 'retain', length: 3 },
					{
						type: 'replace',
						remove: [ { type: '/heading' } ],
						insert: [ { type: '/paragraph' } ]
					},
					{ type: 'retain', length: 58 }
				]
			},
			{
				msg: 'range inside a heading 1, convert to heading 2',
				args: [ doc, new ve.Range( 1, 2 ), 'heading', { level: 2 } ],
				ops: [
					{
						type: 'attribute',
						key: 'level',
						from: 1,
						to: 2
					},
					{ type: 'retain', length: 63 }
				]
			},
			{
				msg: 'range around 2 paragraphs, convert to preformatted',
				args: [ doc, new ve.Range( 50, 58 ), 'preformatted' ],
				ops: [
					{ type: 'retain', length: 50 },
					{
						type: 'replace',
						remove: [ { type: 'paragraph' } ],
						insert: [ { type: 'preformatted' } ]
					},
					{ type: 'retain', length: 1 },
					{
						type: 'replace',
						remove: [ { type: '/paragraph' } ],
						insert: [ { type: '/preformatted' } ]
					},
					{ type: 'retain', length: 2 },
					{
						type: 'replace',
						remove: [ { type: 'paragraph' } ],
						insert: [ { type: 'preformatted' } ]
					},
					{ type: 'retain', length: 1 },
					{
						type: 'replace',
						remove: [ { type: '/paragraph' } ],
						insert: [ { type: '/preformatted' } ]
					},
					{ type: 'retain', length: 5 }
				]
			},
			{
				msg: 'zero-length range before inline node at the start',
				args: [ doc2, new ve.Range( 1 ), 'heading', { level: 2 } ],
				ops: [
					{
						type: 'replace',
						remove: [ { type: 'paragraph' } ],
						insert: [ { type: 'heading', attributes: { level: 2 } } ]
					},
					{ type: 'retain', length: 7 },
					{
						type: 'replace',
						remove: [ { type: '/paragraph' } ],
						insert: [ { type: '/heading' } ]
					},
					{ type: 'retain', length: 2 }
				]
			},
			{
				msg: 'zero-length range inside inline node at the start',
				args: [ doc2, new ve.Range( 2 ), 'heading', { level: 2 } ],
				ops: [
					{
						type: 'replace',
						remove: [ { type: 'paragraph' } ],
						insert: [ { type: 'heading', attributes: { level: 2 } } ]
					},
					{ type: 'retain', length: 7 },
					{
						type: 'replace',
						remove: [ { type: '/paragraph' } ],
						insert: [ { type: '/heading' } ]
					},
					{ type: 'retain', length: 2 }
				]
			},
			{
				msg: 'zero-length range after inline node at the start',
				args: [ doc2, new ve.Range( 3 ), 'heading', { level: 2 } ],
				ops: [
					{
						type: 'replace',
						remove: [ { type: 'paragraph' } ],
						insert: [ { type: 'heading', attributes: { level: 2 } } ]
					},
					{ type: 'retain', length: 7 },
					{
						type: 'replace',
						remove: [ { type: '/paragraph' } ],
						insert: [ { type: '/heading' } ]
					},
					{ type: 'retain', length: 2 }
				]
			},
			{
				msg: 'zero-length range before inline node at the end',
				args: [ doc2, new ve.Range( 6 ), 'heading', { level: 2 } ],
				ops: [
					{
						type: 'replace',
						remove: [ { type: 'paragraph' } ],
						insert: [ { type: 'heading', attributes: { level: 2 } } ]
					},
					{ type: 'retain', length: 7 },
					{
						type: 'replace',
						remove: [ { type: '/paragraph' } ],
						insert: [ { type: '/heading' } ]
					},
					{ type: 'retain', length: 2 }
				]
			},
			{
				msg: 'zero-length range inside inline node at the end',
				args: [ doc2, new ve.Range( 7 ), 'heading', { level: 2 } ],
				ops: [
					{
						type: 'replace',
						remove: [ { type: 'paragraph' } ],
						insert: [ { type: 'heading', attributes: { level: 2 } } ]
					},
					{ type: 'retain', length: 7 },
					{
						type: 'replace',
						remove: [ { type: '/paragraph' } ],
						insert: [ { type: '/heading' } ]
					},
					{ type: 'retain', length: 2 }
				]
			},
			{
				msg: 'zero-length range after inline node at the end',
				args: [ doc2, new ve.Range( 8 ), 'heading', { level: 2 } ],
				ops: [
					{
						type: 'replace',
						remove: [ { type: 'paragraph' } ],
						insert: [ { type: 'heading', attributes: { level: 2 } } ]
					},
					{ type: 'retain', length: 7 },
					{
						type: 'replace',
						remove: [ { type: '/paragraph' } ],
						insert: [ { type: '/heading' } ]
					},
					{ type: 'retain', length: 2 }
				]
			}
		];

	ve.test.utils.runTransactionConstructorTests(
		assert,
		ve.dm.TransactionBuilder.static.newFromContentBranchConversion,
		cases
	);
} );

QUnit.test( 'newFromWrap', ( assert ) => {
	const doc = ve.dm.example.createExampleDocument(),
		metaDoc = ve.dm.example.createExampleDocument( 'withMeta' ),
		listMetaDoc = ve.dm.example.createExampleDocument( 'listWithMeta' ),
		listDoc = ve.dm.example.createExampleDocumentFromData(
			listMetaDoc.getData().filter( ( item ) => item.type !== 'alienMeta' &&
					item.type !== '/alienMeta' )
		),
		cases = [
			{
				msg: 'changes a heading to a paragraph',
				args: [ doc, new ve.Range( 1, 4 ), [ { type: 'heading', attributes: { level: 1 } } ], [ { type: 'paragraph' } ], [], [] ],
				ops: [
					{ type: 'replace', remove: [ { type: 'heading', attributes: { level: 1 } } ], insert: [ { type: 'paragraph' } ] },
					{ type: 'retain', length: 3 },
					{ type: 'replace', remove: [ { type: '/heading' } ], insert: [ { type: '/paragraph' } ] },
					{ type: 'retain', length: 58 }
				]
			},
			{
				msg: 'unwraps a list',
				args: [ doc, new ve.Range( 13, 25 ), [ { type: 'list' } ], [], [ { type: 'listItem' } ], [] ],
				ops: [
					{ type: 'retain', length: 12 },
					{
						type: 'replace',
						remove: [ { type: 'list', attributes: { style: 'bullet' } }, { type: 'listItem' } ],
						insert: []
					},
					{ type: 'retain', length: 10 },
					{ type: 'replace', remove: [ { type: '/listItem' }, { type: '/list' } ], insert: [] },
					{ type: 'retain', length: 37 }
				]
			},
			{
				msg: 'unwraps a multiple-item list',
				args: [ listDoc, new ve.Range( 1, 11 ), [ { type: 'list' } ], [], [ { type: 'listItem', attributes: { styles: [ 'bullet' ] } } ], [] ],
				ops: [
					{ type: 'replace',
						remove: [ { type: 'list' }, { type: 'listItem', attributes: { styles: [ 'bullet' ] } } ],
						insert: []
					},
					{ type: 'retain', length: 3 },
					{ type: 'replace',
						remove: [ { type: '/listItem' }, { type: 'listItem', attributes: { styles: [ 'bullet' ] } } ],
						insert: []
					},
					{ type: 'retain', length: 3 },
					{ type: 'replace',
						remove: [ { type: '/listItem' }, { type: '/list' } ],
						insert: []
					},
					{ type: 'retain', length: 2 }
				]
			},
			{
				msg: 'replaces a table with a list',
				args: [ doc, new ve.Range( 9, 33 ), [ { type: 'table' }, { type: 'tableSection', attributes: { style: 'body' } }, { type: 'tableRow' }, { type: 'tableCell', attributes: { style: 'data' } } ], [ { type: 'list' }, { type: 'listItem' } ], [], [] ],
				ops: [
					{ type: 'retain', length: 5 },
					{ type: 'replace', remove: [ { type: 'table' }, { type: 'tableSection', attributes: { style: 'body' } }, { type: 'tableRow' }, { type: 'tableCell', attributes: { style: 'data' } } ], insert: [ { type: 'list' }, { type: 'listItem' } ] },
					{ type: 'retain', length: 24 },
					{ type: 'replace', remove: [ { type: '/tableCell' }, { type: '/tableRow' }, { type: '/tableSection' }, { type: '/table' } ], insert: [ { type: '/listItem' }, { type: '/list' } ] },
					{ type: 'retain', length: 26 }
				]
			},
			{
				msg: 'wraps two adjacent paragraphs in a list',
				args: [ doc, new ve.Range( 55, 61 ), [], [ { type: 'list', attributes: { style: 'number' } } ], [], [ { type: 'listItem' } ] ],
				ops: [
					{ type: 'retain', length: 55 },
					{
						type: 'replace',
						remove: [],
						insert: [ { type: 'list', attributes: { style: 'number' } }, { type: 'listItem' } ]
					},
					{ type: 'retain', length: 3 },
					{ type: 'replace', remove: [], insert: [ { type: '/listItem' }, { type: 'listItem' } ] },
					{ type: 'retain', length: 3 },
					{ type: 'replace', remove: [], insert: [ { type: '/listItem' }, { type: '/list' } ] },
					{ type: 'retain', length: 2 }
				]
			},
			{
				msg: 'wraps two adjacent paragraphs in a definitionList',
				args: [ doc, new ve.Range( 55, 61 ), [], [ { type: 'definitionList' } ], [], [ { type: 'definitionListItem', attributes: { style: 'term' } } ] ],
				ops: [
					{ type: 'retain', length: 55 },
					{
						type: 'replace',
						remove: [],
						insert: [ { type: 'definitionList' }, { type: 'definitionListItem', attributes: { style: 'term' } } ]
					},
					{ type: 'retain', length: 3 },
					{
						type: 'replace',
						remove: [],
						insert: [ { type: '/definitionListItem' }, { type: 'definitionListItem', attributes: { style: 'term' } } ]
					},
					{ type: 'retain', length: 3 },
					{ type: 'replace', remove: [], insert: [ { type: '/definitionListItem' }, { type: '/definitionList' } ] },
					{ type: 'retain', length: 2 }
				]
			},
			{
				msg: 'metadata is preserved on wrap',
				args: [
					metaDoc,
					new ve.Range( 4, 23 ),
					[],
					[ { type: 'list' } ],
					[ { type: 'paragraph' } ],
					[ { type: 'listItem' }, { type: 'heading', level: 1 } ]
				],
				ops: [
					{ type: 'retain', length: 4 },
					{
						type: 'replace',
						remove: [ { type: 'paragraph' } ],
						insert: [
							{ type: 'list' },
							{ type: 'listItem' },
							{ type: 'heading', level: 1 }
						]
					},
					{ type: 'retain', length: 3 },
					{
						type: 'replace',
						remove: [ { type: '/paragraph' } ],
						insert: [
							{ type: '/heading' },
							{ type: '/listItem' }
						]
					},
					{ type: 'retain', length: 2 },
					{
						type: 'replace',
						remove: [ { type: 'paragraph' } ],
						insert: [
							{ type: 'listItem' },
							{ type: 'heading', level: 1 }
						]
					},
					{ type: 'retain', length: 3 },
					{
						type: 'replace',
						remove: [ { type: '/paragraph' } ],
						insert: [
							{ type: '/heading' },
							{ type: '/listItem' }
						]
					},
					{ type: 'retain', length: 2 },
					{
						type: 'replace',
						remove: [ { type: 'paragraph' } ],
						insert: [
							{ type: 'listItem' },
							{ type: 'heading', level: 1 }
						]
					},
					{ type: 'retain', length: 3 },
					{
						type: 'replace',
						remove: [ { type: '/paragraph' } ],
						insert: [
							{ type: '/heading' },
							{ type: '/listItem' },
							{ type: '/list' }
						]
					},
					{ type: 'retain', length: 12 }
				]
			},
			{
				msg: 'metadata is preserved on unwrap',
				args: [ listMetaDoc, new ve.Range( 3, 35 ), [ { type: 'list' } ], [], [ { type: 'listItem', attributes: { styles: [ 'bullet' ] } } ], [] ],
				ops: [
					{ type: 'retain', length: 2 },
					{ type: 'replace', remove: [ { type: 'list' } ], insert: [] },
					{ type: 'retain', length: 2 },
					{ type: 'replace', remove: [ { type: 'listItem', attributes: { styles: [ 'bullet' ] } } ], insert: [] },
					{ type: 'retain', length: 11 },
					{ type: 'replace', remove: [ { type: '/listItem' } ], insert: [] },
					{ type: 'retain', length: 2 },
					{ type: 'replace', remove: [ { type: 'listItem', attributes: { styles: [ 'bullet' ] } } ], insert: [] },
					{ type: 'retain', length: 11 },
					{ type: 'replace', remove: [ { type: '/listItem' } ], insert: [] },
					{ type: 'retain', length: 2 },
					{ type: 'replace', remove: [ { type: '/list' } ], insert: [] },
					{ type: 'retain', length: 4 }
				]
			},
			{
				msg: 'checks integrity of unwrapOuter parameter',
				args: [ doc, new ve.Range( 13, 32 ), [ { type: 'table' } ], [], [], [] ],
				exception: Error
			},
			{
				msg: 'checks integrity of unwrapEach parameter',
				args: [ doc, new ve.Range( 13, 32 ), [ { type: 'list' } ], [], [ { type: 'paragraph' } ], [] ],
				exception: Error
			},
			{
				msg: 'checks that unwrapOuter fits before the range',
				args: [ doc, new ve.Range( 1, 4 ), [ { type: 'listItem' }, { type: 'paragraph' } ], [], [], [] ],
				exception: Error
			}
		];

	ve.test.utils.runTransactionConstructorTests(
		assert,
		ve.dm.TransactionBuilder.static.newFromWrap,
		cases
	);
} );

QUnit.test( 'pushRetain', ( assert ) => {
	const cases = [
		{
			msg: 'retain',
			calls: [ [ 'pushRetain', 5 ] ],
			ops: [ { type: 'retain', length: 5 } ],
			diff: 0
		},
		{
			msg: 'multiple retain',
			calls: [ [ 'pushRetain', 5 ], [ 'pushRetain', 3 ] ],
			ops: [ { type: 'retain', length: 8 } ],
			diff: 0
		}
	];
	ve.test.utils.runTransactionBuilderTests( assert, cases );
} );

QUnit.test( 'pushReplacement', ( assert ) => {
	const doc = new ve.dm.Document( [ { type: 'paragraph' }, ...'abc', { type: '/paragraph' } ] ),
		doc2 = new ve.dm.Document( [ { type: 'paragraph' }, ...'abcghi', { type: '/paragraph' } ] ),
		cases = [
			{
				msg: 'insert',
				calls: [
					[ 'pushReplacement', doc, 0, 0, [ { type: 'paragraph' }, ...'abc', { type: '/paragraph' } ] ]
				],
				ops: [
					{
						type: 'replace',
						remove: [],
						insert: [ { type: 'paragraph' }, ...'abc', { type: '/paragraph' } ]
					}
				],
				diff: 5
			},
			{
				msg: 'multiple insert',
				calls: [
					[ 'pushReplacement', doc, 0, 0, [ { type: 'paragraph' }, ...'ab' ] ],
					[ 'pushReplacement', doc, 0, 0, [ 'c', { type: '/paragraph' } ] ]
				],
				ops: [
					{
						type: 'replace',
						remove: [],
						insert: [ { type: 'paragraph' }, ...'abc', { type: '/paragraph' } ]
					}
				],
				diff: 5
			},
			{
				msg: 'insert and retain',
				calls: [
					[ 'pushRetain', 1 ],
					[ 'pushReplacement', doc, 0, 0, [ ...'abc' ] ]
				],
				ops: [
					{ type: 'retain', length: 1 },
					{ type: 'replace', remove: [], insert: [ ...'abc' ] }
				],
				diff: 3
			},
			{
				msg: 'remove',
				calls: [
					[ 'pushReplacement', doc, 0, 5, [] ]
				],
				ops: [
					{
						type: 'replace',
						remove: [ { type: 'paragraph' }, ...'abc', { type: '/paragraph' } ],
						insert: []
					}
				],
				diff: -5
			},
			{
				msg: 'multiple remove',
				calls: [
					[ 'pushReplacement', doc, 0, 3, [] ],
					[ 'pushReplacement', doc, 3, 2, [] ]
				],
				ops: [
					{
						type: 'replace',
						remove: [ { type: 'paragraph' }, ...'abc', { type: '/paragraph' } ],
						insert: []
					}
				],
				diff: -5
			},
			{
				msg: 'retain and remove',
				calls: [
					[ 'pushRetain', 1 ],
					[ 'pushReplacement', doc, 1, 3, [] ]
				],
				ops: [
					{ type: 'retain', length: 1 },
					{ type: 'replace', remove: [ ...'abc' ], insert: [] }
				],
				diff: -3
			},
			{
				msg: 'replace',
				calls: [
					[ 'pushReplacement', doc, 1, 3, [ ...'def' ] ]
				],
				ops: [
					{
						type: 'replace',
						remove: [ ...'abc' ],
						insert: [ ...'def' ]
					}
				],
				diff: 0
			},
			{
				msg: 'multiple replace',
				calls: [
					[ 'pushReplacement', doc2, 1, 3, [ ...'def' ] ],
					[ 'pushReplacement', doc2, 4, 3, [ ...'jkl' ] ]
				],
				ops: [
					{
						type: 'replace',
						remove: [ ...'abcghi' ],
						insert: [ ...'defjkl' ]
					}
				],
				diff: 0
			}
		];

	ve.test.utils.runTransactionBuilderTests( assert, cases );
} );

QUnit.test( 'pushReplaceElementAttribute', ( assert ) => {
	const cases = [
		{
			msg: 'replace element attribute',
			calls: [
				[ 'pushReplaceElementAttribute', 'style', 'bullet', 'number' ]
			],
			ops: [
				{
					type: 'attribute',
					key: 'style',
					from: 'bullet',
					to: 'number'
				}
			],
			diff: 0
		},
		{
			msg: 'replace multiple element attributes',
			calls: [
				[ 'pushReplaceElementAttribute', 'style', 'bullet', 'number' ],
				[ 'pushReplaceElementAttribute', 'level', 1, 2 ]
			],
			ops: [
				{
					type: 'attribute',
					key: 'style',
					from: 'bullet',
					to: 'number'
				},
				{
					type: 'attribute',
					key: 'level',
					from: 1,
					to: 2
				}
			],
			diff: 0
		}
	];

	ve.test.utils.runTransactionBuilderTests( assert, cases );
} );

QUnit.test( 'isNoOp', ( assert ) => {
	const doc = ve.dm.example.createExampleDocument(),
		metaDoc = ve.dm.example.createExampleDocument( 'withMeta' ),
		listMetaDoc = ve.dm.example.createExampleDocument( 'listWithMeta' );

	[ doc, metaDoc, listMetaDoc ].forEach( ( d, i ) => {
		const isListMetaDoc = ( i === 2 );

		let tx = ve.dm.TransactionBuilder.static.newFromReplacement(
			d, new ve.Range( 1 ), [], false
		);
		assert.true( tx.isNoOp() );

		tx = ve.dm.TransactionBuilder.static.newFromInsertion(
			d, 1, []
		);
		assert.true( tx.isNoOp() );

		tx = ve.dm.TransactionBuilder.static.newFromRemoval(
			d, new ve.Range( 1 ), false
		);
		assert.true( tx.isNoOp() );

		if ( !isListMetaDoc ) {
			tx = ve.dm.TransactionBuilder.static.newFromDocumentInsertion(
				d, 1,
				ve.dm.example.createExampleDocument(), new ve.Range( 0 )
			);
			assert.true( tx.isNoOp() );
		}

		tx = ve.dm.TransactionBuilder.static.newFromAttributeChanges(
			d, isListMetaDoc ? 5 : 0, {}
		);
		assert.true( tx.isNoOp() );

		tx = ve.dm.TransactionBuilder.static.newFromAnnotation(
			d, new ve.Range( 1 ), 'set', new ve.dm.ItalicAnnotation()
		);
		assert.true( tx.isNoOp() );
	} );
} );

QUnit.test( 'operations/build from operations', ( assert ) => {
	const TBstatic = ve.dm.TransactionBuilder.static,
		doc = ve.dm.example.createExampleDocument(),
		underline = ve.dm.example.createAnnotation( ve.dm.example.underline ),
		cases = [
			{
				msg: 'Replace annotated text',
				method: 'newFromReplacement',
				args: [ doc, new ve.Range( 1, 4 ), [ ...'Foo' ] ],
				expected: [
					{ type: 'retain', length: 1 },
					{
						type: 'replace',
						remove: [ 'a', [ 'b', [ ve.dm.example.boldHash ] ], [ 'c', [ ve.dm.example.italicHash ] ] ],
						insert: [ ...'Foo' ]
					},
					{ type: 'retain', length: 59 }
				]
			},
			{
				msg: 'New paragraph',
				method: 'newFromInsertion',
				args: [ doc, 0, [ { type: 'paragraph' }, { type: '/paragraph' } ] ],
				roundTripArgs: [ doc, 0, [ { type: 'paragraph' }, { type: '/paragraph' } ] ],
				expected: [
					{
						type: 'replace',
						remove: [],
						insert: [ { type: 'paragraph' }, { type: '/paragraph' } ]
					},
					{ type: 'retain', length: 63 }
				]
			},
			{
				msg: 'New alien preserves originalDomElements',
				method: 'newFromInsertion',
				args: [ doc, 0, [ { type: 'alienBlock' }, { type: '/alienBlock' } ] ],
				expected: [
					{
						type: 'replace',
						remove: [],
						insert: [ { type: 'alienBlock' }, { type: '/alienBlock' } ]
					},
					{ type: 'retain', length: 63 }
				]
			},
			{
				msg: 'Add annotation',
				method: 'newFromAnnotation',
				args: [ doc, new ve.Range( 1, 4 ), 'set', underline ],
				expected: [
					{ type: 'retain', length: 1 },
					{
						type: 'replace',
						remove: [
							'a',
							[ 'b', [ ve.dm.example.boldHash ] ],
							[ 'c', [ ve.dm.example.italicHash ] ]
						],
						insert: [
							[ 'a', [ ve.dm.example.underlineHash ] ],
							[ 'b', [ ve.dm.example.underlineHash, ve.dm.example.boldHash ] ],
							[ 'c', [ ve.dm.example.underlineHash, ve.dm.example.italicHash ] ]
						]
					},
					{ type: 'retain', length: 59 }
				]
			}
		];

	cases.forEach( ( caseItem ) => {
		let tx = TBstatic[ caseItem.method ]( ...caseItem.args );
		const ops = ve.copy( tx.operations );
		assert.deepEqual( ops, caseItem.expected, caseItem.msg + ': operations' );
		if ( caseItem.roundTripArgs ) {
			tx = TBstatic[ caseItem.method ]( ...caseItem.roundTripArgs );
		}
		assert.deepEqual( new ve.dm.Transaction( ops ), tx, caseItem.msg + ': build from operations' );
	} );
} );

QUnit.test( 'newFromRemoval preserving metadata', ( assert ) => {
	function removeBoringProperties( operations ) {
		return operations.map( ( op ) => {
			if ( !op.insert ) {
				return op;
			}
			op = ve.copy( op );
			[ ...op.insert, ...op.remove ].forEach( ( item ) => {
				delete item.originalDomElementsHash;
				delete item.internal;
			} );
			return op;
		} );
	}
	const cases = [
		{
			msg: 'Removal ending at meta',
			html: '<p>foo</p><meta><p>bar</p>',
			range: new ve.Range( 0, 7 ),
			ops: [
				{ type: 'replace', insert: [
					{ type: 'alienMeta' },
					{ type: '/alienMeta' }
				], remove: [
					{ type: 'paragraph' },
					...'foo',
					{ type: '/paragraph' },
					{ type: 'alienMeta' },
					{ type: '/alienMeta' }
				] },
				{ type: 'retain', length: 7 }
			]
		},
		{
			msg: 'Removal beginning at meta',
			html: '<p>foo</p><meta><p>bar</p>',
			range: new ve.Range( 5, 13 ),
			ops: [
				{ type: 'retain', length: 5 },
				{ type: 'replace', insert: [
					{ type: 'alienMeta' },
					{ type: '/alienMeta' }
				], remove: [
					{ type: 'alienMeta' },
					{ type: '/alienMeta' },
					{ type: 'paragraph' },
					...'bar',
					{ type: '/paragraph' }
				] },
				{ type: 'retain', length: 2 }
			]
		},
		{
			msg: 'Removal across meta',
			html: '<p>foo</p><meta><p>bar</p><p>baz</p>',
			range: new ve.Range( 0, 12 ),
			ops: [
				{ type: 'replace', insert: [
					{ type: 'alienMeta' },
					{ type: '/alienMeta' }
				], remove: [
					{ type: 'paragraph' },
					...'foo',
					{ type: '/paragraph' },
					{ type: 'alienMeta' },
					{ type: '/alienMeta' },
					{ type: 'paragraph' },
					...'bar',
					{ type: '/paragraph' }
				] },
				{ type: 'retain', length: 7 }
			]
		},
		{
			msg: 'Merge /ul/li/p nodes across meta',
			html: '<ul><li><p>foo</p></li><li><p></p></li></ul><p></p><div><div><meta></div></div><ul><li><p>bar</p></li></ul>',
			range: new ve.Range( 4, 25 ),
			ops: [
				{ type: 'retain', length: 2 },
				{ type: 'replace', remove: [], insert: [
					{ type: 'alienMeta' },
					{ type: '/alienMeta' }
				] },
				{ type: 'retain', length: 2 },
				{ type: 'replace', insert: [], remove: [
					...'oo',
					{ type: '/paragraph' },
					{ type: '/listItem' },
					{ type: 'listItem' },
					{ type: 'paragraph' },
					{ type: '/paragraph' },
					{ type: '/listItem' },
					{ type: '/list' },
					{ type: 'paragraph' },
					{ type: '/paragraph' },
					{ type: 'div' },
					{ type: 'div' },
					{ type: 'alienMeta' },
					{ type: '/alienMeta' },
					{ type: '/div' },
					{ type: '/div' },
					{ type: 'list', attributes: { style: 'bullet' } },
					{ type: 'listItem' },
					{ type: 'paragraph' },
					'b'
				] },
				{ type: 'retain', length: 7 }
			]
		}
	];

	cases.forEach( ( caseItem ) => {
		const doc = ve.dm.converter.getModelFromDom( ve.createDocumentFromHtml( caseItem.html ) );
		const tx = ve.dm.TransactionBuilder.static.newFromRemoval( doc, caseItem.range );
		assert.deepEqual(
			removeBoringProperties( tx.operations ),
			caseItem.ops,
			caseItem.msg
		);
	} );
} );
