%PDF- %PDF-
Direktori : /www/varak.net/wiki.varak.net/extensions/VisualEditor/lib/ve/tests/dm/ |
Current File : /www/varak.net/wiki.varak.net/extensions/VisualEditor/lib/ve/tests/dm/ve.dm.Transaction.test.js |
/*! * VisualEditor DataModel Transaction tests. * * @copyright 2011-2016 VisualEditor Team and others; see http://ve.mit-license.org */ QUnit.module( 've.dm.Transaction' ); /* Helper methods */ function runBuilderTests( assert, cases ) { var msg, tx, i; for ( msg in cases ) { tx = new ve.dm.Transaction(); for ( i = 0; i < cases[ msg ].calls.length; i++ ) { tx[ cases[ msg ].calls[ i ][ 0 ] ].apply( tx, cases[ msg ].calls[ i ].slice( 1 ) ); } assert.deepEqualWithDomElements( tx.getOperations(), cases[ msg ].ops, msg + ': operations match' ); } } function runConstructorTests( assert, constructor, cases, testRange ) { var msg, doc, args, tx; for ( msg in cases ) { doc = cases[ msg ].args[ 0 ]; args = cases[ msg ].args; if ( cases[ msg ].ops ) { tx = constructor.apply( ve.dm.Transaction, args ); assert.equalLinearDataWithDom( doc.getStore(), tx.getOperations(), cases[ msg ].ops, msg + ': operations match' ); if ( testRange ) { assert.equalRange( tx.getModifiedRange( doc ), cases[ msg ].range || new ve.Range( args[ 1 ], args[ 1 ] + args[ 2 ].length ), msg + ': range matches' ); } } else if ( cases[ msg ].exception ) { /*jshint loopfunc:true */ assert.throws( function () { constructor.apply( ve.dm.Transaction, args ); }, cases[ msg ].exception, msg + ': throw exception' ); } } } /* Tests */ QUnit.test( 'newFromInsertion', function ( assert ) { var i, key, doc = ve.dm.example.createExampleDocument(), isolationDoc = ve.dm.example.createExampleDocument( 'isolationData' ), complexTableDoc = ve.dm.example.createExampleDocument( 'complexTable' ), listWithMetaDoc = ve.dm.example.createExampleDocument( 'listWithMeta' ), 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' }, 'F', 'o', 'o', { type: '/paragraph' }, { type: 'internalList' }, { type: '/internalList' } ] ) ), cases = { 'paragraph before first element': { args: [ doc, 0, [ { type: 'paragraph' }, '1', { type: '/paragraph' } ] ], ops: [ { type: 'replace', remove: [], insert: [ { type: 'paragraph' }, '1', { type: '/paragraph' } ], insertedDataOffset: 0, insertedDataLength: 3 }, { type: 'retain', length: 63 } ] }, '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' } ], insertedDataOffset: 0, insertedDataLength: 3 }, { type: 'retain', length: 2 } ] }, 'split paragraph': { args: [ doc, 10, [ '1', { type: '/paragraph' }, { type: 'paragraph' } ] ], ops: [ { type: 'retain', length: 10 }, { type: 'replace', remove: [], insert: [ '1', { type: '/paragraph' }, { type: 'paragraph' } ], insertedDataOffset: 0, insertedDataLength: 3 }, { type: 'retain', length: 53 } ] }, 'paragraph inside a heading closes and reopens heading': { args: [ doc, 2, [ { type: 'paragraph' }, 'F', 'O', 'O', { type: '/paragraph' } ] ], ops: [ { type: 'retain', length: 2 }, { type: 'replace', remove: [], insert: [ { type: '/heading' }, { type: 'paragraph' }, 'F', 'O', 'O', { type: '/paragraph' }, { type: 'heading', attributes: { level: 1 } } ], insertedDataOffset: 1, insertedDataLength: 5 }, { type: 'retain', length: 61 } ], range: new ve.Range( 3, 8 ) }, 'paragraph inside a list moves in front of list': { args: [ doc, 13, [ { type: 'paragraph' }, 'F', 'O', 'O', { type: '/paragraph' } ] ], ops: [ { type: 'retain', length: 12 }, { type: 'replace', remove: [], insert: [ { type: 'paragraph' }, 'F', 'O', 'O', { type: '/paragraph' } ], insertedDataOffset: 0, insertedDataLength: 5 }, { type: 'retain', length: 51 } ], range: new ve.Range( 12, 17 ) }, 'tableCell inside the document is wrapped in a table, tableSection and tableRow': { args: [ doc, 43, [ { type: 'tableCell', attributes: { style: 'data' } }, { type: 'paragraph' }, 'F', 'O', 'O', { 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' }, 'F', 'O', 'O', { type: '/paragraph' }, { type: '/tableCell' }, { type: '/tableRow' }, { type: '/tableSection' }, { type: '/table' } ], insertedDataOffset: 3, insertedDataLength: 7 }, { type: 'retain', length: 20 } ], range: new ve.Range( 46, 53 ) }, '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' }, 'F', 'O', 'O', { 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' }, 'F', 'O', 'O', { type: '/paragraph' }, { type: '/tableCell' }, { type: '/tableRow' }, { type: '/tableSection' }, { type: '/table' } ], insertedDataOffset: 3, insertedDataLength: 7 }, { type: 'retain', length: 10 } ], range: new ve.Range( 56, 63 ) }, 'text at a structural location in the document is wrapped in a paragraph': { args: [ doc, 0, [ 'F', 'O', 'O' ] ], ops: [ { type: 'replace', remove: [], insert: [ { type: 'paragraph' }, 'F', 'O', 'O', { type: '/paragraph' } ], insertedDataOffset: 1, insertedDataLength: 3 }, { type: 'retain', length: 63 } ], range: new ve.Range( 1, 4 ) }, 'text inside a paragraph is not wrapped in a paragraph': { args: [ doc, 16, [ 'F', 'O', 'O' ] ], ops: [ { type: 'retain', length: 16 }, { type: 'replace', remove: [], insert: [ 'F', 'O', 'O' ], insertedDataOffset: 0, insertedDataLength: 3 }, { type: 'retain', length: 47 } ] }, 'text inside a heading is not wrapped in a paragraph': { args: [ doc, 2, [ 'F', 'O', 'O' ] ], ops: [ { type: 'retain', length: 2 }, { type: 'replace', remove: [], insert: [ 'F', 'O', 'O' ], insertedDataOffset: 0, insertedDataLength: 3 }, { type: 'retain', length: 61 } ] }, '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' }, 'O' ], insertedDataOffset: 0, insertedDataLength: 7 }, { type: 'retain', length: 47 } ], range: new ve.Range( 16, 23 ) }, 'text inside a tableSection moves all the way to the end of the table and is wrapped in a paragraph': { args: [ doc, 34, [ 'F', 'O', 'O' ] ], ops: [ { type: 'retain', length: 37 }, { type: 'replace', remove: [], insert: [ { type: 'paragraph' }, 'F', 'O', 'O', { type: '/paragraph' } ], insertedDataOffset: 1, insertedDataLength: 3 }, { type: 'retain', length: 26 } ], range: new ve.Range( 38, 41 ) }, 'insert two complete paragraphs into start of paragraph moves insertion point left': { args: [ doc, 10, [ { type: 'paragraph' }, 'F', 'O', 'O', { type: '/paragraph' }, { type: 'paragraph' }, 'B', 'A', 'R', { type: '/paragraph' } ] ], ops: [ { type: 'retain', length: 9 }, { type: 'replace', remove: [], insert: [ { type: 'paragraph' }, 'F', 'O', 'O', { type: '/paragraph' }, { type: 'paragraph' }, 'B', 'A', 'R', { type: '/paragraph' } ], insertedDataOffset: 0, insertedDataLength: 10 }, { type: 'retain', length: 54 } ], range: new ve.Range( 9, 19 ) }, 'insert text, close paragraph and open heading into end of paragraph moves insertion point right': { args: [ doc, 57, [ 'F', 'O', 'O', { type: '/paragraph' }, { type: 'heading', attributes: { level: 1 } }, 'B', 'A', 'R' ] ], ops: [ { type: 'retain', length: 58 }, { type: 'replace', remove: [], insert: [ { type: 'paragraph' }, 'F', 'O', 'O', { type: '/paragraph' }, { type: 'heading', attributes: { level: 1 } }, 'B', 'A', 'R', { type: '/heading' } ], insertedDataOffset: 1, insertedDataLength: 8 }, { type: 'retain', length: 5 } ], range: new ve.Range( 59, 67 ) }, 'insert heading and incomplete paragraph into heading': { args: [ doc, 2, [ { type: 'heading', attributes: { level: 1 } }, 'F', 'O', 'O', { type: '/heading' }, { type: 'paragraph' }, 'B', 'A', 'R' ] ], ops: [ { type: 'retain', length: 2 }, { type: 'replace', remove: [], insert: [ { type: '/heading' }, { type: 'heading', attributes: { level: 1 } }, 'F', 'O', 'O', { type: '/heading' }, { type: 'paragraph' }, 'B', 'A', 'R', { type: '/paragraph' }, { type: 'heading', attributes: { level: 1 } } ], insertedDataOffset: 1, insertedDataLength: 9 }, { type: 'retain', length: 61 } ], range: new ve.Range( 3, 12 ) }, 'inserting two paragraphs into a document with just an empty paragraph': { args: [ doc2, 1, [ 'F', 'O', 'O', { type: '/paragraph' }, { type: 'paragraph' }, 'B', 'A', 'R' ] ], ops: [ { type: 'retain', length: 1 }, { type: 'replace', remove: [], insert: [ 'F', 'O', 'O', { type: '/paragraph' }, { type: 'paragraph' }, 'B', 'A', 'R' ], insertedDataOffset: 0, insertedDataLength: 8 }, { type: 'retain', length: 3 } ] }, 'inserting three paragraphs into a document with just an empty paragraph': { args: [ doc2, 1, [ 'F', 'O', 'O', { type: '/paragraph' }, { type: 'paragraph' }, 'B', 'A', 'R', { type: '/paragraph' }, { type: 'paragraph' }, 'B', 'A', 'Z' ] ], ops: [ { type: 'retain', length: 1 }, { type: 'replace', remove: [], insert: [ 'F', 'O', 'O', { type: '/paragraph' }, { type: 'paragraph' }, 'B', 'A', 'R', { type: '/paragraph' }, { type: 'paragraph' }, 'B', 'A', 'Z' ], insertedDataOffset: 0, insertedDataLength: 13 }, { type: 'retain', length: 3 } ] }, 'inserting one paragraph into empty paragraph moves insertion before': { args: [ doc2, 1, [ { type: 'paragraph' }, 'F', 'O', 'O', { type: '/paragraph' } ] ], ops: [ { type: 'replace', remove: [], insert: [ { type: 'paragraph' }, 'F', 'O', 'O', { type: '/paragraph' } ], insertedDataOffset: 0, insertedDataLength: 5 }, { type: 'retain', length: 4 } ], range: new ve.Range( 0, 5 ) }, 'inserting paragraph at end of paragraph moves insertion point forward': { args: [ doc3, 4, [ { type: 'paragraph' }, 'B', 'A', 'R', { type: '/paragraph' } ] ], ops: [ { type: 'retain', length: 5 }, { type: 'replace', remove: [], insert: [ { type: 'paragraph' }, 'B', 'A', 'R', { type: '/paragraph' } ], insertedDataOffset: 0, insertedDataLength: 5 }, { type: 'retain', length: 2 } ], range: new ve.Range( 5, 10 ) }, 'inserting paragraph into middle of paragraph splits paragraph': { args: [ doc3, 2, [ { type: 'paragraph' }, 'B', 'A', 'R', { type: '/paragraph' } ] ], ops: [ { type: 'retain', length: 2 }, { type: 'replace', remove: [], insert: [ { type: '/paragraph' }, { type: 'paragraph' }, 'B', 'A', 'R', { type: '/paragraph' }, { type: 'paragraph' } ], insertedDataOffset: 1, insertedDataLength: 5 }, { type: 'retain', length: 5 } ], range: new ve.Range( 3, 8 ) }, 'inserting paragraph into middle of list splits list': { args: [ isolationDoc, 11, [ { type: 'paragraph' }, 'B', 'A', 'R', { type: '/paragraph' } ] ], ops: [ { type: 'retain', length: 11 }, { type: 'replace', remove: [], insert: [ { type: '/list' }, { type: 'paragraph' }, 'B', 'A', 'R', { type: '/paragraph' }, { type: 'list', attributes: { style: 'bullet' } } ], insertedDataOffset: 1, insertedDataLength: 5 }, { type: 'retain', length: 235 } ], range: new ve.Range( 12, 17 ) }, 'inserting paragraph between table cells splits table, tableSection and tableRow': { args: [ complexTableDoc, 40, [ { type: 'paragraph' }, 'B', 'A', 'R', { type: '/paragraph' } ] ], ops: [ { type: 'retain', length: 40 }, { type: 'replace', remove: [], insert: [ { type: '/tableRow' }, { type: '/tableSection' }, { type: '/table' }, { type: 'paragraph' }, 'B', 'A', 'R', { 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 ) }, 'preserving trailing metadata': { args: [ listWithMetaDoc, 4, [ 'b' ] ], ops: [ { type: 'retain', length: 4 }, { type: 'replace', remove: [], insert: [ 'b' ], insertedDataOffset: 0, insertedDataLength: 1 }, { type: 'retain', length: 10 } ] } // 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) }; QUnit.expect( Object.keys( cases ).length * 2 ); for ( key in cases ) { for ( i = 0; i < cases[ key ].ops.length; i++ ) { if ( cases[ key ].ops[ i ].remove ) { ve.dm.example.preprocessAnnotations( cases[ key ].ops[ i ].remove, doc.getStore() ); } if ( cases[ key ].ops[ i ].insert ) { ve.dm.example.preprocessAnnotations( cases[ key ].ops[ i ].insert, doc.getStore() ); } } } runConstructorTests( assert, ve.dm.Transaction.newFromInsertion, cases, true ); } ); QUnit.test( 'newFromRemoval', function ( assert ) { var i, key, store, doc = ve.dm.example.createExampleDocument( 'data' ), alienDoc = ve.dm.example.createExampleDocument( 'alienData' ), alienWithEmptyDoc = ve.dm.example.createExampleDocument( 'alienWithEmptyData' ), metaDoc = ve.dm.example.createExampleDocument( 'withMeta' ), internalDoc = ve.dm.example.createExampleDocument( 'internalData' ), cases = { '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 } ] }, '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 } ] }, '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 } ] }, '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 } ] }, '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 } ] }, '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 } ] }, 'extra closings': { args: [ doc, new ve.Range( 31, 39 ) ], ops: [ { type: 'retain', length: 38 }, { type: 'replace', remove: [ 'h' ], insert: [] }, { type: 'retain', length: 24 } ] }, '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 } ] }, '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 } ] }, 'over first alien into paragraph': { args: [ alienDoc, new ve.Range( 0, 4 ) ], ops: [ { type: 'replace', remove: [ { type: 'alienBlock', originalDomElements: $( '<foobar />' ).toArray() }, { type: '/alienBlock' } ], insert: [] }, { type: 'retain', length: 1 }, { type: 'replace', remove: [ 'a' ], insert: [] }, { type: 'retain', length: alienDoc.data.getLength() - 4 } ] }, '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: $( '<foobar />' ).toArray() }, { type: '/alienBlock' } ], insert: [] }, { type: 'retain', length: alienDoc.data.getLength() - 10 } ] }, '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: $( '<foobar />' ).toArray() }, { type: '/alienInline' } ], insert: [] }, { type: 'retain', length: alienWithEmptyDoc.data.getLength() - 6 } ] }, '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 } ] }, '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 } ] }, '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 } ] }, '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 } ] }, 'removing content spanning metadata': { args: [ metaDoc, new ve.Range( 7, 9 ) ], ops: [ { type: 'retain', length: 7 }, { type: 'replace', remove: [ 'B', 'a' ], insert: [], removeMetadata: metaDoc.getMetadata().slice( 7, 9 ), insertMetadata: [] }, { type: 'replaceMetadata', remove: [], insert: ve.dm.MetaLinearData.static.merge( metaDoc.getMetadata().slice( 7, 9 ) )[ 0 ] }, { type: 'retain', length: 4 } ] }, 'selection including internal nodes doesn\'t remove them': { args: [ internalDoc, new ve.Range( 2, 24 ) ], ops: [ { type: 'retain', length: 2 }, { type: 'replace', remove: [ 'o', 'o', { type: '/paragraph' } ], insert: [] }, { type: 'retain', length: 16 }, { type: 'replace', remove: [ { type: 'paragraph' }, 'Q', 'u' ], insert: [] }, { type: 'retain', length: 3 } ] }, 'selection ending with internal nodes': { args: [ internalDoc, new ve.Range( 2, 21 ) ], ops: [ { type: 'retain', length: 2 }, { type: 'replace', remove: [ 'o', 'o' ], insert: [] }, { type: 'retain', length: 23 } ] }, 'selection starting with internal nodes': { args: [ internalDoc, new ve.Range( 5, 24 ) ], ops: [ { type: 'retain', length: 22 }, { type: 'replace', remove: [ 'Q', 'u' ], insert: [] }, { type: 'retain', length: 3 } ] }, 'selection of just internal nodes returns a no-op transaction': { args: [ internalDoc, new ve.Range( 5, 21 ) ], ops: [ { type: 'retain', length: 27 } ] } }; QUnit.expect( Object.keys( cases ).length ); for ( key in cases ) { for ( i = 0; i < cases[ key ].ops.length; i++ ) { store = cases[ key ].args[ 0 ].getStore(); if ( cases[ key ].ops[ i ].remove ) { ve.dm.example.preprocessAnnotations( cases[ key ].ops[ i ].remove, store ); } if ( cases[ key ].ops[ i ].insert ) { ve.dm.example.preprocessAnnotations( cases[ key ].ops[ i ].insert, store ); } } } runConstructorTests( assert, ve.dm.Transaction.newFromRemoval, cases ); } ); QUnit.test( 'newFromReplacement', function ( assert ) { var i, key, doc = ve.dm.example.createExampleDocument(), metaDoc = ve.dm.example.createExampleDocument( 'withMeta' ), cases = { 'replace, preserving metadata': { args: [ metaDoc, new ve.Range( 1, 9 ), [ 'X', 'Y' ] ], ops: [ { type: 'retain', length: 1 }, { type: 'replace', insert: [ 'X', 'Y' ], insertMetadata: [ metaDoc.getMetadata()[ 4 ].concat( metaDoc.getMetadata()[ 7 ] ), undefined ], insertedDataLength: 2, insertedDataOffset: 0, remove: [ 'F', 'o', 'o', 'B', 'a', 'r', 'B', 'a' ], removeMetadata: metaDoc.getMetadata().slice( 1, 9 ) }, { type: 'retain', length: 4 } ] }, 'replace, removing metadata': { args: [ metaDoc, new ve.Range( 1, 9 ), [ 'X', 'Y' ], true ], ops: [ { type: 'retain', length: 1 }, { type: 'replace', insert: [ 'X', 'Y' ], insertMetadata: [ undefined, undefined ], insertedDataLength: 2, insertedDataOffset: 0, remove: [ 'F', 'o', 'o', 'B', 'a', 'r', 'B', 'a' ], removeMetadata: metaDoc.getMetadata().slice( 1, 9 ) }, { type: 'retain', length: 4 } ] } }; QUnit.expect( Object.keys( cases ).length ); for ( key in cases ) { for ( i = 0; i < cases[ key ].ops.length; i++ ) { if ( cases[ key ].ops[ i ].remove ) { ve.dm.example.preprocessAnnotations( cases[ key ].ops[ i ].remove, doc.getStore() ); } if ( cases[ key ].ops[ i ].insert ) { ve.dm.example.preprocessAnnotations( cases[ key ].ops[ i ].insert, doc.getStore() ); } } } runConstructorTests( assert, ve.dm.Transaction.newFromReplacement, cases, false ); } ); QUnit.test( 'newFromDocumentInsertion', function ( assert ) { var i, j, doc2, store2, tx, actualStoreItems, expectedStoreItems, removalOps, doc = ve.dm.example.createExampleDocument( 'internalData' ), bold = ve.dm.example.createAnnotation( ve.dm.example.bold ), whee = [ { type: 'paragraph' }, 'W', 'h', 'e', 'e', { type: '/paragraph' } ], wheeItem = [ { type: 'internalItem' } ].concat( whee ).concat( [ { type: '/internalItem' } ] ), cases = [ { msg: 'simple insertion', doc: 'internalData', offset: 7, range: new ve.Range( 7, 12 ), modify: function ( newDoc ) { // Change "Bar" to "Bazaar" newDoc.commit( ve.dm.Transaction.newFromInsertion( newDoc, 3, [ 'z', 'a', 'a' ] ) ); }, 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 ) ) .concat( doc.getData( new ve.Range( 12, 20 ) ) ), insert: doc.getData( new ve.Range( 6, 10 ) ) .concat( [ 'z', 'a', 'a' ] ) .concat( 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: function ( newDoc ) { // Bold the first two characters newDoc.commit( ve.dm.Transaction.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 ) ) .concat( doc.getData( new ve.Range( 19, 20 ) ) ), insert: doc.getData( new ve.Range( 6, 15 ) ) .concat( [ [ doc.data.getData( 15 ), [ ve.dm.example.boldIndex ] ] ] ) .concat( [ [ doc.data.getData( 16 ), [ ve.dm.example.boldIndex ] ] ] ) .concat( doc.getData( new ve.Range( 17, 20 ) ) ) }, { type: 'retain', length: 7 } ], expectedStoreItems: { h49981eab0f8056ff: bold } }, { msg: 'insertion into internal list', doc: 'internalData', offset: 21, range: new ve.Range( 21, 27 ), modify: function ( newDoc ) { var 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 ) ).concat( 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: function ( newDoc ) { newDoc.commit( ve.dm.Transaction.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 ) ) .concat( [ '!', '!', '!' ] ) .concat( 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: function ( newDoc ) { var 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 ) ) .concat( doc.getData( new ve.Range( 12, 20 ) ) ), insert: doc.getData( new ve.Range( 6, 20 ) ).concat( wheeItem ) }, { type: 'retain', length: 7 } ] }, { msg: 'insertion from unrelated document', doc: 'internalData', offset: 0, newDocData: [ { type: 'paragraph' }, 'F', 'o', 'o', { type: '/paragraph' }, { type: 'internalList' }, { type: '/internalList' } ], removalOps: [], expectedOps: [ { type: 'replace', remove: [], insert: [ { type: 'paragraph' }, 'F', 'o', 'o', { type: '/paragraph' } ] }, { type: 'retain', length: 6 }, { type: 'replace', remove: doc.getData( new ve.Range( 6, 20 ) ), insert: doc.getData( new ve.Range( 6, 20 ) ) }, { type: 'retain', length: 7 } ] }, { 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.boldIndex ] ], 'o', { type: '/paragraph' } ] }, { type: 'retain', length: 6 }, { type: 'replace', remove: doc.getData( new ve.Range( 6, 20 ) ), insert: doc.getData( new ve.Range( 6, 20 ) ) }, { type: 'retain', length: 7 } ], expectedStoreItems: { h49981eab0f8056ff: bold } } ]; QUnit.expect( cases.length * 3 ); for ( i = 0; i < cases.length; i++ ) { doc = ve.dm.example.createExampleDocument( cases[ i ].doc ); if ( cases[ i ].newDocData ) { store2 = new ve.dm.IndexValueStore(); doc2 = new ve.dm.Document( ve.dm.example.preprocessAnnotations( cases[ i ].newDocData, store2 ) ); removalOps = []; } else if ( cases[ i ].range ) { doc2 = doc.cloneFromRange( cases[ i ].range ); cases[ i ].modify( doc2 ); tx = ve.dm.Transaction.newFromRemoval( doc, cases[ i ].range ); doc.commit( tx ); removalOps = tx.getOperations(); } assert.deepEqualWithDomElements( removalOps, cases[ i ].removalOps, cases[ i ].msg + ': removal' ); tx = ve.dm.Transaction.newFromDocumentInsertion( doc, cases[ i ].offset, doc2 ); assert.deepEqualWithDomElements( tx.getOperations(), cases[ i ].expectedOps, cases[ i ].msg + ': transaction' ); actualStoreItems = {}; expectedStoreItems = cases[ i ].expectedStoreItems || {}; for ( j in expectedStoreItems ) { actualStoreItems[ j ] = doc.store.value( j ); expectedStoreItems[ j ].store = store2; } assert.deepEqual( actualStoreItems, expectedStoreItems, cases[ i ].msg + ': store items' ); } } ); QUnit.test( 'newFromAttributeChanges', function ( assert ) { var doc = ve.dm.example.createExampleDocument(), cases = { 'first element': { args: [ doc, 0, { level: 2 } ], ops: [ { type: 'attribute', key: 'level', from: 1, to: 2 }, { type: 'retain', length: 63 } ] }, 'middle element': { args: [ doc, 17, { style: 'number' } ], ops: [ { type: 'retain', length: 17 }, { type: 'attribute', key: 'style', from: 'bullet', to: 'number' }, { type: 'retain', length: 46 } ] }, '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 } ] }, 'non-element': { args: [ doc, 1, { level: 2 } ], exception: Error }, 'closing element': { args: [ doc, 4, { level: 2 } ], exception: Error } }; QUnit.expect( Object.keys( cases ).length ); runConstructorTests( assert, ve.dm.Transaction.newFromAttributeChanges, cases ); } ); QUnit.test( 'newFromAnnotation', function ( assert ) { var 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' }, [ 'F', [ ve.dm.example.bold ] ], [ 'o', [ ve.dm.example.bold ] ], [ 'o', [ ve.dm.example.bold ] ], [ 'B', [ strong ] ], [ 'a', [ strong ] ], [ 'r', [ strong ] ], { type: '/paragraph' }, { type: 'internalList' }, { type: '/internalList' } ] ), annotationDoc = ve.dm.example.createExampleDocument( 'annotationData' ), cases = { 'over plain text': { args: [ doc, new ve.Range( 1, 2 ), 'set', boldAnnotation ], ops: [ { type: 'retain', length: 1 }, { type: 'annotate', method: 'set', bias: 'start', index: ve.dm.example.boldIndex }, { type: 'retain', length: 1 }, { type: 'annotate', method: 'set', bias: 'stop', index: ve.dm.example.boldIndex }, { type: 'retain', length: 61 } ] }, 'over partially annotated text': { args: [ doc, new ve.Range( 1, 4 ), 'set', boldAnnotation ], ops: [ { type: 'retain', length: 1 }, { type: 'annotate', method: 'set', bias: 'start', index: ve.dm.example.boldIndex }, { type: 'retain', length: 1 }, { type: 'annotate', method: 'set', bias: 'stop', index: ve.dm.example.boldIndex }, { type: 'retain', length: 1 }, { type: 'annotate', method: 'set', bias: 'start', index: ve.dm.example.boldIndex }, { type: 'retain', length: 1 }, { type: 'annotate', method: 'set', bias: 'stop', index: ve.dm.example.boldIndex }, { type: 'retain', length: 59 } ] }, 'comparable annotation over partially annotated text': { args: [ doc, new ve.Range( 1, 4 ), 'set', strongAnnotation ], ops: [ { type: 'retain', length: 1 }, { type: 'annotate', method: 'set', bias: 'start', index: ve.dm.example.strongIndex }, { type: 'retain', length: 1 }, { type: 'annotate', method: 'set', bias: 'stop', index: ve.dm.example.strongIndex }, { type: 'retain', length: 1 }, { type: 'annotate', method: 'set', bias: 'start', index: ve.dm.example.strongIndex }, { type: 'retain', length: 1 }, { type: 'annotate', method: 'set', bias: 'stop', index: ve.dm.example.strongIndex }, { type: 'retain', length: 59 } ] }, 'adjacent comparable annotations not cleared together': { args: [ doc2, new ve.Range( 1, 7 ), 'clear', strongAnnotation ], ops: [ { type: 'retain', length: 4 }, { type: 'annotate', method: 'clear', bias: 'start', index: ve.dm.example.strongIndex }, { type: 'retain', length: 3 }, { type: 'annotate', method: 'clear', bias: 'stop', index: ve.dm.example.strongIndex }, { type: 'retain', length: 3 } ] }, 'over elements': { args: [ doc, new ve.Range( 4, 9 ), 'set', boldAnnotation ], ops: [ { type: 'retain', length: 63 } ] }, 'over elements and content': { args: [ doc, new ve.Range( 3, 11 ), 'set', boldAnnotation ], ops: [ { type: 'retain', length: 3 }, { type: 'annotate', method: 'set', bias: 'start', index: ve.dm.example.boldIndex }, { type: 'retain', length: 1 }, { type: 'annotate', method: 'set', bias: 'stop', index: ve.dm.example.boldIndex }, { type: 'retain', length: 6 }, { type: 'annotate', method: 'set', bias: 'start', index: ve.dm.example.boldIndex }, { type: 'retain', length: 1 }, { type: 'annotate', method: 'set', bias: 'stop', index: ve.dm.example.boldIndex }, { type: 'retain', length: 52 } ] }, 'over content and content element (image)': { args: [ doc, new ve.Range( 38, 42 ), 'set', boldAnnotation ], ops: [ { type: 'retain', length: 38 }, { type: 'annotate', method: 'set', bias: 'start', index: ve.dm.example.boldIndex }, { type: 'retain', length: 4 }, { type: 'annotate', method: 'set', bias: 'stop', index: ve.dm.example.boldIndex }, { type: 'retain', length: 21 } ] }, 'over content and unannotatable content element (unboldable node)': { args: [ annotationDoc, new ve.Range( 1, 9 ), 'set', boldAnnotation ], ops: [ { type: 'retain', length: 1 }, { type: 'annotate', method: 'set', bias: 'start', index: ve.dm.example.boldIndex }, { type: 'retain', length: 3 }, { type: 'annotate', method: 'set', bias: 'stop', index: ve.dm.example.boldIndex }, { type: 'retain', length: 2 }, { type: 'annotate', method: 'set', bias: 'start', index: ve.dm.example.boldIndex }, { type: 'retain', length: 3 }, { type: 'annotate', method: 'set', bias: 'stop', index: ve.dm.example.boldIndex }, { type: 'retain', length: 21 } ] }, 'over handles own children nodes': { args: [ annotationDoc, new ve.Range( 1, 27 ), 'set', boldAnnotation ], ops: [ { type: 'retain', length: 1 }, { type: 'annotate', method: 'set', bias: 'start', index: ve.dm.example.boldIndex }, { type: 'retain', length: 3 }, { type: 'annotate', method: 'set', bias: 'stop', index: ve.dm.example.boldIndex }, { type: 'retain', length: 2 }, { type: 'annotate', method: 'set', bias: 'start', index: ve.dm.example.boldIndex }, { type: 'retain', length: 3 }, { type: 'annotate', method: 'set', bias: 'stop', index: ve.dm.example.boldIndex }, { type: 'retain', length: 15 }, { type: 'annotate', method: 'set', bias: 'start', index: ve.dm.example.boldIndex }, { type: 'retain', length: 3 }, { type: 'annotate', method: 'set', bias: 'stop', index: ve.dm.example.boldIndex }, { type: 'retain', length: 3 } ] } }; QUnit.expect( Object.keys( cases ).length ); runConstructorTests( assert, ve.dm.Transaction.newFromAnnotation, cases ); } ); QUnit.test( 'newFromContentBranchConversion', function ( assert ) { var i, key, store, doc = ve.dm.example.createExampleDocument(), doc2 = ve.dm.example.createExampleDocument( 'inlineAtEdges' ), cases = { '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 } ] }, '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 } ] }, '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 } ] }, '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 } ] }, '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 } ] }, '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 } ] }, '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 } ] }, '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 } ] }, '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 } ] } }; QUnit.expect( Object.keys( cases ).length ); for ( key in cases ) { for ( i = 0; i < cases[ key ].ops.length; i++ ) { store = cases[ key ].args[ 0 ].getStore(); if ( cases[ key ].ops[ i ].remove ) { ve.dm.example.preprocessAnnotations( cases[ key ].ops[ i ].remove, store ); } if ( cases[ key ].ops[ i ].insert ) { ve.dm.example.preprocessAnnotations( cases[ key ].ops[ i ].insert, store ); } } } runConstructorTests( assert, ve.dm.Transaction.newFromContentBranchConversion, cases ); } ); QUnit.test( 'newFromWrap', function ( assert ) { var i, key, doc = ve.dm.example.createExampleDocument(), metaDoc = ve.dm.example.createExampleDocument( 'withMeta' ), listMetaDoc = ve.dm.example.createExampleDocument( 'listWithMeta' ), listDoc = ve.dm.example.createExampleDocumentFromObject( 'listDoc', null, { listDoc: listMetaDoc.getData() } ), cases = { '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 } ] }, '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 } ] }, '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 } ] }, '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 } ] }, '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 } ] }, '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 } ] }, 'metadata is preserved on wrap': { args: [ metaDoc, new ve.Range( 1, 10 ), [ { type: 'paragraph' } ], [ { type: 'heading', level: 1 } ], [], [] ], ops: [ { type: 'replace', remove: [ { type: 'paragraph' } ], insert: [ { type: 'heading', level: 1 } ], insertMetadata: metaDoc.getMetadata().slice( 0, 1 ), removeMetadata: metaDoc.getMetadata().slice( 0, 1 ) }, { type: 'retain', length: 9 }, { type: 'replace', remove: [ { type: '/paragraph' } ], insert: [ { type: '/heading' } ] }, { type: 'retain', length: 2 } ] }, 'metadata is preserved on unwrap': { args: [ listMetaDoc, new ve.Range( 1, 11 ), [ { type: 'list' } ], [], [ { type: 'listItem', attributes: { styles: [ 'bullet' ] } } ], [] ], ops: [ { type: 'replace', remove: [ { type: 'list' }, { type: 'listItem', attributes: { styles: [ 'bullet' ] } } ], insert: [], insertMetadata: [], removeMetadata: listMetaDoc.getMetadata().slice( 0, 2 ) }, { type: 'replaceMetadata', insert: ve.dm.MetaLinearData.static.merge( listMetaDoc.getMetadata().slice( 0, 2 ) )[ 0 ], remove: [] }, { type: 'retain', length: 3 }, { type: 'replace', remove: [ { type: '/listItem' }, { type: 'listItem', attributes: { styles: [ 'bullet' ] } } ], insert: [], insertMetadata: [], removeMetadata: listMetaDoc.getMetadata().slice( 5, 7 ) }, { type: 'replaceMetadata', insert: ve.dm.MetaLinearData.static.merge( listMetaDoc.getMetadata().slice( 5, 7 ) )[ 0 ], remove: [] }, { type: 'retain', length: 3 }, { type: 'replace', remove: [ { type: '/listItem' }, { type: '/list' } ], insert: [], insertMetadata: [], removeMetadata: listMetaDoc.getMetadata().slice( 10, 12 ) }, { type: 'replaceMetadata', insert: ve.dm.MetaLinearData.static.merge( listMetaDoc.getMetadata().slice( 10, 12 ) )[ 0 ], remove: [] }, { type: 'retain', length: 2 } ] }, 'checks integrity of unwrapOuter parameter': { args: [ doc, new ve.Range( 13, 32 ), [ { type: 'table' } ], [], [], [] ], exception: Error }, 'checks integrity of unwrapEach parameter': { args: [ doc, new ve.Range( 13, 32 ), [ { type: 'list' } ], [], [ { type: 'paragraph' } ], [] ], exception: Error }, 'checks that unwrapOuter fits before the range': { args: [ doc, new ve.Range( 1, 4 ), [ { type: 'listItem' }, { type: 'paragraph' } ], [], [], [] ], exception: Error } }; QUnit.expect( Object.keys( cases ).length ); for ( key in cases ) { for ( i = 0; cases[ key ].ops && i < cases[ key ].ops.length; i++ ) { if ( cases[ key ].ops[ i ].remove ) { ve.dm.example.preprocessAnnotations( cases[ key ].ops[ i ].remove, doc.getStore() ); } if ( cases[ key ].ops[ i ].insert ) { ve.dm.example.preprocessAnnotations( cases[ key ].ops[ i ].insert, doc.getStore() ); } } } runConstructorTests( assert, ve.dm.Transaction.newFromWrap, cases ); } ); QUnit.test( 'translateOffset', function ( assert ) { var mapping, offset, expected, doc = new ve.dm.Document( '-----defg---h--'.split( '' ) ), tx = new ve.dm.Transaction(); tx.pushReplace( doc, 0, 0, [ 'a', 'b', 'c' ] ); tx.pushRetain( 5 ); tx.pushReplace( doc, 5, 4, [] ); tx.pushRetain( 2 ); tx.pushStartAnnotating( 'set', { type: 'textStyle/bold' } ); tx.pushRetain( 1 ); tx.pushReplace( doc, 12, 1, [ 'i', 'j', 'k', 'l', 'm' ] ); tx.pushRetain( 2 ); tx.pushReplace( doc, 15, 0, [ 'n', 'o', 'p' ] ); mapping = { 0: [ 0, 3 ], 1: 4, 2: 5, 3: 6, 4: 7, 5: 8, 6: 8, 7: 8, 8: 8, 9: 8, 10: 9, 11: 10, 12: 11, 13: [ 12, 16 ], 14: 17, 15: [ 18, 21 ], 16: 22 }; QUnit.expect( 2 * Object.keys( mapping ).length ); for ( offset in mapping ) { expected = Array.isArray( mapping[ offset ] ) ? mapping[ offset ] : [ mapping[ offset ], mapping[ offset ] ]; assert.strictEqual( tx.translateOffset( Number( offset ) ), expected[ 1 ], offset ); assert.strictEqual( tx.translateOffset( Number( offset ), true ), expected[ 0 ], offset + ' (excludeInsertion)' ); } } ); QUnit.test( 'translateRange', function ( assert ) { var i, cases, doc = ve.dm.example.createExampleDocument(), tx = new ve.dm.Transaction(); tx.pushRetain( 55 ); tx.pushReplace( doc, 55, 0, [ { type: 'list', attributes: { style: 'number' } } ] ); tx.pushReplace( doc, 55, 0, [ { type: 'listItem' } ] ); tx.pushRetain( 3 ); tx.pushReplace( doc, 58, 0, [ { type: '/listItem' } ] ); tx.pushReplace( doc, 58, 0, [ { type: 'listItem' } ] ); tx.pushRetain( 3 ); tx.pushReplace( doc, 61, 0, [ { type: '/listItem' } ] ); tx.pushReplace( doc, 61, 0, [ { type: '/list' } ] ); cases = [ { before: new ve.Range( 55, 61 ), after: new ve.Range( 55, 67 ), msg: 'Wrapped range is translated to outer range' }, { before: new ve.Range( 54, 62 ), after: new ve.Range( 54, 68 ), msg: 'Wrapped range plus one each side is translated to outer range plus one each side' }, { before: new ve.Range( 54, 61 ), after: new ve.Range( 54, 67 ), msg: 'Wrapped range plus one on the left' }, { before: new ve.Range( 55, 62 ), after: new ve.Range( 55, 68 ), msg: 'wrapped range plus one on the right' } ]; QUnit.expect( cases.length * 2 ); for ( i = 0; i < cases.length; i++ ) { assert.equalRange( tx.translateRange( cases[ i ].before ), cases[ i ].after, cases[ i ].msg ); assert.equalRange( tx.translateRange( cases[ i ].before.flip() ), cases[ i ].after.flip(), cases[ i ].msg + ' (reversed)' ); } } ); QUnit.test( 'getModifiedRange', function ( assert ) { var i, j, len, tx, doc = ve.dm.example.createExampleDocument(), cases = [ { calls: [ [ 'pushRetain', 5 ] ], range: null, msg: 'no-op transaction returns null' }, { calls: [ [ 'pushRetain', 5 ], [ 'pushReplace', doc, 5, 0, [ 'a', 'b', 'c' ] ], [ 'pushRetain', 42 ] ], range: new ve.Range( 5, 8 ), msg: 'simple insertion returns range covering new content' }, { calls: [ [ 'pushRetain', 5 ], [ 'pushReplace', doc, 5, 13, [] ], [ 'pushRetain', 42 ] ], range: new ve.Range( 5 ), msg: 'simple removal returns zero-length range at removal' }, { calls: [ [ 'pushRetain', 5 ], [ 'pushReplace', doc, 5, 3, [ 'a', 'b', 'c', 'd' ] ], [ 'pushRetain', 42 ] ], range: new ve.Range( 5, 9 ), msg: 'simple replacement returns range covering new content (1)' }, { calls: [ [ 'pushRetain', 5 ], [ 'pushReplace', doc, 5, 13, [ 'a', 'b', 'c', 'd' ] ], [ 'pushRetain', 42 ] ], range: new ve.Range( 5, 9 ), msg: 'simple replacement returns range covering new content (2)' }, { calls: [ [ 'pushRetain', 5 ], [ 'pushReplace', doc, 5, 3, [] ], [ 'pushRetain', 42 ], [ 'pushReplace', doc, 50, 0, [ 'h', 'e', 'l', 'l', 'o' ] ], [ 'pushRetain', 108 ] ], range: new ve.Range( 5, 52 ), msg: 'range covers two operations with retain in between' }, { calls: [ [ 'pushRetain', 5 ], [ 'pushReplace', doc, 5, 3, [ 'a', 'b', 'c', 'd' ] ], [ 'pushRetain', 54 ], [ 'pushReplace', doc, 62, 0, [ 'h', 'e', 'l', 'l', 'o' ] ], [ 'pushRetain', 1 ] ], range: new ve.Range( 5, 9 ), msg: 'range ignores internalList changes' }, { calls: [ [ 'pushReplace', doc, 0, 3, [] ] ], range: new ve.Range( 0 ), msg: 'removal without retains' }, { calls: [ [ 'pushReplace', doc, 0, 0, [ 'a', 'b', 'c' ] ] ], range: new ve.Range( 0, 3 ), msg: 'insertion without retains' }, { calls: [ [ 'pushRetain', 5 ], [ 'pushReplaceElementAttribute', 'style', 'bullet', 'number' ] ], range: new ve.Range( 5 ), msg: 'single attribute change' }, { calls: [ [ 'pushReplaceElementAttribute', 'style', 'bullet', 'number' ], [ 'pushRetain', 42 ], [ 'pushReplaceElementAttribute', 'style', 'bullet', 'number' ] ], range: new ve.Range( 0, 42 ), msg: 'two attribute changes' } ]; QUnit.expect( cases.length ); for ( i = 0, len = cases.length; i < len; i++ ) { tx = new ve.dm.Transaction(); for ( j = 0; j < cases[ i ].calls.length; j++ ) { tx[ cases[ i ].calls[ j ][ 0 ] ].apply( tx, cases[ i ].calls[ j ].slice( 1 ) ); } assert.equalRange( tx.getModifiedRange( doc ), cases[ i ].range, cases[ i ].msg ); } } ); QUnit.test( 'pushRetain', 2, function ( assert ) { var cases = { retain: { calls: [ [ 'pushRetain', 5 ] ], ops: [ { type: 'retain', length: 5 } ], diff: 0 }, 'multiple retain': { calls: [ [ 'pushRetain', 5 ], [ 'pushRetain', 3 ] ], ops: [ { type: 'retain', length: 8 } ], diff: 0 } }; runBuilderTests( assert, cases ); } ); QUnit.test( 'pushReplace', function ( assert ) { var doc = new ve.dm.Document( [ { type: 'paragraph' }, 'a', 'b', 'c', { type: '/paragraph' } ] ), doc2 = new ve.dm.Document( [ { type: 'paragraph' }, 'a', 'b', 'c', 'g', 'h', 'i', { type: '/paragraph' } ] ), cases = { insert: { calls: [ [ 'pushReplace', doc, 0, 0, [ { type: 'paragraph' }, 'a', 'b', 'c', { type: '/paragraph' } ] ] ], ops: [ { type: 'replace', remove: [], insert: [ { type: 'paragraph' }, 'a', 'b', 'c', { type: '/paragraph' } ] } ], diff: 5 }, 'multiple insert': { calls: [ [ 'pushReplace', doc, 0, 0, [ { type: 'paragraph' }, 'a', 'b' ] ], [ 'pushReplace', doc, 0, 0, [ 'c', { type: '/paragraph' } ] ] ], ops: [ { type: 'replace', remove: [], insert: [ { type: 'paragraph' }, 'a', 'b', 'c', { type: '/paragraph' } ] } ], diff: 5 }, 'insert and retain': { calls: [ [ 'pushRetain', 1 ], [ 'pushReplace', doc, 0, 0, [ 'a', 'b', 'c' ] ] ], ops: [ { type: 'retain', length: 1 }, { type: 'replace', remove: [], insert: [ 'a', 'b', 'c' ] } ], diff: 3 }, remove: { calls: [ [ 'pushReplace', doc, 0, 5, [] ] ], ops: [ { type: 'replace', remove: [ { type: 'paragraph' }, 'a', 'b', 'c', { type: '/paragraph' } ], insert: [] } ], diff: -5 }, 'multiple remove': { calls: [ [ 'pushReplace', doc, 0, 3, [] ], [ 'pushReplace', doc, 3, 2, [] ] ], ops: [ { type: 'replace', remove: [ { type: 'paragraph' }, 'a', 'b', 'c', { type: '/paragraph' } ], insert: [] } ], diff: -5 }, 'retain and remove': { calls: [ [ 'pushRetain', 1 ], [ 'pushReplace', doc, 1, 3, [] ] ], ops: [ { type: 'retain', length: 1 }, { type: 'replace', remove: [ 'a', 'b', 'c' ], insert: [] } ], diff: -3 }, replace: { calls: [ [ 'pushReplace', doc, 1, 3, [ 'd', 'e', 'f' ] ] ], ops: [ { type: 'replace', remove: [ 'a', 'b', 'c' ], insert: [ 'd', 'e', 'f' ] } ], diff: 0 }, 'multiple replace': { calls: [ [ 'pushReplace', doc2, 1, 3, [ 'd', 'e', 'f' ] ], [ 'pushReplace', doc2, 4, 3, [ 'j', 'k', 'l' ] ] ], ops: [ { type: 'replace', remove: [ 'a', 'b', 'c', 'g', 'h', 'i' ], insert: [ 'd', 'e', 'f', 'j', 'k', 'l' ] } ], diff: 0 } }; QUnit.expect( Object.keys( cases ).length ); runBuilderTests( assert, cases ); } ); QUnit.test( 'pushReplaceElementAttribute', function ( assert ) { var cases = { 'replace element attribute': { calls: [ [ 'pushReplaceElementAttribute', 'style', 'bullet', 'number' ] ], ops: [ { type: 'attribute', key: 'style', from: 'bullet', to: 'number' } ], diff: 0 }, '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 } }; QUnit.expect( Object.keys( cases ).length ); runBuilderTests( assert, cases ); } ); QUnit.test( 'push*Annotating', function ( assert ) { var cases = { 'start annotating': { calls: [ [ 'pushStartAnnotating', 'set', 0 ] ], ops: [ { type: 'annotate', method: 'set', bias: 'start', index: 0 } ], diff: 0 }, 'stop annotating': { calls: [ [ 'pushStopAnnotating', 'set', 0 ] ], ops: [ { type: 'annotate', method: 'set', bias: 'stop', index: 0 } ], diff: 0 }, 'start multiple annotations': { calls: [ [ 'pushStartAnnotating', 'set', 0 ], [ 'pushStartAnnotating', 'set', 1 ] ], ops: [ { type: 'annotate', method: 'set', bias: 'start', index: 0 }, { type: 'annotate', method: 'set', bias: 'start', index: 1 } ], diff: 0 }, 'stop multiple annotations': { calls: [ [ 'pushStopAnnotating', 'set', 0 ], [ 'pushStopAnnotating', 'set', 1 ] ], ops: [ { type: 'annotate', method: 'set', bias: 'stop', index: 0 }, { type: 'annotate', method: 'set', bias: 'stop', index: 1 } ], diff: 0 } }; QUnit.expect( Object.keys( cases ).length ); runBuilderTests( assert, cases ); } ); QUnit.test( 'newFromMetadataInsertion', function ( assert ) { var doc = ve.dm.example.createExampleDocument( 'withMeta' ), listWithMetaDoc = ve.dm.example.createExampleDocument( 'listWithMeta' ), element = { type: 'alienMeta', attributes: { style: 'comment', text: ' inline ' } }, cases = { 'inserting metadata element into existing element list': { args: [ doc, 11, 2, [ element ] ], ops: [ { type: 'retain', length: 11 }, { type: 'retainMetadata', length: 2 }, { type: 'replaceMetadata', remove: [], insert: [ element ] }, { type: 'retainMetadata', length: 2 }, { type: 'retain', length: 2 } ] }, 'inserting metadata element into empty list': { args: [ doc, 3, 0, [ element ] ], ops: [ { type: 'retain', length: 3 }, { type: 'replaceMetadata', remove: [], insert: [ element ] }, { type: 'retain', length: 10 } ] }, 'inserting trailing metadata (1)': { args: [ listWithMetaDoc, 12, 0, [ element ] ], ops: [ { type: 'retain', length: 12 }, { type: 'replaceMetadata', remove: [], insert: [ element ] }, { type: 'retainMetadata', length: 1 }, { type: 'retain', length: 2 } ] }, 'inserting trailing metadata (2)': { args: [ listWithMetaDoc, 12, 1, [ element ] ], ops: [ { type: 'retain', length: 12 }, { type: 'retainMetadata', length: 1 }, { type: 'replaceMetadata', remove: [], insert: [ element ] }, { type: 'retain', length: 2 } ] } }; QUnit.expect( Object.keys( cases ).length ); runConstructorTests( assert, ve.dm.Transaction.newFromMetadataInsertion, cases ); } ); QUnit.test( 'newFromMetadataRemoval', function ( assert ) { var doc = ve.dm.example.createExampleDocument( 'withMeta' ), listWithMetaDoc = ve.dm.example.createExampleDocument( 'listWithMeta' ), allElements = ve.dm.example.withMetaMetaData[ 11 ], someElements = allElements.slice( 1, 3 ), cases = { 'removing all metadata elements from metadata list': { args: [ doc, 11, new ve.Range( 0, 4 ) ], ops: [ { type: 'retain', length: 11 }, { type: 'replaceMetadata', remove: allElements, insert: [] }, { type: 'retain', length: 2 } ] }, 'removing some metadata elements from metadata list': { args: [ doc, 11, new ve.Range( 1, 3 ) ], ops: [ { type: 'retain', length: 11 }, { type: 'retainMetadata', length: 1 }, { type: 'replaceMetadata', remove: someElements, insert: [] }, { type: 'retainMetadata', length: 1 }, { type: 'retain', length: 2 } ] }, 'removing trailing metadata': { args: [ listWithMetaDoc, 12, new ve.Range( 0, 1 ) ], ops: [ { type: 'retain', length: 12 }, { type: 'replaceMetadata', remove: [ { type: 'alienMeta', originalDomElements: $( '<meta property="thirteen" />' ).toArray() } ], insert: [] }, { type: 'retain', length: 2 } ] }, 'checks metadata at offset is non-empty': { args: [ doc, 5, new ve.Range( 1, 3 ) ], exception: Error }, 'checks range is valid for metadata at offset': { args: [ doc, 11, new ve.Range( 1, 5 ) ], exception: Error } }; QUnit.expect( Object.keys( cases ).length ); runConstructorTests( assert, ve.dm.Transaction.newFromMetadataRemoval, cases ); } ); QUnit.test( 'newFromMetadataElementReplacement', function ( assert ) { var doc = ve.dm.example.createExampleDocument( 'withMeta' ), listWithMetaDoc = ve.dm.example.createExampleDocument( 'listWithMeta' ), newElement = { type: 'alienMeta', attributes: { style: 'comment', text: ' inline ' } }, oldElement = ve.dm.example.withMetaMetaData[ 11 ][ 3 ], cases = { 'replacing metadata at end of list': { args: [ doc, 11, 3, newElement ], ops: [ { type: 'retain', length: 11 }, { type: 'retainMetadata', length: 3 }, { type: 'replaceMetadata', remove: [ oldElement ], insert: [ newElement ] }, { type: 'retain', length: 2 } ] }, 'replacing trailing metadata': { args: [ listWithMetaDoc, 12, 0, newElement ], ops: [ { type: 'retain', length: 12 }, { type: 'replaceMetadata', remove: [ listWithMetaDoc.metadata.getData( 12 )[ 0 ] ], insert: [ newElement ] }, { type: 'retain', length: 2 } ] }, 'checks offset is in bounds': { args: [ doc, 15, 0, newElement ], exception: Error }, 'checks metadata index is in bounds': { args: [ doc, 11, 5, newElement ], exception: Error } }; QUnit.expect( Object.keys( cases ).length ); runConstructorTests( assert, ve.dm.Transaction.newFromMetadataElementReplacement, cases ); } ); QUnit.test( 'isNoOp', function ( assert ) { var tx, doc = ve.dm.example.createExampleDocument(), metaDoc = ve.dm.example.createExampleDocument( 'withMeta' ), listMetaDoc = ve.dm.example.createExampleDocument( 'listWithMeta' ); QUnit.expect( 3 * 8 - 2 ); [ doc, metaDoc, listMetaDoc ].forEach( function ( d, i ) { var isDoc = ( i === 0 ), isListMetaDoc = ( i === 2 ); tx = ve.dm.Transaction.newFromReplacement( d, new ve.Range( 1 ), [], false ); assert.strictEqual( tx.isNoOp(), true ); tx = ve.dm.Transaction.newFromInsertion( d, 1, [] ); assert.strictEqual( tx.isNoOp(), true ); tx = ve.dm.Transaction.newFromRemoval( d, new ve.Range( 1 ), false ); assert.strictEqual( tx.isNoOp(), true ); if ( !isListMetaDoc ) { tx = ve.dm.Transaction.newFromDocumentInsertion( d, 1, ve.dm.example.createExampleDocument(), new ve.Range( 0 ) ); assert.strictEqual( tx.isNoOp(), true ); } tx = ve.dm.Transaction.newFromAttributeChanges( d, isListMetaDoc ? 1 : 0, {} ); assert.strictEqual( tx.isNoOp(), true ); tx = ve.dm.Transaction.newFromAnnotation( d, new ve.Range( 1 ), 'set', new ve.dm.ItalicAnnotation() ); assert.strictEqual( tx.isNoOp(), true ); tx = ve.dm.Transaction.newFromMetadataInsertion( d, 1, 0, [] ); assert.strictEqual( tx.isNoOp(), true ); if ( !isDoc ) { tx = ve.dm.Transaction.newFromMetadataRemoval( d, 0, new ve.Range( 1 ) ); assert.strictEqual( tx.isNoOp(), true ); } // metadata replacement never creates no-op } ); } ); QUnit.test( 'toJSON/build with operations', function ( assert ) { var i, tx, ops, 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 ), [ 'F', 'o', 'o' ] ], expected: [ { type: 'retain', length: 1 }, { type: 'replace', remove: [ 'a', [ 'b', [ ve.dm.example.boldIndex ] ], [ 'c', [ ve.dm.example.italicIndex ] ] ], insert: [ 'F', 'o', 'o' ], insertedDataOffset: 0, insertedDataLength: 3 }, { 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' } ], insertedDataOffset: 0, insertedDataLength: 2 }, { 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' } ], insertedDataOffset: 0, insertedDataLength: 2 }, { type: 'retain', length: 63 } ] }, { msg: 'Add annotation', method: 'newFromAnnotation', args: [ doc, new ve.Range( 1, 4 ), 'set', underline ], expected: [ { type: 'retain', length: 1 }, { type: 'annotate', method: 'set', bias: 'start', index: ve.dm.example.underlineIndex }, { type: 'retain', length: 3 }, { type: 'annotate', method: 'set', bias: 'stop', index: ve.dm.example.underlineIndex }, { type: 'retain', length: 59 } ] } ]; QUnit.expect( 2 * cases.length ); for ( i = 0; i < cases.length; i++ ) { tx = ve.dm.Transaction[ cases[ i ].method ].apply( ve.dm.Transaction, cases[ i ].args ); ops = JSON.parse( JSON.stringify( tx.toJSON() ) ); assert.deepEqual( ops, cases[ i ].expected, cases[ i ].msg + ': toJSON' ); if ( cases[ i ].roundTripArgs ) { tx = ve.dm.Transaction[ cases[ i ].method ].apply( ve.dm.Transaction, cases[ i ].roundTripArgs ); } assert.deepEqual( new ve.dm.Transaction( ops ), tx, cases[ i ].msg + ': build from operations' ); } } );