jenkins-bot has submitted this change and it was merged.

Change subject: Extract change coalescing logic from ChangeHandler
......................................................................


Extract change coalescing logic from ChangeHandler

This introduces the ChangeRunCoalescer class.

Change-Id: Ib0085abc4c1cbf8ca162a031a96008840690907b
---
M client/includes/Changes/ChangeHandler.php
A client/includes/Changes/ChangeListTransformer.php
A client/includes/Changes/ChangeRunCoalescer.php
M client/includes/WikibaseClient.php
M client/tests/phpunit/includes/Changes/ChangeHandlerTest.php
A client/tests/phpunit/includes/Changes/ChangeRunCoalescerTest.php
6 files changed, 793 insertions(+), 938 deletions(-)

Approvals:
  Thiemo Mättig (WMDE): Looks good to me, approved
  jenkins-bot: Verified



diff --git a/client/includes/Changes/ChangeHandler.php 
b/client/includes/Changes/ChangeHandler.php
index 5ff4576..0d6ddbd 100644
--- a/client/includes/Changes/ChangeHandler.php
+++ b/client/includes/Changes/ChangeHandler.php
@@ -6,14 +6,10 @@
 use MWException;
 use Title;
 use Wikibase\Change;
-use Wikibase\Client\Changes\AffectedPagesFinder;
-use Wikibase\Client\Changes\PageUpdater;
 use Wikibase\DataModel\Entity\Diff\EntityDiff;
 use Wikibase\DataModel\Entity\Diff\ItemDiff;
 use Wikibase\EntityChange;
 use Wikibase\ItemChange;
-use Wikibase\Lib\Changes\EntityChangeFactory;
-use Wikibase\Lib\Store\EntityRevisionLookup;
 use Wikibase\SiteLinkCommentCreator;
 
 /**
@@ -25,9 +21,6 @@
  *
  * @licence GNU GPL v2+
  * @author Daniel Kinzler
- *
- * @fixme: ChangeNotification, ChangeHandler, ClientHooks::onWikibasePollHandle
- *         and ClientChangeHandler need to be combined and refactored.
  */
 class ChangeHandler {
 
@@ -63,14 +56,9 @@
        private $updater;
 
        /**
-        * @var EntityRevisionLookup $entityRevisionLookup
+        * @var ChangeListTransformer
         */
-       private $entityRevisionLookup;
-
-       /**
-        * @var EntityChangeFactory
-        */
-       private $changeFactory;
+       private $changeListTransformer;
 
        /**
         * @var AffectedPagesFinder
@@ -83,18 +71,16 @@
        private $localSiteId;
 
        public function __construct(
-               EntityChangeFactory $changeFactory,
                AffectedPagesFinder $affectedPagesFinder,
                PageUpdater $updater,
-               EntityRevisionLookup $entityRevisionLookup,
+               ChangeListTransformer $changeListTransformer,
                $localSiteId,
                $injectRC,
                $allowDataTransclusion
        ) {
-               $this->changeFactory = $changeFactory;
+               $this->changeListTransformer = $changeListTransformer;
                $this->affectedPagesFinder = $affectedPagesFinder;
                $this->updater = $updater;
-               $this->entityRevisionLookup = $entityRevisionLookup;
 
                if ( !is_string( $localSiteId ) ) {
                        throw new InvalidArgumentException( '$localSiteId must 
be a string' );
@@ -111,277 +97,6 @@
                $this->localSiteId = $localSiteId;
                $this->injectRC = (bool)$injectRC;
                $this->dataTransclusionAllowed = $allowDataTransclusion;
-
-               $this->mirrorUpdater = null;
-       }
-
-       /**
-        * Group changes by the entity they were applied to.
-        *
-        * @since 0.4
-        *
-        * @param EntityChange[] $changes
-        * @return EntityChange[][] an associative array using entity IDs for 
keys. Associated with each
-        *         entity ID is the list of changes performed on that entity.
-        */
-       public function groupChangesByEntity( array $changes ) {
-               wfProfileIn( __METHOD__ );
-               $groups = array();
-
-               foreach ( $changes as $change ) {
-                       $id = $change->getEntityId()->getSerialization();
-
-                       if ( !isset( $groups[$id] ) ) {
-                               $groups[$id] = array();
-                       }
-
-                       $groups[$id][] = $change;
-               }
-
-               wfProfileOut( __METHOD__ );
-               return $groups;
-       }
-
-       /**
-        * Combines a set of changes into one change. All changes are assume to 
have been performed
-        * by the same user on the same entity. They are further assumed to be 
UPDATE actions
-        * and sorted in causal (chronological) order.
-        *
-        * If $changes is empty, this method returns null. If $changes contains 
exactly one change,
-        * that change is returned. Otherwise, a combined change is returned.
-        *
-        * @since 0.4
-        *
-        * @param EntityChange[] $changes The changes to combine.
-        *
-        * @throws MWException
-        * @return Change a combined change representing the activity from all 
the original changes.
-        */
-       public function mergeChanges( array $changes ) {
-               if ( empty( $changes ) )  {
-                       return null;
-               } elseif ( count( $changes ) === 1 )  {
-                       return reset( $changes );
-               }
-
-               wfProfileIn( __METHOD__ );
-
-               // we now assume that we have a list if EntityChanges,
-               // all done by the same user on the same entity.
-
-               /* @var EntityChange $last */
-               /* @var EntityChange $first */
-               $last = end( $changes );
-               $first = reset( $changes );
-
-               $minor = true;
-               $bot = true;
-
-               $ids = array();
-
-               foreach ( $changes as $change ) {
-                       $ids[] = $change->getId();
-                       $meta = $change->getMetadata();
-
-                       $minor &= isset( $meta['minor'] ) && 
(bool)$meta['minor'];
-                       $bot &= isset( $meta['bot'] ) && (bool)$meta['bot'];
-               }
-
-               $lastmeta = $last->getMetadata();
-               $firstmeta = $first->getMetadata();
-
-               $entityId = $first->getEntityId();
-
-               $parentRevId = $firstmeta['parent_id'];
-               $latestRevId = $firstmeta['rev_id'];
-
-               $entityRev = $this->entityRevisionLookup->getEntityRevision( 
$entityId, $latestRevId );
-
-               if ( !$entityRev ) {
-                       throw new MWException( "Failed to load revision 
$latestRevId of $entityId" );
-               }
-
-               $parentRev = $parentRevId ? 
$this->entityRevisionLookup->getEntityRevision( $entityId, $parentRevId ) : 
null;
-
-               //XXX: we could avoid loading the entity data by merging the 
diffs programatically
-               //     instead of re-calculating.
-               $change = $this->changeFactory->newFromUpdate(
-                       $parentRev ? EntityChange::UPDATE : EntityChange::ADD,
-                       $parentRev === null ? null : $parentRev->getEntity(),
-                       $entityRev->getEntity()
-               );
-
-               $change->setFields(
-                       array(
-                               'revision_id' => $last->getField( 'revision_id' 
),
-                               'user_id' => $last->getField( 'user_id' ),
-                               'object_id' => $last->getField( 'object_id' ),
-                               'time' => $last->getField( 'time' ),
-                       )
-               );
-
-               $change->setMetadata( array_merge(
-                       $lastmeta,
-                       array(
-                               'parent_id' => $parentRevId,
-                               'minor' => $minor,
-                               'bot' => $bot,
-                       )
-                       //FIXME: size before & size after
-                       //FIXME: size before & size after
-               ) );
-
-               $info = $change->hasField( 'info' ) ? $change->getField( 'info' 
) : array();
-               $info['change-ids'] = $ids;
-               $info['changes'] = $changes;
-               $change->setField( 'info', $info );
-
-               wfProfileOut( __METHOD__ );
-               return $change;
-       }
-
-       /**
-        * Coalesce consecutive changes by the same user to the same entity 
into one.
-        * A run of changes may be broken if the action performed changes (e.g. 
deletion
-        * instead of update) or if a sitelink pointing to the local wiki was 
modified.
-        *
-        * Some types of actions, like deletion, will break runs.
-        * Interleaved changes to different items will break runs.
-        *
-        * @since 0.4
-        *
-        * @param EntityChange[] $changes
-        * @return EntityChange[] grouped changes
-        */
-       public function coalesceRuns( array $changes ) {
-               wfProfileIn( __METHOD__ );
-
-               $coalesced = array();
-
-               $currentRun = array();
-               $currentUser = null;
-               $currentEntity = null;
-               $currentAction = null;
-               $breakNext = false;
-
-               foreach ( $changes as $change ) {
-                       try {
-                               $action = $change->getAction();
-                               $meta = $change->getMetadata();
-                               $user = $meta['user_text'];
-                               $entityId = 
$change->getEntityId()->__toString();
-
-                               $break = $breakNext
-                                       || $currentAction !== $action
-                                       || $currentUser !== $user
-                                       || $currentEntity !== $entityId;
-
-                               $breakNext = false;
-
-                               if ( !$break && ( $change instanceof ItemChange 
) ) {
-                                       $siteLinkDiff = 
$change->getSiteLinkDiff();
-                                       if ( isset( $siteLinkDiff[ 
$this->localSiteId ] ) ) {
-                                               $break = true;
-                                               $breakNext = true;
-                                       };
-                               }
-
-                               // FIXME: We should call changeNeedsRendering() 
and see if the needs-rendering
-                               //        stays the same, and break the run if 
not. This way, uninteresting
-                               //        changes can be sorted out more 
cleanly later.
-                               // FIXME: Perhaps more easily, get rid of them 
here and now!
-                               if ( $break ) {
-                                       if ( !empty( $currentRun ) ) {
-                                               try {
-                                                       $coalesced[] = 
$this->mergeChanges( $currentRun );
-                                               } catch ( MWException $ex ) {
-                                                       // Something went wrong 
while trying to merge the changes.
-                                                       // Just keep the 
original run.
-                                                       wfWarn( 
$ex->getMessage() );
-                                                       $coalesced = 
array_merge( $coalesced, $currentRun );
-                                               }
-                                       }
-
-                                       $currentRun = array();
-                                       $currentUser = $user;
-                                       $currentEntity = $entityId;
-                                       $currentAction = $action === 
EntityChange::ADD ? EntityChange::UPDATE : $action;
-                               }
-
-                               $currentRun[] = $change;
-                       // skip any change that failed to process in some way 
(bug 49417)
-                       } catch ( \Exception $e ) {
-                               wfLogWarning( __METHOD__ . ':' . 
$e->getMessage() );
-                       }
-               }
-
-               if ( !empty( $currentRun ) ) {
-                       try {
-                               $coalesced[] = $this->mergeChanges( $currentRun 
);
-                       } catch ( MWException $ex ) {
-                               // Something went wrong while trying to merge 
the changes.
-                               // Just keep the original run.
-                               wfWarn( $ex->getMessage() );
-                               $coalesced = array_merge( $coalesced, 
$currentRun );
-                       }
-               }
-
-               wfProfileOut( __METHOD__ );
-               return $coalesced;
-       }
-
-       /**
-        * Coalesce changes where possible. This combines any consecutive 
changes by the same user
-        * to the same entity into one. Interleaved changes to different items 
are handled gracefully.
-        *
-        * @since 0.4
-        *
-        * @param EntityChange[] $changes
-        * @return Change[] grouped changes
-        */
-       public function coalesceChanges( array $changes ) {
-               wfProfileIn( __METHOD__ );
-               $coalesced = array();
-
-               $changesByEntity = $this->groupChangesByEntity( $changes );
-               foreach ( $changesByEntity as $entityChanges ) {
-                       $entityChanges = $this->coalesceRuns( $entityChanges );
-                       $coalesced = array_merge( $coalesced, $entityChanges );
-               }
-
-               usort( $coalesced, 
'Wikibase\Client\Changes\ChangeHandler::compareChangesByTimestamp' );
-
-               wfDebugLog( __CLASS__, __METHOD__ . ": coalesced "
-                       . count( $changes ) . " into " . count( $coalesced ) . 
" changes"  );
-
-               wfProfileOut( __METHOD__ );
-               return $coalesced;
-       }
-
-       /**
-        * Compares two changes based on their timestamp.
-        *
-        * @param Change $a
-        * @param Change $b
-        *
-        * @return Mixed
-        */
-       public static function compareChangesByTimestamp( Change $a, Change $b 
) {
-               //NOTE: beware https://bugs.php.net/bug.php?id=50688 !
-
-               if ( $a->getTime() > $b->getTime() ) {
-                       return 1;
-               } elseif ( $a->getTime() < $b->getTime() ) {
-                       return -1;
-               }
-
-               if ( $a->getId() > $b->getId() ) {
-                       return 1;
-               } elseif ( $a->getId() < $b->getId() ) {
-                       return -1;
-               }
-
-               return 0;
        }
 
        /**
@@ -394,7 +109,7 @@
        public function handleChanges( array $changes ) {
                wfProfileIn( __METHOD__ );
 
-               $changes = $this->coalesceChanges( $changes );
+               $changes = $this->changeListTransformer->transformChangeList( 
$changes );
 
                if ( !wfRunHooks( 'WikibaseHandleChanges', array( $changes ) ) 
) {
                        wfProfileOut( __METHOD__ );
@@ -441,11 +156,6 @@
                        wfDebugLog( __CLASS__, __FUNCTION__ . ": No actions to 
take for change #$chid." );
                        wfProfileOut( __METHOD__ );
                        return false;
-               }
-
-               if ( $this->mirrorUpdater !== null && ( $change instanceof 
EntityChange ) ) {
-                       // keep local mirror up to date
-                       $this->mirrorUpdater->handleChange( $change );
                }
 
                $titlesToUpdate = $this->getPagesToUpdate( $change );
diff --git a/client/includes/Changes/ChangeListTransformer.php 
b/client/includes/Changes/ChangeListTransformer.php
new file mode 100644
index 0000000..1552a74
--- /dev/null
+++ b/client/includes/Changes/ChangeListTransformer.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace Wikibase\Client\Changes;
+
+use Wikibase\Change;
+
+/**
+ * Interface for service objects implementing some transformation on a list of 
Change objects.
+ *
+ * @since 0.5
+ *
+ * @licence GNU GPL v2+
+ * @author Daniel Kinzler
+ *
+ */
+interface ChangeListTransformer {
+
+       /**
+        * Processes the given list of changes, possibly merging, filtering or 
otherwise modifying
+        * changes and/or the list of changes.
+        *
+        * @param Change[] $changes
+        *
+        * @return Change[]
+        */
+       public function transformChangeList( array $changes );
+
+}
diff --git a/client/includes/Changes/ChangeRunCoalescer.php 
b/client/includes/Changes/ChangeRunCoalescer.php
new file mode 100644
index 0000000..d81c56a
--- /dev/null
+++ b/client/includes/Changes/ChangeRunCoalescer.php
@@ -0,0 +1,319 @@
+<?php
+
+namespace Wikibase\Client\Changes;
+
+use MWException;
+use Wikibase\Change;
+use Wikibase\EntityChange;
+use Wikibase\ItemChange;
+use Wikibase\Lib\Changes\EntityChangeFactory;
+use Wikibase\Lib\Store\EntityRevisionLookup;
+
+/**
+ * ChangeListTransformer implementation that combines runs of changes into a 
single change.
+ * A "run" of changes is a sequence of consecutive changes performed by the 
same
+ * user, and not interrupted by a "disruptive" change. Changes altering the 
association
+ * between pages on the local wiki and items on the repo are considered 
disruptive.
+ *
+ * @since 0.5
+ *
+ * @licence GNU GPL v2+
+ * @author Daniel Kinzler
+ *
+ */
+class ChangeRunCoalescer implements ChangeListTransformer {
+
+       /**
+        * @var EntityRevisionLookup
+        */
+       private $entityRevisionLookup;
+
+       /**
+        * @var EntityChangeFactory
+        */
+       private $changeFactory;
+
+       /**
+        * @var string
+        */
+       private $localSiteId;
+
+       /**
+        * @param EntityRevisionLookup $entityRevisionLookup
+        * @param EntityChangeFactory $changeFactory
+        * @param string $localSiteId
+        */
+       public function __construct(
+               EntityRevisionLookup $entityRevisionLookup,
+               EntityChangeFactory $changeFactory,
+               $localSiteId
+       ) {
+               $this->entityRevisionLookup = $entityRevisionLookup;
+               $this->changeFactory = $changeFactory;
+               $this->localSiteId = $localSiteId;
+       }
+
+       /**
+        * Processes the given list of changes, combining any runs of changes 
into a single change.
+        * See the class level documentation for more details on change runs.
+        *
+        * @param Change[] $changes
+        *
+        * @return Change[]
+        */
+       public function transformChangeList( array $changes ) {
+               wfProfileIn( __METHOD__ );
+               $coalesced = array();
+
+               $changesByEntity = $this->groupChangesByEntity( $changes );
+               foreach ( $changesByEntity as $entityChanges ) {
+                       $entityChanges = $this->coalesceRuns( $entityChanges );
+                       $coalesced = array_merge( $coalesced, $entityChanges );
+               }
+
+               usort( $coalesced, 
'Wikibase\Client\Changes\ChangeRunCoalescer::compareChangesByTimestamp' );
+
+               wfDebugLog( __CLASS__, __METHOD__ . ": coalesced "
+                       . count( $changes ) . " into " . count( $coalesced ) . 
" changes"  );
+
+               wfProfileOut( __METHOD__ );
+               return $coalesced;
+       }
+
+
+       /**
+        * Group changes by the entity they were applied to.
+        *
+        * @param EntityChange[] $changes
+        * @return EntityChange[][] an associative array using entity IDs for 
keys. Associated with each
+        *         entity ID is the list of changes performed on that entity.
+        */
+       private function groupChangesByEntity( array $changes ) {
+               wfProfileIn( __METHOD__ );
+               $groups = array();
+
+               foreach ( $changes as $change ) {
+                       $id = $change->getEntityId()->getSerialization();
+
+                       if ( !isset( $groups[$id] ) ) {
+                               $groups[$id] = array();
+                       }
+
+                       $groups[$id][] = $change;
+               }
+
+               wfProfileOut( __METHOD__ );
+               return $groups;
+       }
+
+       /**
+        * Combines a set of changes into one change. All changes are assume to 
have been performed
+        * by the same user on the same entity. They are further assumed to be 
UPDATE actions
+        * and sorted in causal (chronological) order.
+        *
+        * If $changes is empty, this method returns null. If $changes contains 
exactly one change,
+        * that change is returned. Otherwise, a combined change is returned.
+        *
+        * @param EntityChange[] $changes The changes to combine.
+        *
+        * @throws MWException
+        * @return Change a combined change representing the activity from all 
the original changes.
+        */
+       private function mergeChanges( array $changes ) {
+               if ( empty( $changes ) )  {
+                       return null;
+               } elseif ( count( $changes ) === 1 )  {
+                       return reset( $changes );
+               }
+
+               wfProfileIn( __METHOD__ );
+
+               // we now assume that we have a list if EntityChanges,
+               // all done by the same user on the same entity.
+
+               /* @var EntityChange $last */
+               /* @var EntityChange $first */
+               $last = end( $changes );
+               $first = reset( $changes );
+
+               $minor = true;
+               $bot = true;
+
+               $ids = array();
+
+               foreach ( $changes as $change ) {
+                       $ids[] = $change->getId();
+                       $meta = $change->getMetadata();
+
+                       $minor &= isset( $meta['minor'] ) && 
(bool)$meta['minor'];
+                       $bot &= isset( $meta['bot'] ) && (bool)$meta['bot'];
+               }
+
+               $lastmeta = $last->getMetadata();
+               $firstmeta = $first->getMetadata();
+
+               $entityId = $first->getEntityId();
+
+               $parentRevId = $firstmeta['parent_id'];
+               $latestRevId = $firstmeta['rev_id'];
+
+               $entityRev = $this->entityRevisionLookup->getEntityRevision( 
$entityId, $latestRevId );
+
+               if ( !$entityRev ) {
+                       throw new MWException( "Failed to load revision 
$latestRevId of $entityId" );
+               }
+
+               $parentRev = $parentRevId ? 
$this->entityRevisionLookup->getEntityRevision( $entityId, $parentRevId ) : 
null;
+
+               //XXX: we could avoid loading the entity data by merging the 
diffs programatically
+               //     instead of re-calculating.
+               $change = $this->changeFactory->newFromUpdate(
+                       $parentRev ? EntityChange::UPDATE : EntityChange::ADD,
+                       $parentRev === null ? null : $parentRev->getEntity(),
+                       $entityRev->getEntity()
+               );
+
+               $change->setFields(
+                       array(
+                               'revision_id' => $last->getField( 'revision_id' 
),
+                               'user_id' => $last->getField( 'user_id' ),
+                               'object_id' => $last->getField( 'object_id' ),
+                               'time' => $last->getField( 'time' ),
+                       )
+               );
+
+               $change->setMetadata( array_merge(
+                       $lastmeta,
+                       array(
+                               'parent_id' => $parentRevId,
+                               'minor' => $minor,
+                               'bot' => $bot,
+                       )
+               //FIXME: size before & size after
+               //FIXME: size before & size after
+               ) );
+
+               $info = $change->hasField( 'info' ) ? $change->getField( 'info' 
) : array();
+               $info['change-ids'] = $ids;
+               $info['changes'] = $changes;
+               $change->setField( 'info', $info );
+
+               wfProfileOut( __METHOD__ );
+               return $change;
+       }
+
+       /**
+        * Coalesce consecutive changes by the same user to the same entity 
into one.
+        * A run of changes may be broken if the action performed changes (e.g. 
deletion
+        * instead of update) or if a sitelink pointing to the local wiki was 
modified.
+        *
+        * Some types of actions, like deletion, will break runs.
+        * Interleaved changes to different items will break runs.
+        *
+        * @param EntityChange[] $changes
+        * @return EntityChange[] grouped changes
+        */
+       private function coalesceRuns( array $changes ) {
+               wfProfileIn( __METHOD__ );
+
+               $coalesced = array();
+
+               $currentRun = array();
+               $currentUser = null;
+               $currentEntity = null;
+               $currentAction = null;
+               $breakNext = false;
+
+               foreach ( $changes as $change ) {
+                       try {
+                               $action = $change->getAction();
+                               $meta = $change->getMetadata();
+                               $user = $meta['user_text'];
+                               $entityId = 
$change->getEntityId()->__toString();
+
+                               $break = $breakNext
+                                       || $currentAction !== $action
+                                       || $currentUser !== $user
+                                       || $currentEntity !== $entityId;
+
+                               $breakNext = false;
+
+                               if ( !$break && ( $change instanceof ItemChange 
) ) {
+                                       $siteLinkDiff = 
$change->getSiteLinkDiff();
+                                       if ( isset( $siteLinkDiff[ 
$this->localSiteId ] ) ) {
+                                               $break = true;
+                                               $breakNext = true;
+                                       };
+                               }
+
+                               // FIXME: We should call changeNeedsRendering() 
and see if the needs-rendering
+                               //        stays the same, and break the run if 
not. This way, uninteresting
+                               //        changes can be sorted out more 
cleanly later.
+                               // FIXME: Perhaps more easily, get rid of them 
here and now!
+                               if ( $break ) {
+                                       if ( !empty( $currentRun ) ) {
+                                               try {
+                                                       $coalesced[] = 
$this->mergeChanges( $currentRun );
+                                               } catch ( MWException $ex ) {
+                                                       // Something went wrong 
while trying to merge the changes.
+                                                       // Just keep the 
original run.
+                                                       wfWarn( 
$ex->getMessage() );
+                                                       $coalesced = 
array_merge( $coalesced, $currentRun );
+                                               }
+                                       }
+
+                                       $currentRun = array();
+                                       $currentUser = $user;
+                                       $currentEntity = $entityId;
+                                       $currentAction = $action === 
EntityChange::ADD ? EntityChange::UPDATE : $action;
+                               }
+
+                               $currentRun[] = $change;
+                               // skip any change that failed to process in 
some way (bug 49417)
+                       } catch ( \Exception $e ) {
+                               wfLogWarning( __METHOD__ . ':' . 
$e->getMessage() );
+                       }
+               }
+
+               if ( !empty( $currentRun ) ) {
+                       try {
+                               $coalesced[] = $this->mergeChanges( $currentRun 
);
+                       } catch ( MWException $ex ) {
+                               // Something went wrong while trying to merge 
the changes.
+                               // Just keep the original run.
+                               wfWarn( $ex->getMessage() );
+                               $coalesced = array_merge( $coalesced, 
$currentRun );
+                       }
+               }
+
+               wfProfileOut( __METHOD__ );
+               return $coalesced;
+       }
+
+       /**
+        * Compares two changes based on their timestamp.
+        *
+        * @param Change $a
+        * @param Change $b
+        *
+        * @return int
+        */
+       public static function compareChangesByTimestamp( Change $a, Change $b 
) {
+               //NOTE: beware https://bugs.php.net/bug.php?id=50688 !
+
+               if ( $a->getTime() > $b->getTime() ) {
+                       return 1;
+               } elseif ( $a->getTime() < $b->getTime() ) {
+                       return -1;
+               }
+
+               if ( $a->getId() > $b->getId() ) {
+                       return 1;
+               } elseif ( $a->getId() < $b->getId() ) {
+                       return -1;
+               }
+
+               return 0;
+       }
+
+}
\ No newline at end of file
diff --git a/client/includes/WikibaseClient.php 
b/client/includes/WikibaseClient.php
index 7315fa5..2f8f08f 100644
--- a/client/includes/WikibaseClient.php
+++ b/client/includes/WikibaseClient.php
@@ -16,6 +16,7 @@
 use ValueFormatters\FormatterOptions;
 use Wikibase\Client\Changes\ChangeHandler;
 use Wikibase\Client\Changes\AffectedPagesFinder;
+use Wikibase\Client\Changes\ChangeRunCoalescer;
 use Wikibase\Client\Hooks\LanguageLinkBadgeDisplay;
 use Wikibase\Client\Hooks\OtherProjectsSidebarGenerator;
 use Wikibase\Client\Hooks\ParserFunctionRegistrant;
@@ -740,12 +741,17 @@
         * @return ChangeHandler
         */
        public function getChangeHandler() {
+               $siteId = $this->getSite()->getGlobalId();
+
                return new ChangeHandler(
-                       $this->getEntityChangeFactory(),
                        $this->getAffectedPagesFinder(),
                        new WikiPageUpdater(),
-                       $this->getStore()->getEntityRevisionLookup(),
-                       $this->getSite()->getGlobalId(),
+                       new ChangeRunCoalescer(
+                               $this->getStore()->getEntityRevisionLookup(),
+                               $this->getEntityChangeFactory(),
+                               $siteId
+                       ),
+                       $siteId,
                        $this->getSettings()->getSetting( 'injectRecentChanges' 
),
                        $this->getSettings()->getSetting( 
'allowDataTransclusion' )
                );
diff --git a/client/tests/phpunit/includes/Changes/ChangeHandlerTest.php 
b/client/tests/phpunit/includes/Changes/ChangeHandlerTest.php
index 3e63369..814d8b6 100644
--- a/client/tests/phpunit/includes/Changes/ChangeHandlerTest.php
+++ b/client/tests/phpunit/includes/Changes/ChangeHandlerTest.php
@@ -5,16 +5,14 @@
 use ArrayIterator;
 use Diff\Differ\MapDiffer;
 use Site;
-use SiteList;
 use Title;
 use Wikibase\Change;
-use Wikibase\Client\Changes\ChangeHandler;
-use Wikibase\ChangesTable;
 use Wikibase\Client\Changes\AffectedPagesFinder;
+use Wikibase\Client\Changes\ChangeHandler;
+use Wikibase\Client\Changes\PageUpdater;
 use Wikibase\Client\Store\TitleFactory;
 use Wikibase\Client\Usage\UsageLookup;
 use Wikibase\Client\WikibaseClient;
-use Wikibase\DataModel\Entity\Diff\EntityDiff;
 use Wikibase\DataModel\Entity\Entity;
 use Wikibase\DataModel\Entity\Item;
 use Wikibase\DataModel\Entity\ItemId;
@@ -23,8 +21,6 @@
 use Wikibase\Lib\Store\SiteLinkLookup;
 use Wikibase\Lib\Store\StorageException;
 use Wikibase\NamespaceChecker;
-use Wikibase\Client\Changes\PageUpdater;
-use Wikibase\Client\Tests\Changes\MockPageUpdater;
 use Wikibase\Test\MockRepository;
 use Wikibase\Test\TestChanges;
 
@@ -62,11 +58,11 @@
                $usageLookup = $this->getUsageLookup( $repo );
                $titleFactory = $this->getTitleFactory( $entities );
 
-               $changeFactory = TestChanges::getEntityChangeFactory();
+               $transformer = $this->getMock( 
'Wikibase\Client\Changes\ChangeListTransformer' );
 
-               if ( !$updater ) {
-                       $updater = new MockPageUpdater();
-               }
+               $transformer->expects( $this->any() )
+                       ->method( 'transformChangeList' )
+                       ->will( $this->returnArgument( 0 ) );
 
                $namespaceChecker = new NamespaceChecker( array(), array( 
NS_MAIN ) );
 
@@ -80,10 +76,9 @@
                );
 
                $handler = new ChangeHandler(
-                       $changeFactory,
                        $affectedPagesFinder,
-                       $updater,
-                       $repo,
+                       $updater ? : new MockPageUpdater(),
+                       $transformer,
                        'enwiki',
                        true,
                        true
@@ -136,606 +131,7 @@
                return $repo;
        }
 
-       /**
-        * @param array $values
-        * @param EntityDiff|null $diff
-        *
-        * @return EntityChange
-        */
-       public static function makeChange( array $values, EntityDiff $diff = 
null ) {
-               if ( !isset( $values['info'] ) ) {
-                       $values['info'] = array();
-               }
-
-               if ( !isset( $values['info']['metadata'] ) ) {
-                       $values['info']['metadata'] = array();
-               }
-
-               if ( !isset( $values['info']['metadata']['rev_id'] ) && isset( 
$values['revision_id'] ) ) {
-                       $values['info']['metadata']['rev_id'] = $values[ 
'revision_id' ];
-               }
-
-               if ( !isset( $values['info']['metadata']['user_text'] ) && 
isset( $values['user_id'] ) ) {
-                       $values['info']['metadata']['user_text'] = "User" . 
$values['user_id'];
-               }
-
-               if ( !isset( $values['info']['metadata']['parent_id'] ) && 
isset( $values['parent_id'] ) ) {
-                       $values['info']['metadata']['parent_id'] = 
$values['parent_id'];
-               }
-
-               if ( !isset( $values['info']['metadata']['parent_id'] ) ) {
-                       $values['info']['metadata']['parent_id'] = 0;
-               }
-
-               $values['info'] = serialize( $values['info'] );
-
-               /* @var EntityChange $change */
-               $table = ChangesTable::singleton();
-               $change = $table->newRow( $values, true );
-
-               if ( $diff ) {
-                       $change->setDiff( $diff );
-               }
-
-               return $change;
-       }
-
-       public static function makeDiff( $type, $before, $after ) {
-               $differ = new MapDiffer( true );
-
-               $diffOps = $differ->doDiff( $before, $after );
-               $diff = EntityDiff::newForType( $type, $diffOps );
-
-               return $diff;
-       }
-
-       public static function provideGroupChangesByEntity() {
-               $entity1 = 'Q1';
-               $entity2 = 'Q2';
-
-               $changes = array( // $changes
-
-                       self::makeChange( array(
-                               'id' => 1,
-                               'type' => 'wikibase-item~update',
-                               'time' => '20130101010101',
-                               'object_id' => $entity1,
-                               'revision_id' => 11,
-                               'user_id' => 1,
-                       )),
-
-                       self::makeChange( array(
-                               'id' => 2,
-                               'type' => 'wikibase-item~update',
-                               'time' => '20130102020202',
-                               'object_id' => $entity2,
-                               'revision_id' => 21,
-                               'user_id' => 1,
-                       )),
-
-                       self::makeChange( array(
-                               'id' => 1,
-                               'type' => 'wikibase-item~update',
-                               'time' => '20130103030303',
-                               'object_id' => $entity1,
-                               'revision_id' => 12,
-                               'user_id' => 2,
-                       )),
-               );
-
-               return array(
-                       array( // #0: empty
-                               array(), // $changes
-                               array(), // $expectedGroups
-                       ),
-
-                       array( // #1: two groups
-                               $changes, // $changes
-                               array( // $expectedGroups
-                                       $entity1 => array( $changes[0], 
$changes[2] ),
-                                       $entity2 => array( $changes[1] ),
-                               )
-                       )
-               );
-       }
-
-       /**
-        * @dataProvider provideGroupChangesByEntity
-        */
-       public function testGroupChangesByEntity( $changes, $expectedGroups ) {
-               $handler = $this->newChangeHandler();
-               $groups = $handler->groupChangesByEntity( $changes );
-
-               $this->assertEquals( count( $expectedGroups ), count( $groups 
), "number of groups" );
-               $this->assertArrayEquals( array_keys( $expectedGroups ), 
array_keys( $groups ), false, false );
-
-               foreach ( $groups as $entityId => $group ) {
-                       $expected = $expectedGroups[$entityId];
-                       $this->assertArrayEquals( $expected, $group, true, 
false );
-               }
-       }
-
-       protected static function getChangeFields( EntityChange $change ) {
-               $fields = $change->getFields();
-               unset( $fields['id'] );
-
-               return $fields;
-       }
-
-       protected function assertChangeEquals( Change $expected, Change 
$actual, $message = null ) {
-               if ( $message ) {
-                       $message .= ': ';
-               } else {
-                       $message = 'change.';
-               }
-
-               $this->assertEquals( get_class( $expected ), get_class( $actual 
), $message . "class" );
-
-               $this->assertEquals( $expected->getObjectId(), 
$actual->getObjectId(), $message . "ObjectId" );
-               $this->assertEquals( $expected->getTime(), $actual->getTime(), 
$message . "Time" );
-               $this->assertEquals( $expected->getType(), $actual->getType(), 
$message . "Type" );
-               $this->assertEquals( $expected->getUser(), $actual->getUser(), 
$message . "User" );
-
-               if ( $expected instanceof EntityChange && $actual instanceof 
EntityChange ) {
-                       $this->assertEquals( $expected->getAction(), 
$actual->getAction(), $message . "Action" );
-                       $this->assertArrayEquals( $expected->getMetadata(), 
$actual->getMetadata(), false, true );
-               }
-       }
-
-       public static function provideMergeChanges() {
-               $entity1 = 'q1';
-
-               $change1 = self::makeChange( array(
-                       'id' => 1,
-                       'type' => 'wikibase-item~add',
-                       'time' => '20130101010101',
-                       'object_id' => $entity1,
-                       'revision_id' => 11,
-                       'user_id' => 1,
-                       'info' => array(
-                               'metadata' => array (
-                                       'user_text' => 'User1',
-                                       'bot' => 0,
-                                       'page_id' => 1,
-                                       'rev_id' => 11,
-                                       'parent_id' => 0,
-                                       'comment' => 'wikibase-comment-add',
-                               ),
-                       )
-               ));
-
-               $change2 = self::makeChange( array(
-                       'id' => 2,
-                       'type' => 'wikibase-item~update',
-                       'time' => '20130102020202',
-                       'object_id' => $entity1,
-                       'revision_id' => 12,
-                       'user_id' => 1,
-                       'info' => array(
-                               'metadata' => array (
-                                       'user_text' => 'User1',
-                                       'bot' => 0,
-                                       'page_id' => 1,
-                                       'rev_id' => 12,
-                                       'parent_id' => 11,
-                                       'comment' => 'wikibase-comment-add',
-                               ),
-                       )
-               ));
-
-               $change3 = self::makeChange( array(
-                       'id' => 1,
-                       'type' => 'wikibase-item~update',
-                       'time' => '20130103030303',
-                       'object_id' => $entity1,
-                       'revision_id' => 13,
-                       'user_id' => 1,
-                       'info' => array(
-                               'metadata' => array (
-                                       'user_text' => 'User1',
-                                       'bot' => 0,
-                                       'page_id' => 1,
-                                       'rev_id' => 13,
-                                       'parent_id' => 12,
-                                       'comment' => 'wikibase-comment-add',
-                               ),
-                       )
-               ));
-
-               $change0 = self::makeChange( array(
-                       'id' => 1,
-                       'type' => 'wikibase-item~add',
-                       'time' => '20130101010101',
-                       'object_id' => $entity1,
-                       'revision_id' => 0xdeadbeef, // invalid
-                       'user_id' => 1,
-                       'info' => array(
-                               'metadata' => array (
-                                       'user_text' => 'User1',
-                                       'bot' => 0,
-                                       'page_id' => 1,
-                                       'rev_id' => 0xdeadbeef, // invalid
-                                       'parent_id' => 0,
-                                       'comment' => 'wikibase-comment-add',
-                               ),
-                       )
-               ));
-
-               $changeMerged = self::makeChange( array(
-                       'id' => null,
-                       'type' => 'wikibase-item~add', // because the first 
change has no parent
-                       'time' => '20130103030303', // last change's timestamp
-                       'object_id' => $entity1,
-                       'revision_id' => 13, // last changes rev id
-                       'user_id' => 1,
-                       'info' => array(
-                               'metadata' => array (
-                                       'user_text' => 'User1',
-                                       'bot' => 0,
-                                       'page_id' => 1,
-                                       'rev_id' => 13,   // rev id from last 
change
-                                       'parent_id' => 0, // parent rev from 
first change
-                                       'comment' => 'wikibase-comment-add',
-                               ),
-                       )
-               ));
-
-               return array(
-                       array( // #0: empty
-                               array(), // $changes
-                               null, // $expected
-                       ),
-
-                       array( // #1: single
-                               array( $change1 ), // $changes
-                               $change1, // $expected
-                       ),
-
-                       array( // #2: merged
-                               array( $change1, $change2, $change3 ), // 
$changes
-                               $changeMerged, // $expected
-                       ),
-
-                       array( // #3: bad
-                               array( $change0, $change2, $change3 ), // 
$changes
-                               null, // $expected
-                               'MWException', // $error
-                       ),
-               );
-       }
-
-       /**
-        * @dataProvider provideMergeChanges
-        */
-       public function testMergeChanges( $changes, $expected, $error = null ) {
-               try {
-                       $handler = $this->newChangeHandler();
-                       $merged = $handler->mergeChanges( $changes );
-
-                       if ( $error ) {
-                               $this->fail( "error expected: $error" );
-                       }
-
-                       if ( !$expected ) {
-                               $this->assertEquals( $expected, $merged );
-                       } else {
-                               $this->assertChangeEquals( $expected, $merged );
-                       }
-               } catch ( \MWException $ex ) {
-                       if ( !$error ) {
-                               throw $ex;
-                       }
-
-                       $this->assertInstanceOf( $error, $ex, "expected error" 
);
-               }
-       }
-
-       /**
-        * @todo: move to TestChanges, unify with TestChanges::getChanges()
-        */
-       public static function makeTestChanges( $userId, $numericId ) {
-               $prefixedId = 'Q' . $numericId;
-
-               $offset = 100 * $numericId + 1000 * $userId;
-
-               // create with a label and site link set
-               $create = self::makeChange( array(
-                       'id' => $offset + 1,
-                       'type' => 'wikibase-item~add',
-                       'time' => '20130101010101',
-                       'object_id' => $prefixedId,
-                       'revision_id' => $offset + 11,
-                       'user_id' => $userId,
-               ), self::makeDiff( Item::ENTITY_TYPE,
-                       array(),
-                       array(
-                               'label' => array( 'en' => 'Test' ),
-                               'links' => array( 'enwiki' => 'Test' ), // old 
style sitelink representation
-                       )
-               ) );
-
-               // set a label
-               $update = self::makeChange( array(
-                       'id' => $offset + 23,
-                       'type' => 'wikibase-item~update',
-                       'time' => '20130102020202',
-                       'object_id' => $prefixedId,
-                       'revision_id' => $offset + 12,
-                       'user_id' => $userId,
-               ), self::makeDiff( Item::ENTITY_TYPE,
-                       array(),
-                       array(
-                               'label' => array( 'de' => 'Test' ),
-                       )
-               ) );
-
-               // merged change consisting of $create and $update
-               $create_update = self::makeChange( array(
-                       'id' => null,
-                       'type' => $create->getField('type'), // because the 
first change has no parent
-                       'time' => $update->getField('time'), // last change's 
timestamp
-                       'object_id' => $update->getField('object_id'),
-                       'revision_id' => $update->getField('revision_id'), // 
last changes rev id
-                       'user_id' => $update->getField('user_id'),
-                       'info' => array(
-                               'metadata' => array(
-                                       'bot' => 0,
-                                       'comment' => 'wikibase-comment-add' // 
this assumes a specific 'type'
-                               )
-                       )
-               ), self::makeDiff( Item::ENTITY_TYPE,
-                       array(),
-                       array(
-                               'label' => array( 'en' => 'Test', 'de' => 
'Test' ),
-                               'links' => array( 'enwiki' => 'Test' ), // old 
style sitelink representation
-                       )
-               ) );
-
-               // change link to other wiki
-               $updateXLink = self::makeChange( array(
-                       'id' => $offset + 14,
-                       'type' => 'wikibase-item~update',
-                       'time' => '20130101020304',
-                       'object_id' => $prefixedId,
-                       'revision_id' => $offset + 13,
-                       'user_id' => $userId,
-               ), self::makeDiff( Item::ENTITY_TYPE,
-                       array(),
-                       array(
-                               'links' => array( 'dewiki' => array( 'name' => 
'Testen', 'badges' => array() ) ),
-                       )
-               ) );
-
-               // merged change consisting of $create, $update and $updateXLink
-               $create_update_link = self::makeChange( array(
-                       'id' => null,
-                       'type' => $create->getField('type'), // because the 
first change has no parent
-                       'time' => $updateXLink->getField('time'), // last 
change's timestamp
-                       'object_id' => $updateXLink->getField('object_id'),
-                       'revision_id' => $updateXLink->getField('revision_id'), 
// last changes rev id
-                       'user_id' => $updateXLink->getField('user_id'),
-                       'info' => array(
-                               'metadata' => array(
-                                       'bot' => 0,
-                                       'comment' => 'wikibase-comment-add' // 
this assumes a specific 'type'
-                               )
-                       )
-               ), self::makeDiff( Item::ENTITY_TYPE,
-                       array(),
-                       array(
-                               'label' => array( 'en' => 'Test', 'de' => 
'Test' ),
-                               'links' => array(
-                                       'enwiki' => array( 'name' => 'Test' ), 
// incomplete new style sitelink representation
-                                       'dewiki' => array( 'name' => 'Test' ), 
// incomplete new style sitelink representation
-                               ),
-                       )
-               ) );
-
-               // some other user changed a label
-               $updateX = self::makeChange( array(
-                       'id' => $offset + 12,
-                       'type' => 'wikibase-item~update',
-                       'time' => '20130103030303',
-                       'object_id' => $prefixedId,
-                       'revision_id' => $offset + 14,
-                       'user_id' => $userId + 17,
-               ), self::makeDiff( Item::ENTITY_TYPE,
-                       array(),
-                       array(
-                               'label' => array( 'fr' => array( 'name' => 
'Test', 'badges' => array() ) ),
-                       )
-               ) );
-
-               // change link to local wiki
-               $updateLink = self::makeChange( array(
-                       'id' => $offset + 13,
-                       'type' => 'wikibase-item~update',
-                       'time' => '20130102030405',
-                       'object_id' => $prefixedId,
-                       'revision_id' => $offset + 17,
-                       'user_id' => $userId,
-               ), self::makeDiff( Item::ENTITY_TYPE,
-                       array(
-                               'links' => array( 'enwiki' => array( 'name' => 
'Test', 'badges' => array( 'Q555' ) ) ),
-                       ),
-                       array(
-                               'links' => array( 'enwiki' => array( 'name' => 
'Spam', 'badges' => array( 'Q12345' ) ) ),
-                       )
-               ) );
-
-               // change only badges in link to local wiki
-               $updateLinkBadges = self::makeChange( array(
-                       'id' => $offset + 14,
-                       'type' => 'wikibase-item~update',
-                       'time' => '20130102030405',
-                       'object_id' => $prefixedId,
-                       'revision_id' => $offset + 18,
-                       'user_id' => $userId,
-               ), self::makeDiff( Item::ENTITY_TYPE,
-                       array(
-                               'links' => array( 'enwiki' => array( 'name' => 
'Test', 'badges' => array( 'Q555' ) ) ),
-                       ),
-                       array(
-                               'links' => array( 'enwiki' => array( 'name' => 
'Test', 'badges' => array( 'Q12345' ) ) ),
-                       )
-               ) );
-
-               // item deleted
-               $delete = self::makeChange( array(
-                       'id' => $offset + 35,
-                       'type' => 'wikibase-item~remove',
-                       'time' => '20130105050505',
-                       'object_id' => $prefixedId,
-                       'revision_id' => 0,
-                       'user_id' => $userId,
-               ), self::makeDiff( Item::ENTITY_TYPE,
-                       array(
-                               'label' => array( 'en' => 'Test', 'de' => 
'Test' ),
-                               'links' => array( 'enwiki' => 'Test', 'dewiki' 
=> 'Test' ),
-                       ),
-                       array()
-               ) );
-
-               return array(
-                       'create' => $create,  // create item
-                       'update' => $update,  // update item
-                       'create+update' => $create_update, // merged create and 
update
-                       'update/other' => $updateX,        // update by another 
user
-                       'update-link/local' => $updateLink,  // change the link 
to this client wiki
-                       'update-link/local/basges' => $updateLinkBadges,  // 
change the link to this client wiki
-                       'update-link/other' => $updateXLink, // change the link 
to some other client wiki
-                       'create+update+update-link/other' => 
$create_update_link, // merged create and update and update link to other wiki
-                       'delete' => $delete, // delete item
-               );
-       }
-
-       public static function provideCoalesceRuns() {
-               $changes = self::makeTestChanges( 1, 1 );
-
-               $create = $changes['create']; // create item
-               $update = $changes['update']; // update item
-               $create_update = $changes['create+update']; // merged create 
and update
-               $updateX = $changes['update/other']; // update by another user
-               $updateLink = $changes['update-link/local']; // change the link 
to this client wiki
-               $updateXLink = $changes['update-link/other']; // change the 
link to some other client wiki
-               $create_update_link = 
$changes['create+update+update-link/other']; // merged create and update and 
update link to other wiki
-               $delete = $changes['delete']; // delete item
-
-               return array(
-                       array( // #0: empty
-                               array(), // $changes
-                               array(), // $expected
-                       ),
-
-                       array( // #1: single
-                               array( $create ), // $changes
-                               array( $create ), // $expected
-                       ),
-
-                       array( // #2: create and update
-                               array( $create, $update ), // $changes
-                               array( $create_update ), // $expected
-                       ),
-
-                       array( // #3: user change
-                               array( $create, $updateX, $update ), // $changes
-                               array( $create, $updateX, $update ), // 
$expected
-                       ),
-
-                       array( // #4: action change
-                               array( $create, $update, $delete ), // $changes
-                               array( $create_update, $delete ), // $expected
-                       ),
-
-                       array( // #5: relevant link manipulation
-                               array( $create, $updateLink, $update ), // 
$changes
-                               array( $create, $updateLink, $update ), // 
$expected
-                       ),
-
-                       array( // #6: irrelevant link manipulation
-                               array( $create, $update, $updateXLink ), // 
$changes
-                               array( $create_update_link ), // $expected
-                       ),
-               );
-       }
-
-       /**
-        * @dataProvider provideCoalesceRuns
-        */
-       public function testCoalesceRuns( $changes, $expected ) {
-               $handler = $this->newChangeHandler();
-               $coalesced = $handler->coalesceRuns( $changes );
-
-               $this->assertEquals( count( $expected ), count( $coalesced ), 
"number of changes" );
-
-               $i = 0;
-               while ( next( $coalesced ) && next( $expected ) ) {
-                       $this->assertChangeEquals( current( $expected ), 
current( $coalesced ), "expected[" . $i++ . "]" );
-               }
-       }
-
-       public static function provideCoalesceChanges() {
-               $changes11 = self::makeTestChanges( 1, 1 );
-
-               $create11 = $changes11['create']; // create item
-               $update11 = $changes11['update']; // update item
-               $updateXLink11 = $changes11['update-link/other']; // change the 
link to some other client wiki
-               $create_update_link11 = 
$changes11['create+update+update-link/other']; // merged create and update and 
update link to other wiki
-               $delete11 = $changes11['delete']; // delete item
-
-               $changes12 = self::makeTestChanges( 1, 2 );
-
-               $create12 = $changes12['create']; // create item
-               $update12 = $changes12['update']; // update item
-               $create_update12 = $changes12['create+update']; // merged 
create and update
-
-               return array(
-                       array( // #0: empty
-                               array(), // $changes
-                               array(), // $expected
-                       ),
-
-                       array( // #1: single
-                               array( $create11 ), // $changes
-                               array( $create11 ), // $expected
-                       ),
-
-                       array( // #2: unrelated
-                               array( $create11, $update12 ), // $changes
-                               array( $create11, $update12 ), // $expected
-                       ),
-
-                       array( // #3: reversed
-                               array( $update12, $create11 ), // $changes
-                               array( $create11, $update12 ), // $expected
-                       ),
-
-                       array( // #4: mixed
-                               array( $create11, $create12, $update11, 
$update12, $updateXLink11, $delete11 ), // $changes
-                               array( $create_update_link11, $create_update12, 
$delete11 ), // $expected
-                       ),
-               );
-       }
-
-       /**
-        * @dataProvider provideCoalesceChanges
-        */
-       public function testCoalesceChanges( $changes, $expected ) {
-               $handler = $this->newChangeHandler();
-               $coalesced = $handler->coalesceChanges( $changes );
-
-               $this->assertEquals( count( $expected ), count( $coalesced ), 
"number of changes" );
-
-               $i = 0;
-               while ( next( $coalesced ) && next( $expected ) ) {
-                       $this->assertChangeEquals( current( $expected ), 
current( $coalesced ), "expected[" . $i++ . "]" );
-               }
-       }
-
-
-       // 
==================================================================================
-
-       public static function provideHandleChanges() {
+       public function provideHandleChanges() {
                $empty = Item::newEmpty();
                $empty->setId( new ItemId( 'q55668877' ) );
 
@@ -779,14 +175,7 @@
                $handleChangeCallCount = 0;
                $handleChangesCallCount = 0;
 
-               $changeHandler = $this->getMockBuilder( 
'Wikibase\Client\Changes\ChangeHandler' )
-                       ->disableOriginalConstructor()->setMethods( array( 
'coalesceChanges', 'handleChange' ) )->getMock();
-
-               $changeHandler->expects( $this->once() )
-                       ->method( 'coalesceChanges' )->will( 
$this->returnValue( $changes ) );
-
-               $changeHandler->expects( $this->exactly( count( $changes ) ) )
-                       ->method( 'handleChange' );
+               $changeHandler = $this->newChangeHandler();
 
                $changeHandler->handleChanges( $changes );
 
@@ -1083,25 +472,6 @@
                                } ) );
 
                return $usageLookup;
-       }
-
-       /**
-        * @return SiteList
-        */
-       private function getSiteList() {
-               $siteList = $this->getMock( 'SiteList' );
-               $siteList->expects( $this->any() )
-                       ->method( 'getSite' )
-                       ->will( $this->returnCallback( function( $globalSiteId 
) {
-                               $site = new \MediaWikiSite();
-
-                               $site->setGlobalId( $globalSiteId );
-                               $site->setLanguageCode( substr( $globalSiteId, 
0, 2 ) );
-
-                               return $site;
-                       } ) );
-
-               return $siteList;
        }
 
        /**
diff --git a/client/tests/phpunit/includes/Changes/ChangeRunCoalescerTest.php 
b/client/tests/phpunit/includes/Changes/ChangeRunCoalescerTest.php
new file mode 100644
index 0000000..fdfadd9
--- /dev/null
+++ b/client/tests/phpunit/includes/Changes/ChangeRunCoalescerTest.php
@@ -0,0 +1,422 @@
+<?php
+
+namespace Wikibase\Client\Tests\Changes;
+
+use Diff\Differ\MapDiffer;
+use Wikibase\Change;
+use Wikibase\ChangesTable;
+use Wikibase\Client\Changes\ChangeRunCoalescer;
+use Wikibase\DataModel\Entity\Diff\EntityDiff;
+use Wikibase\DataModel\Entity\Item;
+use Wikibase\DataModel\Entity\ItemId;
+use Wikibase\EntityChange;
+use Wikibase\Test\MockRepository;
+use Wikibase\Test\TestChanges;
+
+/**
+ * @covers Wikibase\Client\Changes\ChangeRunCoalescer
+ *
+ * @group Wikibase
+ * @group WikibaseClient
+ * @group WikibaseChange
+ * @group ChangeHandlerTest
+ *
+ * @group Database
+ *
+ * @licence GNU GPL v2+
+ * @author Daniel Kinzler
+ */
+class ChangeRunCoalescerTest extends \MediaWikiTestCase {
+
+       private function newCoalescer( array $entities = array() ) {
+               $repo = $this->getMockRepo( $entities );
+
+               $changeFactory = TestChanges::getEntityChangeFactory();
+
+               $coalescer = new ChangeRunCoalescer(
+                       $repo,
+                       $changeFactory,
+                       'enwiki'
+               );
+
+               return $coalescer;
+       }
+
+       private function getMockRepo( array $entities = array() ) {
+               $repo = new MockRepository();
+
+               // entity 1, revision 11
+               $entity1 = Item::newEmpty();
+               $entity1->setId( new ItemId( 'q1' ) );
+               $entity1->setLabel( 'en', 'one' );
+               $repo->putEntity( $entity1, 11 );
+
+               // entity 1, revision 12
+               $entity1->setLabel( 'de', 'eins' );
+               $repo->putEntity( $entity1, 12 );
+
+               // entity 1, revision 13
+               $entity1->setLabel( 'it', 'uno' );
+               $repo->putEntity( $entity1, 13 );
+
+               // entity 1, revision 1111
+               $entity1->setDescription( 'en', 'the first' );
+               $repo->putEntity( $entity1, 1111 );
+
+               // entity 2, revision 21
+               $entity1 = Item::newEmpty();
+               $entity1->setId( new ItemId( 'q2' ) );
+               $entity1->setLabel( 'en', 'two' );
+               $repo->putEntity( $entity1, 21 );
+
+               // entity 2, revision 22
+               $entity1->setLabel( 'de', 'zwei' );
+               $repo->putEntity( $entity1, 22 );
+
+               // entity 2, revision 23
+               $entity1->setLabel( 'it', 'due' );
+               $repo->putEntity( $entity1, 23 );
+
+               // entity 2, revision 1211
+               $entity1->setDescription( 'en', 'the second' );
+               $repo->putEntity( $entity1, 1211 );
+
+               $this->updateMockRepo( $repo, $entities );
+
+               return $repo;
+       }
+
+       private function updateMockRepo( MockRepository $repo, $entities ) {
+               foreach ( $entities as $id => $siteLinks ) {
+                       if ( !( $siteLinks instanceof Entity ) ) {
+                               $entity = Item::newEmpty();
+                               $entity->setId( new ItemId( $id ) );
+
+                               foreach ( $siteLinks as $siteId => $page ) {
+                                       if ( is_int( $siteId ) ) {
+                                               $siteIdentifier = 
$this->site->getGlobalId();
+                                       } else {
+                                               $siteIdentifier = $siteId;
+                                       }
+
+                                       $entity->addSiteLink( new SiteLink( 
$siteIdentifier, $page ) );
+                               }
+                       } else {
+                               $entity = $siteLinks;
+                       }
+
+                       $repo->putEntity( $entity );
+               }
+       }
+
+       /**
+        * @param array $values
+        * @param EntityDiff|null $diff
+        *
+        * @return EntityChange
+        */
+       private function makeChange( array $values, EntityDiff $diff = null ) {
+               if ( !isset( $values['info'] ) ) {
+                       $values['info'] = array();
+               }
+
+               if ( !isset( $values['info']['metadata'] ) ) {
+                       $values['info']['metadata'] = array();
+               }
+
+               if ( !isset( $values['info']['metadata']['rev_id'] ) && isset( 
$values['revision_id'] ) ) {
+                       $values['info']['metadata']['rev_id'] = $values[ 
'revision_id' ];
+               }
+
+               if ( !isset( $values['info']['metadata']['user_text'] ) && 
isset( $values['user_id'] ) ) {
+                       $values['info']['metadata']['user_text'] = "User" . 
$values['user_id'];
+               }
+
+               if ( !isset( $values['info']['metadata']['parent_id'] ) && 
isset( $values['parent_id'] ) ) {
+                       $values['info']['metadata']['parent_id'] = 
$values['parent_id'];
+               }
+
+               if ( !isset( $values['info']['metadata']['parent_id'] ) ) {
+                       $values['info']['metadata']['parent_id'] = 0;
+               }
+
+               $values['info'] = serialize( $values['info'] );
+
+               /* @var EntityChange $change */
+               $table = ChangesTable::singleton();
+               $change = $table->newRow( $values, true );
+
+               if ( $diff ) {
+                       $change->setDiff( $diff );
+               }
+
+               return $change;
+       }
+
+       private function makeDiff( $type, $before, $after ) {
+               $differ = new MapDiffer( true );
+
+               $diffOps = $differ->doDiff( $before, $after );
+               $diff = EntityDiff::newForType( $type, $diffOps );
+
+               return $diff;
+       }
+
+       private function assertChangeEquals( Change $expected, Change $actual, 
$message = null ) {
+               if ( $message ) {
+                       $message .= ': ';
+               } else {
+                       $message = 'change.';
+               }
+
+               $this->assertEquals( get_class( $expected ), get_class( $actual 
), $message . "class" );
+
+               $this->assertEquals( $expected->getObjectId(), 
$actual->getObjectId(), $message . "ObjectId" );
+               $this->assertEquals( $expected->getTime(), $actual->getTime(), 
$message . "Time" );
+               $this->assertEquals( $expected->getType(), $actual->getType(), 
$message . "Type" );
+               $this->assertEquals( $expected->getUser(), $actual->getUser(), 
$message . "User" );
+
+               if ( $expected instanceof EntityChange && $actual instanceof 
EntityChange ) {
+                       $this->assertEquals( $expected->getAction(), 
$actual->getAction(), $message . "Action" );
+                       $this->assertArrayEquals( $expected->getMetadata(), 
$actual->getMetadata(), false, true );
+               }
+       }
+
+       /**
+        * @todo: move to TestChanges, unify with TestChanges::getChanges()
+        */
+       private function makeTestChanges( $userId, $numericId ) {
+               $prefixedId = 'Q' . $numericId;
+
+               $offset = 100 * $numericId + 1000 * $userId;
+
+               // create with a label and site link set
+               $create = self::makeChange( array(
+                       'id' => $offset + 1,
+                       'type' => 'wikibase-item~add',
+                       'time' => '20130101010101',
+                       'object_id' => $prefixedId,
+                       'revision_id' => $offset + 11,
+                       'user_id' => $userId,
+               ), self::makeDiff( Item::ENTITY_TYPE,
+                       array(),
+                       array(
+                               'label' => array( 'en' => 'Test' ),
+                               'links' => array( 'enwiki' => 'Test' ), // old 
style sitelink representation
+                       )
+               ) );
+
+               // set a label
+               $update = self::makeChange( array(
+                       'id' => $offset + 23,
+                       'type' => 'wikibase-item~update',
+                       'time' => '20130102020202',
+                       'object_id' => $prefixedId,
+                       'revision_id' => $offset + 12,
+                       'user_id' => $userId,
+               ), self::makeDiff( Item::ENTITY_TYPE,
+                       array(),
+                       array(
+                               'label' => array( 'de' => 'Test' ),
+                       )
+               ) );
+
+               // merged change consisting of $create and $update
+               $create_update = self::makeChange( array(
+                       'id' => null,
+                       'type' => $create->getField('type'), // because the 
first change has no parent
+                       'time' => $update->getField('time'), // last change's 
timestamp
+                       'object_id' => $update->getField('object_id'),
+                       'revision_id' => $update->getField('revision_id'), // 
last changes rev id
+                       'user_id' => $update->getField('user_id'),
+                       'info' => array(
+                               'metadata' => array(
+                                       'bot' => 0,
+                                       'comment' => 'wikibase-comment-add' // 
this assumes a specific 'type'
+                               )
+                       )
+               ), self::makeDiff( Item::ENTITY_TYPE,
+                       array(),
+                       array(
+                               'label' => array( 'en' => 'Test', 'de' => 
'Test' ),
+                               'links' => array( 'enwiki' => 'Test' ), // old 
style sitelink representation
+                       )
+               ) );
+
+               // change link to other wiki
+               $updateXLink = self::makeChange( array(
+                       'id' => $offset + 14,
+                       'type' => 'wikibase-item~update',
+                       'time' => '20130101020304',
+                       'object_id' => $prefixedId,
+                       'revision_id' => $offset + 13,
+                       'user_id' => $userId,
+               ), self::makeDiff( Item::ENTITY_TYPE,
+                       array(),
+                       array(
+                               'links' => array( 'dewiki' => array( 'name' => 
'Testen', 'badges' => array() ) ),
+                       )
+               ) );
+
+               // merged change consisting of $create, $update and $updateXLink
+               $create_update_link = self::makeChange( array(
+                       'id' => null,
+                       'type' => $create->getField('type'), // because the 
first change has no parent
+                       'time' => $updateXLink->getField('time'), // last 
change's timestamp
+                       'object_id' => $updateXLink->getField('object_id'),
+                       'revision_id' => $updateXLink->getField('revision_id'), 
// last changes rev id
+                       'user_id' => $updateXLink->getField('user_id'),
+                       'info' => array(
+                               'metadata' => array(
+                                       'bot' => 0,
+                                       'comment' => 'wikibase-comment-add' // 
this assumes a specific 'type'
+                               )
+                       )
+               ), self::makeDiff( Item::ENTITY_TYPE,
+                       array(),
+                       array(
+                               'label' => array( 'en' => 'Test', 'de' => 
'Test' ),
+                               'links' => array(
+                                       'enwiki' => array( 'name' => 'Test' ), 
// incomplete new style sitelink representation
+                                       'dewiki' => array( 'name' => 'Test' ), 
// incomplete new style sitelink representation
+                               ),
+                       )
+               ) );
+
+               // some other user changed a label
+               $updateX = self::makeChange( array(
+                       'id' => $offset + 12,
+                       'type' => 'wikibase-item~update',
+                       'time' => '20130103030303',
+                       'object_id' => $prefixedId,
+                       'revision_id' => $offset + 14,
+                       'user_id' => $userId + 17,
+               ), self::makeDiff( Item::ENTITY_TYPE,
+                       array(),
+                       array(
+                               'label' => array( 'fr' => array( 'name' => 
'Test', 'badges' => array() ) ),
+                       )
+               ) );
+
+               // change link to local wiki
+               $updateLink = self::makeChange( array(
+                       'id' => $offset + 13,
+                       'type' => 'wikibase-item~update',
+                       'time' => '20130102030405',
+                       'object_id' => $prefixedId,
+                       'revision_id' => $offset + 17,
+                       'user_id' => $userId,
+               ), self::makeDiff( Item::ENTITY_TYPE,
+                       array(
+                               'links' => array( 'enwiki' => array( 'name' => 
'Test', 'badges' => array( 'Q555' ) ) ),
+                       ),
+                       array(
+                               'links' => array( 'enwiki' => array( 'name' => 
'Spam', 'badges' => array( 'Q12345' ) ) ),
+                       )
+               ) );
+
+               // change only badges in link to local wiki
+               $updateLinkBadges = self::makeChange( array(
+                       'id' => $offset + 14,
+                       'type' => 'wikibase-item~update',
+                       'time' => '20130102030405',
+                       'object_id' => $prefixedId,
+                       'revision_id' => $offset + 18,
+                       'user_id' => $userId,
+               ), self::makeDiff( Item::ENTITY_TYPE,
+                       array(
+                               'links' => array( 'enwiki' => array( 'name' => 
'Test', 'badges' => array( 'Q555' ) ) ),
+                       ),
+                       array(
+                               'links' => array( 'enwiki' => array( 'name' => 
'Test', 'badges' => array( 'Q12345' ) ) ),
+                       )
+               ) );
+
+               // item deleted
+               $delete = self::makeChange( array(
+                       'id' => $offset + 35,
+                       'type' => 'wikibase-item~remove',
+                       'time' => '20130105050505',
+                       'object_id' => $prefixedId,
+                       'revision_id' => 0,
+                       'user_id' => $userId,
+               ), self::makeDiff( Item::ENTITY_TYPE,
+                       array(
+                               'label' => array( 'en' => 'Test', 'de' => 
'Test' ),
+                               'links' => array( 'enwiki' => 'Test', 'dewiki' 
=> 'Test' ),
+                       ),
+                       array()
+               ) );
+
+               return array(
+                       'create' => $create,  // create item
+                       'update' => $update,  // update item
+                       'create+update' => $create_update, // merged create and 
update
+                       'update/other' => $updateX,        // update by another 
user
+                       'update-link/local' => $updateLink,  // change the link 
to this client wiki
+                       'update-link/local/basges' => $updateLinkBadges,  // 
change the link to this client wiki
+                       'update-link/other' => $updateXLink, // change the link 
to some other client wiki
+                       'create+update+update-link/other' => 
$create_update_link, // merged create and update and update link to other wiki
+                       'delete' => $delete, // delete item
+               );
+       }
+
+       public function provideCoalesceChanges() {
+               $changes11 = self::makeTestChanges( 1, 1 );
+
+               $create11 = $changes11['create']; // create item
+               $update11 = $changes11['update']; // update item
+               $updateXLink11 = $changes11['update-link/other']; // change the 
link to some other client wiki
+               $create_update_link11 = 
$changes11['create+update+update-link/other']; // merged create and update and 
update link to other wiki
+               $delete11 = $changes11['delete']; // delete item
+
+               $changes12 = self::makeTestChanges( 1, 2 );
+
+               $create12 = $changes12['create']; // create item
+               $update12 = $changes12['update']; // update item
+               $create_update12 = $changes12['create+update']; // merged 
create and update
+
+               return array(
+                       array( // #0: empty
+                               array(), // $changes
+                               array(), // $expected
+                       ),
+
+                       array( // #1: single
+                               array( $create11 ), // $changes
+                               array( $create11 ), // $expected
+                       ),
+
+                       array( // #2: unrelated
+                               array( $create11, $update12 ), // $changes
+                               array( $create11, $update12 ), // $expected
+                       ),
+
+                       array( // #3: reversed
+                               array( $update12, $create11 ), // $changes
+                               array( $create11, $update12 ), // $expected
+                       ),
+
+                       array( // #4: mixed
+                               array( $create11, $create12, $update11, 
$update12, $updateXLink11, $delete11 ), // $changes
+                               array( $create_update_link11, $create_update12, 
$delete11 ), // $expected
+                       ),
+               );
+       }
+
+       /**
+        * @dataProvider provideCoalesceChanges
+        */
+       public function testCoalesceChanges( $changes, $expected ) {
+               $coalescer = $this->newCoalescer();
+               $coalesced = $coalescer->transformChangeList( $changes );
+
+               $this->assertEquals( count( $expected ), count( $coalesced ), 
"number of changes" );
+
+               $i = 0;
+               while ( next( $coalesced ) && next( $expected ) ) {
+                       $this->assertChangeEquals( current( $expected ), 
current( $coalesced ), "expected[" . $i++ . "]" );
+               }
+       }
+
+}

-- 
To view, visit https://gerrit.wikimedia.org/r/170995
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: merged
Gerrit-Change-Id: Ib0085abc4c1cbf8ca162a031a96008840690907b
Gerrit-PatchSet: 5
Gerrit-Project: mediawiki/extensions/Wikibase
Gerrit-Branch: master
Gerrit-Owner: Daniel Kinzler <[email protected]>
Gerrit-Reviewer: Adrian Lang <[email protected]>
Gerrit-Reviewer: Aude <[email protected]>
Gerrit-Reviewer: Daniel Kinzler <[email protected]>
Gerrit-Reviewer: JanZerebecki <[email protected]>
Gerrit-Reviewer: Thiemo Mättig (WMDE) <[email protected]>
Gerrit-Reviewer: jenkins-bot <>

_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to