Cscott has uploaded a new change for review.
https://gerrit.wikimedia.org/r/84442
Change subject: WIP: Implement `ve.dm.Transaction.transpose()` w/ intentions.
......................................................................
WIP: Implement `ve.dm.Transaction.transpose()` w/ intentions.
Transposing two transactions allows you to reorder them. It is the basis
of a Operational Transform implementation for Visual Editor. There is
an extensive test suite ensuring that the result of transposing two
transactions yields consistent results.
This commit adds a functional transpose operation for transactions which
operates on the high level "intention" associated with the transaction,
leaving it to the `ve.dm.Transaction.new*` methods to create operation
sequences which respect document structure.
Change-Id: I9eaefeed15bf22d2bca13ae1a52a7631425c1875
---
M VisualEditor.hooks.php
M VisualEditor.php
M demos/ve/index.php
A modules/ve/dm/ve.dm.Transaction.transpose.js
A modules/ve/test/dm/ve.dm.Transaction.transpose.test.js
M modules/ve/test/index.php
6 files changed, 1,196 insertions(+), 0 deletions(-)
git pull ssh://gerrit.wikimedia.org:29418/mediawiki/extensions/VisualEditor
refs/changes/42/84442/1
diff --git a/VisualEditor.hooks.php b/VisualEditor.hooks.php
index 23dddbc..029f87a 100644
--- a/VisualEditor.hooks.php
+++ b/VisualEditor.hooks.php
@@ -341,6 +341,7 @@
've/test/dm/ve.dm.InternalList.test.js',
've-mw/test/dm/ve.dm.InternalList.test.js',
've/test/dm/ve.dm.Transaction.test.js',
+
've/test/dm/ve.dm.Transaction.transpose.test.js',
've/test/dm/ve.dm.TransactionProcessor.test.js',
've/test/dm/ve.dm.Surface.test.js',
've/test/dm/ve.dm.SurfaceFragment.test.js',
diff --git a/VisualEditor.php b/VisualEditor.php
index b9e950b..cfb1c7e 100644
--- a/VisualEditor.php
+++ b/VisualEditor.php
@@ -288,6 +288,7 @@
've/dm/ve.dm.MetaList.js',
've/dm/ve.dm.TransactionProcessor.js',
've/dm/ve.dm.Transaction.js',
+ 've/dm/ve.dm.Transaction.transpose.js',
've/dm/ve.dm.Surface.js',
've/dm/ve.dm.SurfaceFragment.js',
've/dm/ve.dm.DataString.js',
diff --git a/demos/ve/index.php b/demos/ve/index.php
index 70bb9ba..5206d1c 100644
--- a/demos/ve/index.php
+++ b/demos/ve/index.php
@@ -142,6 +142,7 @@
<script src="../../modules/ve/dm/ve.dm.MetaList.js"></script>
<script
src="../../modules/ve/dm/ve.dm.TransactionProcessor.js"></script>
<script src="../../modules/ve/dm/ve.dm.Transaction.js"></script>
+ <script
src="../../modules/ve/dm/ve.dm.Transaction.transpose.js"></script>
<script src="../../modules/ve/dm/ve.dm.Surface.js"></script>
<script
src="../../modules/ve/dm/ve.dm.SurfaceFragment.js"></script>
<script src="../../modules/ve/dm/ve.dm.DataString.js"></script>
diff --git a/modules/ve/dm/ve.dm.Transaction.transpose.js
b/modules/ve/dm/ve.dm.Transaction.transpose.js
new file mode 100644
index 0000000..25b4659
--- /dev/null
+++ b/modules/ve/dm/ve.dm.Transaction.transpose.js
@@ -0,0 +1,323 @@
+/*!
+ * VisualEditor DataModel Transaction transposition.
+ *
+ * @copyright 2011-2013 VisualEditor Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+( function ( ve ) {
+
+ /* Private helper functions */
+
+ /*
+ * Position data type.
+ *
+ * Maintain position within data/metadata. Conceptually, metadata
+ * positions precede data positions, so that the data at offset
+ * `n` occurs at position `(n, MAX_INT)`.
+ */
+ function Position(dataPos, metadataPos) {
+ this.data = dataPos || 0;
+ this.meta = metadataPos || 0;
+ }
+ Position.prototype.add = function( d, m ) {
+ if ( d instanceof Position ) {
+ return this.add( d.data, d.meta );
+ }
+ if ( d === 0 ) {
+ return new Position( this.data, this.meta + ( m || 0 )
);
+ }
+ return new Position( this.data + d, ( m || 0 ) );
+ };
+ Position.prototype.subtract = function( d, m ) {
+ if ( d instanceof Position ) {
+ return this.subtract( d.data, d.meta );
+ }
+ return this.add( -d, -m );
+ };
+ Position.prototype.eq = function( d, m ) {
+ if ( d instanceof Position ) {
+ return this.eq( d.data, d.meta );
+ }
+ return this.data === d && this.meta === m;
+ };
+ Position.prototype.lt = function( d, m ) {
+ if ( d instanceof Position ) {
+ return this.lt( d.data, d.meta );
+ }
+ if ( this.data === d ) {
+ return this.meta < ( m || 0 );
+ }
+ return this.data < d;
+ };
+ Position.prototype.toString = function() {
+ return 'Position('+this.data+','+this.meta+')';
+ };
+
+ /*
+ * Helper function for `transposeOne()`.
+ *
+ * We preprocess transactions' operation lists to annotate them with
+ * old position information and mutable insert/remove lists. We
+ * call the result a 'map'.
+ */
+ function mkMap( tx ) {
+ var r = [], oldPos = new Position( 0, 0 ), j, meta, inMeta =
false;
+ // start with sentinel
+ r.push( {
+ op: { type: 'retain', length: 1 },
+ correction: new Position( -1 ),
+ start: new Position( -1 )
+ } );
+ $.each( tx.operations, function( i, op ) {
+ var m = {
+ op: op,
+ start: oldPos
+ };
+ switch ( op.type ) {
+
+ case 'retain':
+ m.correction = new Position( 0 );
+ if ( inMeta ) {
+ inMeta = false;
+ // add sentinel covering "data after
metadata" realignment
+ m.start = oldPos.add( 1 );
+ m.correction = new Position( -1 );
+ r.push( {
+ op: { type: 'retainData' },
+ start: oldPos,
+ correction: new Position( 0 )
+ } );
+ if ( op.length === 1 ) {
+ oldPos = m.start;
+ return;
+ }
+ }
+ oldPos = oldPos.add( op.length );
+ break;
+
+ case 'replace':
+ if ( inMeta ) {
+ throw new Error( 'replace at unaligned
position' );
+ }
+ // combine data and metadata
+ m.remove = [];
+ meta = ( op.removeMetadata === undefined ) ? []
: op.removeMetadata;
+ for ( j = 0; j < op.remove.length; j++ ) {
+ m.remove.push( [ meta[ j ], op.remove[
j ] ] );
+ }
+ m.insert = [];
+ meta = ( op.insertMetadata === undefined ) ? []
: op.insertMetadata;
+ for ( j = 0; j < op.insert.length; j++ ) {
+ m.insert.push( [ meta[ j ], op.insert[
j ] ] );
+ }
+ oldPos = oldPos.add( op.remove.length );
+ break;
+
+ case 'replaceMetadata':
+ if ( !inMeta ) {
+ inMeta = true;
+ // add sentinel
+ r.push( {
+ op: { type: 'retainMetadata',
length: 1 },
+ correction: new Position( 0, -1
),
+ start: oldPos.subtract( 0, 1 )
+ } );
+ }
+ m.remove = op.remove.slice( 0 );
+ m.insert = op.insert.slice( 0 );
+ oldPos = oldPos.add( 0, op.remove.length );
+ break;
+
+ case 'retainMetadata':
+ if ( !inMeta ) {
+ inMeta = true;
+ // add sentinel
+ r.push( {
+ op: { type: 'retainMetadata',
length: 1 },
+ correction: new Position( 0, -1
),
+ start: oldPos.subtract( 0, 1 )
+ } );
+ }
+ m.correction = new Position( 0 );
+ oldPos = oldPos.add( 0, op.length );
+ break;
+
+ default:
+ throw new Error( 'unhandled type: ' + op.type );
+ }
+ r.push( m );
+ } );
+ // add sentinel at end
+ r.push( {
+ op: { type: 'retain', length: 1 },
+ correction: new Position( -1 ),
+ start: oldPos,
+ end: oldPos.add( 1 )
+ } );
+ // link up 'start' and 'end' fields
+ $.each( r, function( i, op ) {
+ if ( i < r.length - 1 ) {
+ op.end = r[ i + 1 ].start;
+ }
+ } );
+ return r;
+ }
+
+ /*
+ * @param {boolean} preferB If true, give position new before given
old `offset`; otherwise give new position after old `offset-1`.
+ */
+ // apply b operations to a to determine how offsets change
+ function translate( pos, bMap ) {
+ var inMeta = false;
+ var i, op, extraData, extraMeta;
+ var oldStart = new Position( 0 ), oldEnd;
+ var newStart = new Position( 0 ), newEnd;
+ var extraData, extraMeta;
+
+ // run through elements completely before pos.
+ extraData = extraMeta = 0;
+ for ( i = 0; i < bMap.length; i++ ) {
+ if ( !bMap[i].end.lt( pos ) ) {
+ break;
+ }
+ op = bMap[i].op;
+ switch ( op.type ) {
+ case 'retainData':
+ extraMeta = 0;
+ break;
+ case 'replace':
+ extraData += op.insert.length -
op.remove.length;
+ break;
+ case 'replaceMetadata':
+ extraMeta += op.insert.length -
op.remove.length;
+ break;
+ default:
+ break;
+ }
+ }
+ // ok, now we're looking at an element which contains pos.
+ // treat replace carefully here.
+ if ( i < bMap.length ) {
+ op = bMap[i].op;
+ // XXX?
+ if ( op.type === 'replace' ) {
+ extraData += op.insert.length -
op.remove.length;
+ } else if ( op.type === 'replaceMetadata' ) {
+ extraMeta += op.insert.length -
op.remove.length;
+ }
+ }
+ return pos.add( extraData, extraMeta );
+ }
+
+ function tweak( xxx ) {
+ // XXX write me
+ // what sorts of fixups need to be made?
+ }
+
+ /*
+ * Helper function for `transpose()`.
+ *
+ * Transposing `a` by `b` is almost the same as transposing `b` by `a`;
we just
+ * break some ties in opposite ways to ensure the results are
consistent.
+ * So we define `transposeOne` here, and then call it twice to create
the
+ * full `transpose` result.
+ *
+ * @param {boolean} preferB If true, perform insertions before a given
`offset` (translated appropriately); otherwise perform insertions after
`offset-1` (translated appropriately).
+ * @returns {ve.dm.Transaction} The new version of `a`
+ */
+ function transposeOne( doc, a, b, preferB ) {
+ var bMap, oldStart, oldEnd, newStart, newEnd, newDoc,
newIntention;
+ var isMeta = false;
+
+ // start by doing common translations.
+ oldStart = oldEnd = new Position( 0 );
+ if ( a.intention[1] instanceof ve.Range ) {
+ oldStart = new Position( a.intention[1].start );
+ oldEnd = new Position( a.intention[1].end );
+ if ( a.intention[2] instanceof ve.Range ) {
+ // refine by metadata pos. (a.intention[1] is
collapsed)
+ oldStart = oldStart.add( 0,
a.intention[2].start );
+ oldEnd = oldEnd.add( 0, a.intention[2].end );
+ isMeta = true;
+ }
+ }
+ if ( oldStart.eq( oldEnd ) && !preferB ) {
+ oldStart = oldEnd =
+ isMeta ? oldStart.add( 0, -1 ) : oldStart.add(
-1 );
+ }
+ // translate
+ bMap = mkMap( b );
+ newStart = translate( oldStart, bMap );
+ newEnd = translate( oldEnd, bMap );
+ if ( oldStart.eq( oldEnd ) && !preferB ) {
+ newStart = newEnd =
+ isMeta ? oldStart.add( 0, 1 ) : oldStart.add( 1
);
+ }
+ // make new arguments.
+ newIntention = a.intention.slice( 0 );
+ if ( a.intention[1] instanceof ve.Range ) {
+ newIntention[1] = a.intention[1].isBackwards() ?
+ new ve.Range( newEnd.data, newStart.data ) :
+ new ve.Range( newStart.data, newEnd.data );
+ if ( a.intention[2] instanceof ve.Range ) {
+ newIntention[2] = a.intention[2].isBackwards() ?
+ new ve.Range( newEnd.meta,
newStart.meta ) :
+ new ve.Range( newStart.meta,
newEnd.meta );
+ }
+ }
+ // create "after b" document.
+ // FIXME: this is expensive. Can we make a lighter-weight
clone?
+ // XXX: do we need to fix up internalList, like ve.dm.example
does?
+ newDoc = new ve.dm.Document( doc.getData( undefined, true ) );
+ // oddball adjustments go here.
+ tweak( newIntention );
+ // create transaction result from new intention
+ return ve.dm.Transaction.newFromIntention( newDoc, newIntention
);
+ }
+
+ /* Public methods */
+
+ /**
+ * @class ve.dm.Transaction
+ */
+
+ /**
+ * Transform this transaction as though the `other` transaction had come
+ * before it.
+ * Returns a pair, `[new_version_of_this, transformed_other]`, such the
the
+ * document resulting from:
+ * ```
+ * doc.commit( other );
+ * doc.commit( new_version_of_this );
+ * ```
+ * is identical to the document which would result from:
+ * ```
+ * doc.commit( this );
+ * doc.commit( transformed_other );
+ * ```
+ * Does not modify this transaction.
+ *
+ * @method
+ * @param {ve.dm.Document} doc The document that both `this` and
`other` were created against.
+ * @param {ve.dm.Transaction} other The transaction to transpose
against.
+ * @returns {ve.dm.Transaction[]} The pair of transposed transactions.
+ */
+ ve.dm.Transaction.prototype.transpose = function ( doc, other ) {
+ // Special case when this is identical to other.
+ if ( ve.compare( this.intention, other.intention ) ) {
+ return [ ve.dm.Transaction.newNoOp( doc ),
+ ve.dm.Transaction.newNoOp( doc ) ];
+ }
+ // Special case when one or the other is a no-op.
+ if ( this.isNoOp() || other.isNoOp() ) {
+ return [ this.copy( doc ), other.copy( doc ) ];
+ }
+ // Create final result by calling `transposeOne` twice.
+ return [
+ transposeOne( doc, this, other, false ),
+ transposeOne( doc, other, this, true )
+ ];
+ };
+
+
+} )( ve );
diff --git a/modules/ve/test/dm/ve.dm.Transaction.transpose.test.js
b/modules/ve/test/dm/ve.dm.Transaction.transpose.test.js
new file mode 100644
index 0000000..a06c00a
--- /dev/null
+++ b/modules/ve/test/dm/ve.dm.Transaction.transpose.test.js
@@ -0,0 +1,868 @@
+/*!
+ * VisualEditor DataModel Transaction transposition tests.
+ *
+ * @copyright 2011-2013 VisualEditor Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+
+QUnit.module( 've.dm.Transaction.transpose' );
+
+/* Helper methods */
+
+/* Tests */
+
+QUnit.test( 'transpose (simple string changes)', function ( assert ) {
+ // Simple transaction transpose tests
+ var initialContent = [ { type: 'paragraph' } ];
+ var finalContent = [ { type: '/paragraph' } ];
+ function metaMap ( data, withClose ) {
+ var meta = [];
+ $.each( data, function( _, e ) {
+ var el = $( '<meta property=test /> ').attr( 'content',
e );
+ elements.push( {
+ 'type': 'alienMeta',
+ 'attributes': {
+ 'domElements': el.toArray()
+ }
+ } );
+ if ( withClose ) {
+ elements.push( {
+ 'type': '/alienMeta'
+ } );
+ }
+ } );
+ return meta;
+ }
+ function makeDoc( oldValue, useMeta ) {
+ var elements = initialContent;
+ oldValue = oldValue.split( '' );
+ if ( useMeta ) {
+ elements = elements.concat( metaMap( oldValue, true ) );
+ } else {
+ elements = elements.concat( oldValue );
+ }
+ elements.push.apply( elements, finalContent );
+ return ve.dm.example.createExampleDocumentFromObject( 'data',
null, {
+ 'data': elements
+ } );
+ }
+ function fromChange( doc, oldValue, newValue, useMeta ) {
+ var commonStart, commonEnd, removed, inserted, tx;
+ if ( typeof oldValue !== 'string' || typeof newValue !==
'string' ) {
+ throw new Error( 'fromChange not being called
correctly' );
+ }
+ commonStart = 0;
+ while ( commonStart < newValue.length &&
+ newValue.charAt( commonStart ) === oldValue.charAt(
commonStart ) ) {
+ commonStart++;
+ }
+ commonEnd = 0;
+ while ( commonEnd < ( newValue.length - commonStart ) &&
+ commonEnd < ( oldValue.length - commonStart ) &&
+ newValue.charAt( newValue.length - commonEnd - 1 ) ===
+ oldValue.charAt( oldValue.length - commonEnd - 1 )) {
+ commonEnd++;
+ }
+ removed = oldValue.substr( commonStart, oldValue.length -
commonStart - commonEnd );
+ inserted = newValue.substr( commonStart, newValue.length -
commonStart - commonEnd );
+ // make a transaction object
+ if ( useMeta ) {
+ if ( removed.length === 0 ) {
+ return
ve.dm.Transaction.newFromMetadataInsertion(
+ doc, initialContent.length, commonStart,
+ metaMap( inserted )
+ );
+ } else if ( inserted.length === 0 ) {
+ return ve.dm.Transaction.newFromMetadataRemoval(
+ doc, initialContent.length,
+ new ve.Range( commonStart, commonStart
+ removed.length )
+ );
+ } else if ( removed.length === 1 && inserted.length ===
1 ) {
+ return
ve.dm.Transaction.newFromMetadataElementReplacement(
+ doc, initialContent.length, commonStart,
+ metaMap( inserted )[0]
+ );
+ } else {
+ // Intention model doesn't support simultaenous
insert/remove
+ return ve.dm.Transaction.newNoOp( doc );
+ }
+ } else {
+ if ( removed.length === 0 ) {
+ return ve.dm.Transaction.newFromInsertion(
+ doc, initialContent.length +
commonStart, inserted
+ );
+ } else if ( inserted.length === 0 ) {
+ return ve.dm.Transaction.newFromRemoval(
+ doc,
+ new ve.Range(
+ initialContent.length +
commonStart,
+ initialContent.length +
commonStart + removed.length
+ )
+ );
+ } else {
+ // Intention model doesn't support simultaenous
insert/remove
+ return ve.dm.Transaction.newNoOp( doc );
+ }
+ }
+ return tx;
+ }
+ function apply( text, transaction, useMeta ) {
+ var result = '', p = 0, retain = 'retain', replace = 'replace';
+ if ( transaction.isNoOp() ) {
+ return text;
+ }
+ if ( useMeta ) {
+ retain += 'Metadata';
+ replace += 'Metadata';
+ } else {
+ for ( result = ''; result.length <
initialContent.length; )
+ result += '@';
+ text = result + text;
+ for ( result = ''; result.length < finalContent.length;
)
+ result += '@';
+ text += result;
+ result = '';
+ }
+ $.each( transaction.operations, function(_, t) {
+ if ( t.type === retain ) {
+ result += text.substr( p, t.length );
+ p += t.length;
+ } else if ( t.type === replace ) {
+ result += t.insert.join( '' );
+ p += t.remove.length;
+ } else {
+ throw new Error( 'Unexpected transaction
operation: ' + t.type );
+ }
+ } );
+ if ( !useMeta ) {
+ result = result.substring(
+ initialContent.length, result.length -
finalContent.length
+ );
+ }
+ return result;
+ }
+ function stripMeta( operations ) {
+ var r = new ve.dm.Transaction( [ 'bogus' ] );
+ $.each( operations, function( _, op ) {
+ switch ( op.type ) {
+ case 'retainMetadata':
+ r.pushRetain( op.length );
+ break;
+ case 'replaceMetadata':
+ r.pushReplaceInternal( op.remove, op.insert );
+ break;
+ default:
+ break;
+ }
+ } );
+ return r.operations;
+ }
+ function runTest1( _, test ) {
+ var doc, txa, txb, result, to1, to2, aops, bops,
+ desc = test.desc, useMeta = test.useMeta;
+ doc = makeDoc( test.from, useMeta );
+ txa = fromChange( doc, test.from, test.a, useMeta );
+ txb = fromChange( doc, test.from, test.b, useMeta );
+ if ( txa.isNoOp() || txb.isNoOp() ) {
+ // skip this test, not applicable
+ assert.ok( true ); assert.ok( true );
+ assert.ok( true ); assert.ok( true );
+ return;
+ }
+ result = txa.transpose( doc, txb );
+ to1 = apply( apply( test.from, txa, useMeta ), result[1],
useMeta );
+ to2 = apply( apply( test.from, txb, useMeta ), result[0],
useMeta );
+ assert.strictEqual( to1, test.to, desc + ': result after A,
B\'' );
+ assert.strictEqual( to2, test.to, desc + ': result after B,
A\'' );
+ aOps = result[0].getOperations();
+ bOps = result[1].getOperations();
+ if ( useMeta ) {
+ aOps = stripMeta( aOps );
+ bOps = stripMeta( bOps );
+ }
+ assert.deepEqual( aOps, test.aprime, desc + ': operations in
A\'' );
+ assert.deepEqual( bOps, test.bprime, desc + ': operations in
B\'' );
+ }
+ var cases = [
+ {
+ desc: 'Two insertions',
+ from: 'abcdef',
+ a: 'Xabcdef', // [insert "X" @0]
+ b: 'aYbcdef', // [insert "Y" @1]
+ aprime: [ // -> becomes [insert "X" @0]
+ { type: 'replace', insert: [ 'X' ], remove: []
},
+ { type: 'retain', length: 7 }
+ ],
+ bprime: [ // -> becomes [insert "Y" @2]
+ { type: 'retain', length: 2 },
+ { type: 'replace', insert: [ 'Y' ], remove: []
},
+ { type: 'retain', length: 5 }
+ ],
+ to: 'XaYbcdef',
+ symmetric: true
+ },
+ {
+ desc: 'Insert at same spot (1)',
+ from: 'abc',
+ a: 'abcX', // [insert "X" @3]
+ b: 'abcY', // [insert "Y" @3]
+ aprime: [ // -> becomes [insert "X" @4]
+ { type: 'retain', length: 4 },
+ { type: 'replace', insert: [ 'X' ], remove: [] }
+ ],
+ bprime: [ // -> becomes [insert "Y" @3]
+ { type: 'retain', length: 3 },
+ { type: 'replace', insert: [ 'Y' ], remove: []
},
+ { type: 'retain', length: 1 }
+ ],
+ to: 'abcYX' // precedence matters
+ },
+ {
+ desc: 'Insert at same spot (2)',
+ from: 'abcdef',
+ a: 'Xabcdef', // [insert "X" @0]
+ b: 'Yabcdef', // [insert "Y" @0]
+ aprime: [ // -> becomes [insert "X" @1]
+ { type: 'retain', length: 1 },
+ { type: 'replace', insert: [ 'X' ], remove: []
},
+ { type: 'retain', length: 6 }
+ ],
+ bprime: [ // -> becomes [insert "Y" @0]
+ { type: 'replace', insert: [ 'Y' ], remove: []
},
+ { type: 'retain', length: 7 }
+ ],
+ to: 'YXabcdef' // precedence matters
+ },
+ {
+ desc: 'Two deletions (1)',
+ from: 'abcdef',
+ a: 'bcdef', // [delete 1 chars @0]
+ b: 'acdef', // [delete 1 chars @1]
+ aprime: [ // -> becomes [delete 1 chars @0]
+ { type: 'replace', insert: [], remove: [ 'a' ]
},
+ { type: 'retain', length: 4 }
+ ],
+ bprime: [ // -> becomes [delete 1 chars @0]
+ { type: 'replace', insert: [], remove: [ 'b' ]
},
+ { type: 'retain', length: 4 }
+ ],
+ to: 'cdef',
+ symmetric: true
+ },
+ {
+ desc: 'Two deletions (2)',
+ from: 'abcdef',
+ a: 'cdef', // [delete 2 chars @0]
+ b: 'acdef', // [delete 1 chars @1]
+ aprime: [ // -> becomes [delete 1 chars @0]
+ { type: 'replace', insert: [], remove: [ 'a' ]
},
+ { type: 'retain', length: 4 }
+ ],
+ bprime: [ // -> becomes [no-op]
+ { type: 'retain', length: 4 }
+ ],
+ to: 'cdef',
+ symmetric: true
+ },
+ {
+ desc: 'Two deletions (3)',
+ from: 'abcdef',
+ a: 'bcdef', // [delete 1 chars @0]
+ b: 'acdef', // [delete 1 chars @1]
+ aprime: [ // -> becomes [delete 1 chars @0]
+ { type: 'replace', insert: [], remove: [ 'a' ]
},
+ { type: 'retain', length: 4 }
+ ],
+ bprime: [ // -> becomes [delete 1 chars @0]
+ { type: 'replace', insert: [], remove: [ 'b' ]
},
+ { type: 'retain', length: 4 }
+ ],
+ to: 'cdef',
+ symmetric: true
+ },
+ {
+ desc: 'Two deletions (4)',
+ from: 'abcdef',
+ a: 'ef', // [delete 4 chars @0]
+ b: 'acdef', // [delete 1 chars @1]
+ aprime: [ // -> becomes [delete 3 chars @0]
+ { type: 'replace', insert: [], remove: [ 'a',
'c', 'd' ] },
+ { type: 'retain', length: 2 }
+ ],
+ bprime: [ // -> becomes [no-op]
+ { type: 'retain', length: 2 }
+ ],
+ to: 'ef',
+ symmetric: true
+ },
+ {
+ desc: 'Two deletions (5)',
+ from: 'abcdef',
+ a: 'def', // [delete 3 chars @0]
+ b: 'ab', // [delete 4 chars @2]
+ aprime: [ // -> becomes [delete 2 chars @0]
+ { type: 'replace', insert: [], remove: [ 'a',
'b' ] }
+ ],
+ bprime: [ // -> becomes [delete 3 chars @0]
+ { type: 'replace', insert: [], remove: [ 'd',
'e', 'f' ] }
+ ],
+ to: '',
+ symmetric: true
+ },
+ {
+ desc: 'Insertion and replacement (1)',
+ from: 'abcdef',
+ a: 'Xabcdef', // [insert "X" @0]
+ b: 'abYef', // [replace 2 chars with "Y" @2]
+ aprime: [ // -> becomes [insert "X" @0]
+ { type: 'replace', insert: [ 'X' ], remove: []
},
+ { type: 'retain', length: 5 }
+ ],
+ bprime: [ // -> becomes [replace 2 chars with "Y" @3]
+ { type: 'retain', length: 3 },
+ { type: 'replace', insert: [ 'Y' ], remove: [
'c', 'd' ] },
+ { type: 'retain', length: 2 }
+ ],
+ to: 'XabYef',
+ symmetric: true
+ },
+ {
+ desc: 'Insertion and replacement (2)',
+ from: 'abcdef',
+ a: 'abYef', // [replace 2 chars with "Y" @2]
+ b: 'abXcdef', // [insert "X" @2]
+ aprime: [ // -> becomes [replace 2 chars with "Y" @3]
+ { type: 'retain', length: 3 },
+ { type: 'replace', insert: [ 'Y' ], remove: [
'c', 'd' ] },
+ { type: 'retain', length: 2 }
+ ],
+ bprime: [ // -> becomes [insert "X" @2]
+ { type: 'retain', length: 2 },
+ { type: 'replace', insert: [ 'X' ], remove: []
},
+ { type: 'retain', length: 3 }
+ ],
+ to: 'abXYef',
+ symmetric: true
+ },
+ {
+ desc: 'Insertion and replacement (3)',
+ from: 'abcdef',
+ a: 'aXbcdef', // [insert "X" @1]
+ b: 'Ydef', // [replace 3 chars with "Y" @0]
+ aprime: [ // -> becomes [insert "X" @0]
+ { type: 'replace', insert: [ 'X' ], remove: []
},
+ { type: 'retain', length: 4 }
+ ],
+ bprime: [ // -> becomes [replace 4 chars with "XY" @0]
+ { type: 'replace', insert: [ 'X', 'Y' ],
remove: [ 'a', 'X', 'b', 'c' ] },
+ { type: 'retain', length: 3 }
+ ],
+ to: 'XYdef'
+ },
+ {
+ desc: 'Insertion and replacement (3) [flipped]',
+ from: 'abcdef',
+ a: 'Ydef', // [replace 3 chars with "Y" @0]
+ b: 'aXbcdef', // [insert "X" @1]
+ aprime: [ // -> becomes [replace 4 chars with "YX" @0]
+ { type: 'replace', insert: [ 'Y', 'X' ],
remove: [ 'a', 'X', 'b', 'c' ] },
+ { type: 'retain', length: 3 }
+ ],
+ bprime: [ // -> becomes [insert "X" @1]
+ { type: 'retain', length: 1 },
+ { type: 'replace', insert: [ 'X' ], remove: []
},
+ { type: 'retain', length: 3 }
+ ],
+ to: 'YXdef'
+ },
+ {
+ desc: 'Insertion and replacement (4)',
+ from: 'abcdef',
+ a: 'Ydef', // [replace 3 chars with "Y" @0]
+ b: 'aXbcdef', // [insert "X" @1]
+ aprime: [ // -> becomes [replace 4 chars with "YX" @0]
+ { type: 'replace', insert: [ 'Y','X' ], remove:
[ 'a', 'X', 'b', 'c' ] },
+ { type: 'retain', length: 3 }
+ ],
+ bprime: [ // -> becomes [insert "X" @1]
+ { type: 'retain', length: 1 },
+ { type: 'replace', insert: [ 'X' ], remove: []
},
+ { type: 'retain', length: 3 }
+ ],
+ to: 'YXdef'
+ },
+ {
+ desc: 'Insertion and replacement (4) [flipped]',
+ from: 'abcdef',
+ a: 'aXbcdef', // [insert "X" @1]
+ b: 'Ydef', // [replace 3 chars with "Y" @0]
+ aprime: [ // -> becomes [insert "X" @0]
+ { type: 'replace', insert: [ 'X' ], remove: []
},
+ { type: 'retain', length: 4 }
+ ],
+ bprime: [ // -> becomes [replace 4 chars with "XY" @0]
+ { type: 'replace', insert: [ 'X','Y' ], remove:
[ 'a', 'X', 'b', 'c' ] },
+ { type: 'retain', length: 3 }
+ ],
+ to: 'XYdef'
+ },
+ {
+ desc: 'Insertion and replacement (5)',
+ from: 'abcdef',
+ a: 'abcdXef', // [insert "X" @ 4]
+ b: 'abYef', // [replace 2 chars with "Y" @2]
+ aprime: [ // -> becomes [insert "X" @3]
+ { type: 'retain', length: 3 },
+ { type: 'replace', insert: [ 'X' ], remove: []
},
+ { type: 'retain', length: 2 }
+ ],
+ bprime: [ // -> becomes [replace 2 chars with "Y" @2]
+ { type: 'retain', length: 2 },
+ { type: 'replace', insert: [ 'Y' ], remove: [
'c', 'd' ] },
+ { type: 'retain', length: 3 }
+ ],
+ to: 'abYXef'
+ },
+ {
+ desc: 'Insertion and replacement (5) [flipped]',
+ from: 'abcdef',
+ a: 'abYef', // [replace 2 chars with "Y" @2]
+ b: 'abcdXef', // [insert "X" @ 4]
+ aprime: [ // -> becomes [replace 3 chars with "XY" @2]
+ { type: 'retain', length: 2 },
+ { type: 'replace', insert: [ 'Y' ], remove: [
'c', 'd' ] },
+ { type: 'retain', length: 3 }
+ ],
+ bprime: [ // -> becomes [insert "X" @2]
+ { type: 'retain', length: 3 },
+ { type: 'replace', insert: [ 'X' ], remove: []
},
+ { type: 'retain', length: 2 }
+ ],
+ to: 'abYXef'
+ },
+ {
+ desc: 'Insertion and replacement (6)',
+ from: 'abcdef',
+ a: 'Xabcdef', // [insert "X" @0]
+ b: 'abYef', // [replace 2 chars with "Y" @2]
+ aprime: [ // -> becomes [insert "X" @0]
+ { type: 'replace', insert: [ 'X' ], remove: []
},
+ { type: 'retain', length: 5 }
+ ],
+ bprime: [ // -> becomes [replace 2 chars with "Y" @3]
+ { type: 'retain', length: 3 },
+ { type: 'replace', insert: [ 'Y' ], remove: [
'c', 'd' ] },
+ { type: 'retain', length: 2 }
+ ],
+ to: 'XabYef',
+ symmetric: true
+ },
+ {
+ desc: 'Simultaneous identical changes',
+ from: 'abcdef',
+ a: 'aXcYef', // [replace 3 char with "XcY" @1]
+ b: 'aXcYef', // [replace 3 char with "XcY" @1]
+ aprime: [ // -> becomes no-op
+ ],
+ bprime: [ // -> becomes no-op
+ ],
+ to: 'aXcYef'
+ },
+ {
+ desc: 'Remove while insert',
+ from: 'abcd',
+ a: 'abXcd',
+ b: 'ad',
+ aprime: [
+ { type: 'retain', length: 1 },
+ { type: 'replace', insert: [ 'X' ], remove: []
},
+ { type: 'retain', length: 1 }
+ ],
+ bprime: [
+ { type: 'retain', length: 1 },
+ { type: 'replace', insert: [ 'X' ], remove: [
'b', 'X', 'c' ] },
+ { type: 'retain', length: 1 }
+ ],
+ to: 'aXd',
+ symmetric: true
+ }
+ ];
+ $.each( cases, function( _, test ) {
+ if ( test.symmetric ) {
+ cases.push( {
+ desc: test.desc + ' [flipped]',
+ from: test.from,
+ a: test.b,
+ b: test.a,
+ aprime: test.bprime,
+ bprime: test.aprime,
+ to: test.to
+ } );
+ }
+ });
+ $.each( cases, function( _, test ) {
+ var newTest = ve.copy ( test );
+ newTest.desc += ' [meta]';
+ newTest.useMeta = true;
+ cases.push( newTest );
+ } );
+
+ QUnit.expect( cases.length * 4 );
+ $.each( cases, runTest1 );
+});
+
+QUnit.test( 'transpose (with ve.dm.Document)', function ( assert ) {
+ // Transaction transpose tests with a full ve.dm.Document.
+ var cases,
+ metadataElement = {
+ 'type': 'alienMeta',
+ 'attributes': {
+ 'style': 'comment',
+ 'text': ' inline '
+ }
+ };
+ function runTest2( _, test ) {
+ var doc1, doc2, txa, txb, result, expected;
+ doc1 = ve.dm.example.createExampleDocument( test.doc );
+ doc2 = ve.dm.example.createExampleDocument( test.doc );
+ txa = ve.dm.Transaction[ test.a[0] ].apply(null, [ doc1
].concat( test.a.slice(1) ) );
+ txb = ve.dm.Transaction[ test.b[0] ].apply(null, [ doc2
].concat( test.b.slice(1) ) );
+ if ( test.disabled ) {
+ // make the number of assertions work out correctly
before we bail.
+ assert.ok(true); assert.ok(true); assert.ok(true);
assert.ok(true);
+ return;
+ }
+ result = txa.transpose( doc1, txb );
+ assert.deepEqualWithDomElements( result[0].getOperations(),
test.aprime,
+ test.desc + ': operations in A\'' );
+ assert.deepEqualWithDomElements( result[1].getOperations(),
test.bprime,
+ test.desc + ': operations in B\'' );
+ // apply a, then b' to doc1
+ doc1.commit( txa ); doc1.commit( result[1] );
+ // apply b, then a' to doc2
+ doc2.commit( txb ); doc2.commit( result[0] );
+ // both documents should be identical.
+ assert.equalNodeTree( doc1.getDocumentNode(),
doc2.getDocumentNode(),
+ test.desc + ': results after A,B\' and B,A\' match' );
+ // verify resulting document.
+ expected = ve.dm.example.createExampleDocument( test.doc
).getFullData();
+ test.expected( expected ); // adjust expectations
+ assert.deepEqualWithDomElements( doc1.getFullData(), expected,
test.desc + ': result as expected' );
+ }
+ cases = [
+ {
+ desc: 'Simple insertion',
+ doc: 'data',
+ a: [ 'newFromInsertion', 0, [
+ { 'type': 'paragraph' }, 'X', { 'type':
'/paragraph' }
+ ] ],
+ b: [ 'newFromInsertion', 0, [
+ { 'type': 'paragraph' }, 'Y', { 'type':
'/paragraph' }
+ ] ],
+ aprime: [
+ { type: 'retain', length: 3 },
+ { type: 'replace', remove: [], insert: [
+ { 'type': 'paragraph' }, 'X', { 'type':
'/paragraph' }
+ ] },
+ { type: 'retain', length: 63 }
+ ],
+ bprime: [
+ { type: 'replace', remove: [], insert: [
+ { 'type': 'paragraph' }, 'Y', { 'type':
'/paragraph' }
+ ] },
+ { type: 'retain', length: 66 }
+ ],
+ expected: function( data ) {
+ ve.batchSplice(data, 0, 0, [
+ { 'type': 'paragraph' }, 'Y', { 'type':
'/paragraph' },
+ { 'type': 'paragraph' }, 'X', { 'type':
'/paragraph' }
+ ]);
+ }
+ },
+ {
+ desc: 'Insert and wrap',
+ doc: 'data',
+ a: [ 'newFromWrap', new ve.Range( 1, 4 ),
+ [ { 'type': 'heading', 'attributes': {
'level': 1 } } ],
+ [ { 'type': 'paragraph' } ], [], []
+ ],
+ b: [ 'newFromInsertion', 0, [
+ { 'type': 'paragraph' }, 'X', { 'type':
'/paragraph' }
+ ] ],
+ aprime: [
+ { type: 'retain', length: 3 },
+ { 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 }
+ ],
+ bprime: [
+ { type: 'replace', remove: [], insert: [
+ { 'type': 'paragraph' }, 'X', { 'type':
'/paragraph' }
+ ] },
+ { type: 'retain', length: 63 }
+ ],
+ expected: function( data ) {
+ ve.batchSplice( data, 0, 1, [
+ { 'type': 'paragraph' }, 'X', { 'type':
'/paragraph' },
+ { 'type': 'paragraph' }
+ ]);
+ ve.batchSplice( data, 7, 1, [
+ { 'type': '/paragraph' }
+ ]);
+ },
+ symmetric: true
+ },
+ {
+ desc: 'Insertion with metadata present',
+ disabled: disableMetadataTests,
+ doc: 'withMeta',
+ a: [ 'newFromRemoval', new ve.Range( 1, 4 ) ],
+ b: [ 'newFromRemoval', new ve.Range( 7, 10 ) ],
+ aprime: [
+ { type: 'retain', length: 1 },
+ { type: 'replace', remove: [ 'F', 'o', 'o' ],
insert: [] },
+ { type: 'retain', length: 6 }
+ ],
+ bprime: [
+ { type: 'retain', length: 4 },
+ {
+ type: 'replace',
+ insert: [],
+ insertMetadata: [],
+ remove: [ 'B', 'a', 'z' ],
+ removeMetadata: [
+ [ {
+ type: 'alienMeta',
+ attributes: {
+ 'domElements':
$( '<meta property="foo" content="bar" />' ).toArray()
+ }
+ } ],
+ undefined,
+ [ {
+ type: 'alienMeta',
+ attributes: {
+ 'domElements':
$( '<!-- inline -->' ).toArray()
+ }
+ } ]
+ ]
+ },
+ {
+ type: 'replaceMetadata',
+ remove: [],
+ insert: [
+ {
+ type: 'alienMeta',
+ attributes: {
+ 'domElements':
$( '<meta property="foo" content="bar" />' ).toArray()
+ }
+ },
+ {
+ type: 'alienMeta',
+ attributes: {
+ 'domElements':
$( '<!-- inline -->' ).toArray()
+ }
+ }
+ ]
+ },
+ { type: 'retain', length: 3 }
+ ],
+ expected: function( data ) {
+ ve.batchSplice( data, 19, 1, [] );
+ ve.batchSplice( data, 15, 2, [] );
+ ve.batchSplice( data, 5, 3, [] );
+ },
+ symmetric: true
+ },
+ {
+ desc: 'Metadata insertion/removal',
+ disabled: disableMetadataTests,
+ doc: 'withMeta',
+ a: [ 'newFromMetadataInsertion', 11, 2, [
metadataElement ] ],
+ b: [ 'newFromMetadataRemoval', 11, new ve.Range( 1, 3 )
],
+ aprime: [
+ { 'type': 'retain', 'length': 11 },
+ { 'type': 'retainMetadata', 'length': 1 },
+ {
+ 'type': 'replaceMetadata',
+ 'remove': [],
+ 'insert': [ metadataElement ]
+ },
+ { 'type': 'retainMetadata', 'length': 1 },
+ { 'type': 'retain', 'length': 2 }
+ ],
+ bprime: [
+ { 'type': 'retain', 'length': 11 },
+ { 'type': 'retainMetadata', 'length': 1 },
+ {
+ 'type': 'replaceMetadata',
+ 'remove': [
+
ve.dm.example.createExampleDocument( 'withMeta' ).metadata.getData( 11 )[ 1 ],
+ metadataElement,
+
ve.dm.example.createExampleDocument( 'withMeta' ).metadata.getData( 11 )[ 2 ]
+ ],
+ 'insert': [ metadataElement ]
+ },
+ { 'type': 'retainMetadata', 'length': 1 },
+ { 'type': 'retain', 'length': 2 }
+ ],
+ expected: function( data ) {
+ ve.batchSplice( data, 23, 4, [
+ metadataElement,
+ { 'type': '/alienMeta' }
+ ] );
+ },
+ symmetric: true
+ },
+ {
+ desc: 'Replace over metadata insertion',
+ disabled: disableMetadataTests || true, // we don't
support this yet
+ doc: 'data',
+ a: [ 'newFromMetadataInsertion', 2, 0, [
metadataElement ] ],
+ b: [ 'newFromRemoval', new ve.Range( 1, 4 ) ],
+ aprime: [
+ { 'type': 'retain', 'length': 1 },
+ {
+ 'type': 'replaceMetadata',
+ 'remove': [],
+ 'insert': [ metadataElement ]
+ },
+ { 'type': 'retain', 'length': 59 }
+ ],
+ bprime: [
+ { 'type': 'retain', 'length': 1 },
+ {
+ 'type': 'replace',
+ 'remove': [
+ 'a',
+ ['b', [ ve.dm.example.bold ]],
+ ['c', [ ve.dm.example.italic ]]
+ ],
+ 'removeMetadata': [
+ undefined,
+ [ metadataElement ],
+ undefined
+ ],
+ 'insert': [],
+ 'insertMetadata': []
+ },
+ {
+ 'type': 'replaceMetadata',
+ 'remove': [],
+ 'insert': [ metadataElement ]
+ },
+ { 'type': 'retain', 'length': 59 }
+ ],
+ expected: function( data ) {
+ ve.batchSplice( data, 1, 3, [
+ metadataElement,
+ { 'type': '/alienData' }
+ ] );
+ },
+ symmetric: true
+ }
+ ];
+ $.each( cases, function( _, test ) {
+ if ( test.symmetric && !test.disabled ) {
+ cases.push( {
+ desc: test.desc + ' [flipped]',
+ doc: test.doc,
+ a: test.b,
+ b: test.a,
+ aprime: test.bprime,
+ bprime: test.aprime,
+ expected: test.expected
+ } );
+ }
+ });
+ QUnit.expect( cases.length * 4);
+ $.each( cases, runTest2 );
+});
+
+QUnit.test( 'transpose (n-way)', function ( assert ) {
+ // Do N^2 transposition of N different transactions, taken two
+ // at a time. Verify that the results are consistent.
+ var cases, n = 0;
+ function runTest3( desc, doc, a, b ) {
+ var doc1, doc2, txa, txb, result;
+ doc1 = ve.dm.example.createExampleDocument( doc );
+ doc2 = ve.dm.example.createExampleDocument( doc );
+ txa = ve.dm.Transaction[ a[0] ].apply(null, [ doc1 ].concat(
a.slice(1) ) );
+ txb = ve.dm.Transaction[ b[0] ].apply(null, [ doc2 ].concat(
b.slice(1) ) );
+ result = txa.transpose( doc1, txb );
+ // apply a, then b' to doc1
+ doc1.commit( txa ); doc1.commit( result[1] );
+ // apply b, then a' to doc2
+ doc2.commit( txb ); doc2.commit( result[0] );
+ // both documents should be identical.
+ assert.equalNodeTree(
+ doc1.getDocumentNode(), doc2.getDocumentNode(),
+ desc + ': results after A,B\' and B,A\' match'
+ );
+ }
+
+ cases = [
+ {
+ doc: 'data',
+ transactions: {
+ // insertions at same point
+ 'insert X@0': [
+ 'newFromInsertion', 0,
+ [ { 'type': 'paragraph' }, 'X', {
'type': '/paragraph' } ]
+ ],
+ 'insert Y@0': [
+ 'newFromInsertion', 0,
+ [ { 'type': 'paragraph' }, 'Y', {
'type': '/paragraph' } ]
+ ],
+ // overlapping removals
+ 'remove ab': [
+ 'newFromRemoval', new ve.Range( 1, 3 )
+ ],
+ 'remove bc': [
+ 'newFromRemoval', new ve.Range( 2, 4 )
+ ],
+ // wrap
+ 'convert heading': [
+ 'newFromWrap', new ve.Range( 1, 4 ),
+ [ { 'type': 'heading', 'attributes': {
'level': 1 } } ],
+ [ { 'type': 'paragraph' } ], [], []
+ ],
+ // overlapping attribute change
+ 'change heading attrib': [
+ 'DISABLED', // attrib not yet supported
by transpose
+ 'newFromAttributeChanges', 0, {
'level': 2 }
+ ],
+ // overlapping annotation change
+ 'bold a': [
+ 'DISABLED', // annotation not yet
supported by transpose
+ 'newFromAnnotation', new ve.Range( 1, 2
), 'set',
+ ve.dm.example.createAnnotation(
ve.dm.example.bold )
+ ]
+ }
+ }
+ ];
+ $.each( cases, function( _, c ) {
+ var len = Object.keys( c.transactions ).length;
+ n += len * len;
+ } );
+ QUnit.expect( n );
+ $.each( cases, function( _, c ) {
+ $.each( c.transactions, function( aDesc, a ) {
+ $.each( c.transactions, function( bDesc, b ) {
+ if ( a[0] === 'DISABLED' || b[0] === 'DISABLED'
) {
+ assert.ok( true ); // skip this
transaction
+ } else {
+ runTest3( aDesc + ' <-> ' + bDesc,
c.doc, a, b );
+ }
+ } );
+ } );
+ } );
+});
diff --git a/modules/ve/test/index.php b/modules/ve/test/index.php
index ccb58e8..579a3aa 100644
--- a/modules/ve/test/index.php
+++ b/modules/ve/test/index.php
@@ -87,6 +87,7 @@
<script src="../../ve/dm/ve.dm.MetaList.js"></script>
<script
src="../../ve/dm/ve.dm.TransactionProcessor.js"></script>
<script src="../../ve/dm/ve.dm.Transaction.js"></script>
+ <script
src="../../ve/dm/ve.dm.Transaction.transpose.js"></script>
<script src="../../ve/dm/ve.dm.Surface.js"></script>
<script src="../../ve/dm/ve.dm.SurfaceFragment.js"></script>
<script src="../../ve/dm/ve.dm.DataString.js"></script>
@@ -277,6 +278,7 @@
<script src="dm/ve.dm.InternalList.test.js"></script>
<script src="dm/ve.dm.LinearData.test.js"></script>
<script src="dm/ve.dm.Transaction.test.js"></script>
+ <script src="dm/ve.dm.Transaction.transpose.test.js"></script>
<script src="dm/ve.dm.TransactionProcessor.test.js"></script>
<script src="dm/ve.dm.Surface.test.js"></script>
<script src="dm/ve.dm.SurfaceFragment.test.js"></script>
--
To view, visit https://gerrit.wikimedia.org/r/84442
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings
Gerrit-MessageType: newchange
Gerrit-Change-Id: I9eaefeed15bf22d2bca13ae1a52a7631425c1875
Gerrit-PatchSet: 1
Gerrit-Project: mediawiki/extensions/VisualEditor
Gerrit-Branch: master
Gerrit-Owner: Cscott <[email protected]>
_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits