Merge "Move mssql class to /libs"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Fri, 10 Feb 2017 20:20:16 +0000 (20:20 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Fri, 10 Feb 2017 20:20:16 +0000 (20:20 +0000)
41 files changed:
.eslintrc.json
RELEASE-NOTES-1.29
autoload.php
composer.json
includes/AjaxDispatcher.php
includes/FileDeleteForm.php
includes/WatchedItemStore.php
includes/filebackend/filejournal/DBFileJournal.php
includes/installer/MysqlUpdater.php
includes/jobqueue/jobs/HTMLCacheUpdateJob.php
includes/libs/rdbms/ChronologyProtector.php
includes/libs/rdbms/database/DBConnRef.php
includes/libs/rdbms/database/Database.php
includes/libs/rdbms/database/DatabaseMysqlBase.php
includes/libs/rdbms/database/IDatabase.php
includes/libs/rdbms/database/position/DBMasterPos.php
includes/libs/rdbms/database/position/MySQLMasterPos.php
includes/libs/rdbms/loadbalancer/ILoadBalancer.php
includes/libs/rdbms/loadbalancer/LoadBalancer.php
includes/revisiondelete/RevDelList.php
includes/specials/SpecialExport.php
resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js
resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterItem.js
resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js
resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js
resources/src/mediawiki.rcfilters/mw.rcfilters.init.js
resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.CapsuleItemWidget.less
resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterCapsuleMultiselectWidget.less
resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterItemWidget.less
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.CapsuleItemWidget.js
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterItemWidget.js
resources/src/mediawiki.special/mediawiki.special.apisandbox.js
resources/src/mediawiki/api.js
resources/src/mediawiki/mediawiki.js
resources/src/startup.js
tests/phpunit/includes/db/DatabaseMysqlBaseTest.php
tests/phpunit/includes/db/LBFactoryTest.php
tests/phpunit/includes/skins/SkinTemplateTest.php
tests/qunit/suites/resources/mediawiki.api/mediawiki.api.test.js
tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js
tests/qunit/suites/resources/mediawiki/mediawiki.test.js

index 044dd72..98d0f10 100644 (file)
@@ -9,7 +9,6 @@
                "require": false,
                "module": false,
                "mediaWiki": false,
-               "mwPerformance": false,
                "OO": false
        },
        "rules": {
index c92b522..ebd967e 100644 (file)
@@ -57,6 +57,8 @@ production.
 
 ==== Upgraded external libraries ====
 * Updated QUnit from v1.22.0 to v1.23.1.
+* Updated cssjanus from v1.1.2 to 1.1.3.
+* Updated psr/log from v1.0.0 to v1.0.2.
 
 ==== New external libraries ====
 
index c64e49d..38a81e8 100644 (file)
@@ -313,7 +313,6 @@ $wgAutoloadLocalClasses = [
        'DBExpectedError' => __DIR__ . '/includes/libs/rdbms/exception/DBExpectedError.php',
        'DBFileJournal' => __DIR__ . '/includes/filebackend/filejournal/DBFileJournal.php',
        'DBLockManager' => __DIR__ . '/includes/libs/lockmanager/DBLockManager.php',
-       'DBMasterPos' => __DIR__ . '/includes/libs/rdbms/database/position/DBMasterPos.php',
        'DBQueryError' => __DIR__ . '/includes/libs/rdbms/exception/DBQueryError.php',
        'DBReadOnlyError' => __DIR__ . '/includes/libs/rdbms/exception/DBReadOnlyError.php',
        'DBReplicationWaitError' => __DIR__ . '/includes/libs/rdbms/exception/DBReplicationWaitError.php',
@@ -982,7 +981,6 @@ $wgAutoloadLocalClasses = [
        'MutableContext' => __DIR__ . '/includes/context/MutableContext.php',
        'MwSql' => __DIR__ . '/maintenance/sql.php',
        'MySQLField' => __DIR__ . '/includes/libs/rdbms/field/MySQLField.php',
-       'MySQLMasterPos' => __DIR__ . '/includes/libs/rdbms/database/position/MySQLMasterPos.php',
        'MySqlLockManager' => __DIR__ . '/includes/filebackend/lockmanager/MySqlLockManager.php',
        'MysqlInstaller' => __DIR__ . '/includes/installer/MysqlInstaller.php',
        'MysqlUpdater' => __DIR__ . '/includes/installer/MysqlUpdater.php',
@@ -1584,6 +1582,7 @@ $wgAutoloadLocalClasses = [
        'WikiTextStructure' => __DIR__ . '/includes/content/WikiTextStructure.php',
        'Wikimedia\\Rdbms\\ChronologyProtector' => __DIR__ . '/includes/libs/rdbms/ChronologyProtector.php',
        'Wikimedia\\Rdbms\\ConnectionManager' => __DIR__ . '/includes/libs/rdbms/connectionmanager/ConnectionManager.php',
+       'Wikimedia\\Rdbms\\DBMasterPos' => __DIR__ . '/includes/libs/rdbms/database/position/DBMasterPos.php',
        'Wikimedia\\Rdbms\\DatabaseDomain' => __DIR__ . '/includes/libs/rdbms/database/DatabaseDomain.php',
        'Wikimedia\\Rdbms\\ILBFactory' => __DIR__ . '/includes/libs/rdbms/lbfactory/ILBFactory.php',
        'Wikimedia\\Rdbms\\ILoadBalancer' => __DIR__ . '/includes/libs/rdbms/loadbalancer/ILoadBalancer.php',
@@ -1596,6 +1595,7 @@ $wgAutoloadLocalClasses = [
        'Wikimedia\\Rdbms\\LoadMonitor' => __DIR__ . '/includes/libs/rdbms/loadmonitor/LoadMonitor.php',
        'Wikimedia\\Rdbms\\LoadMonitorMySQL' => __DIR__ . '/includes/libs/rdbms/loadmonitor/LoadMonitorMySQL.php',
        'Wikimedia\\Rdbms\\LoadMonitorNull' => __DIR__ . '/includes/libs/rdbms/loadmonitor/LoadMonitorNull.php',
+       'Wikimedia\\Rdbms\\MySQLMasterPos' => __DIR__ . '/includes/libs/rdbms/database/position/MySQLMasterPos.php',
        'Wikimedia\\Rdbms\\SessionConsistentConnectionManager' => __DIR__ . '/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManager.php',
        'Wikimedia\\Rdbms\\TransactionProfiler' => __DIR__ . '/includes/libs/rdbms/TransactionProfiler.php',
        'WikitextContent' => __DIR__ . '/includes/content/WikitextContent.php',
index 1645495..d41492e 100644 (file)
@@ -17,7 +17,7 @@
        },
        "require": {
                "composer/semver": "1.4.2",
-               "cssjanus/cssjanus": "1.1.2",
+               "cssjanus/cssjanus": "1.1.3",
                "ext-ctype": "*",
                "ext-iconv": "*",
                "ext-json": "*",
@@ -28,7 +28,7 @@
                "oojs/oojs-ui": "0.19.1",
                "oyejorge/less.php": "1.7.0.10",
                "php": ">=5.5.9",
-               "psr/log": "1.0.0",
+               "psr/log": "1.0.2",
                "wikimedia/assert": "0.2.2",
                "wikimedia/base-convert": "1.0.1",
                "wikimedia/cdb": "1.4.1",
@@ -55,7 +55,9 @@
                "nikic/php-parser": "2.1.0",
                "nmred/kafka-php": "0.1.5",
                "phpunit/phpunit": "4.8.31",
-               "wikimedia/avro": "1.7.7"
+               "wikimedia/avro": "1.7.7",
+               "hamcrest/hamcrest-php": "^2.0",
+               "wmde/hamcrest-html-matchers": "^0.1.0"
        },
        "suggest": {
                "ext-apc": "Local data and opcode cache",
                        "ComposerHookHandler": "includes/composer"
                }
        },
+       "autoload-dev": {
+               "files": [
+                       "vendor/hamcrest/hamcrest-php/hamcrest/Hamcrest.php",
+                       "vendor/wmde/hamcrest-html-matchers/src/functions.php"
+               ]
+       },
        "scripts": {
                "lint": "parallel-lint --exclude vendor",
                "phpcs": "phpcs -p -s",
index d444a27..2adbc80 100644 (file)
@@ -21,6 +21,8 @@
  * @ingroup Ajax
  */
 
+use MediaWiki\MediaWikiServices;
+
 /**
  * @defgroup Ajax Ajax
  */
@@ -135,7 +137,8 @@ class AjaxDispatcher {
                                        }
 
                                        // Make sure DB commit succeeds before sending a response
-                                       wfGetLBFactory()->commitMasterChanges( __METHOD__ );
+                                       $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+                                       $lbFactory->commitMasterChanges( __METHOD__ );
 
                                        $result->sendHeaders();
                                        $result->printText();
index 82af081..f284d92 100644 (file)
@@ -206,7 +206,8 @@ class FileDeleteForm {
                                        $dbw->endAtomic( __METHOD__ );
                                } else {
                                        // Page deleted but file still there? rollback page delete
-                                       wfGetLBFactory()->rollbackMasterChanges( __METHOD__ );
+                                       $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+                                       $lbFactory->rollbackMasterChanges( __METHOD__ );
                                }
                        } else {
                                // Done; nothing changed
index 3cdc59c..858d87b 100644 (file)
@@ -2,6 +2,7 @@
 
 use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
 use MediaWiki\Linker\LinkTarget;
+use MediaWiki\MediaWikiServices;
 use Wikimedia\Assert\Assert;
 use Wikimedia\ScopedCallback;
 
@@ -734,7 +735,7 @@ class WatchedItemStore implements StatsdAwareInterface {
                                        global $wgUpdateRowsPerQuery;
 
                                        $dbw = $this->getConnectionRef( DB_MASTER );
-                                       $factory = wfGetLBFactory();
+                                       $factory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
                                        $ticket = $factory->getEmptyTransactionTicket( __METHOD__ );
 
                                        $watchersChunks = array_chunk( $watchers, $wgUpdateRowsPerQuery );
index 2e06c40..62e635d 100644 (file)
@@ -22,6 +22,8 @@
  * @author Aaron Schulz
  */
 
+use MediaWiki\MediaWikiServices;
+
 /**
  * Version of FileJournal that logs to a DB table
  * @since 1.20
@@ -180,7 +182,7 @@ class DBFileJournal extends FileJournal {
        protected function getMasterDB() {
                if ( !$this->dbw ) {
                        // Get a separate connection in autocommit mode
-                       $lb = wfGetLBFactory()->newMainLB();
+                       $lb =  MediaWikiServices::getInstance()->getDBLoadBalancerFactory()->newMainLB();
                        $this->dbw = $lb->getConnection( DB_MASTER, [], $this->wiki );
                        $this->dbw->clearFlag( DBO_TRX );
                }
index 9be6c3d..7fa5a3d 100644 (file)
@@ -20,6 +20,7 @@
  * @file
  * @ingroup Deployment
  */
+use MediaWiki\MediaWikiServices;
 
 /**
  * Mysql update list and mysql-specific update functions.
@@ -853,7 +854,8 @@ class MysqlUpdater extends DatabaseUpdater {
                        foreach ( $res as $row ) {
                                $count = ( $count + 1 ) % 100;
                                if ( $count == 0 ) {
-                                       wfGetLBFactory()->waitForReplication( [ 'wiki' => wfWikiID() ] );
+                                       $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+                                       $lbFactory->waitForReplication( [ 'wiki' => wfWikiID() ] );
                                }
                                $this->db->insert( 'templatelinks',
                                        [
index f09ba57..2d816f9 100644 (file)
@@ -22,6 +22,8 @@
  * @ingroup Cache
  */
 
+use MediaWiki\MediaWikiServices;
+
 /**
  * Job to purge the cache for all pages that link to or use another page or file
  *
@@ -113,7 +115,7 @@ class HTMLCacheUpdateJob extends Job {
                $touchTimestamp = wfTimestampNow();
 
                $dbw = wfGetDB( DB_MASTER );
-               $factory = wfGetLBFactory();
+               $factory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
                $ticket = $factory->getEmptyTransactionTicket( __METHOD__ );
                // Update page_touched (skipping pages already touched since the root job).
                // Check $wgUpdateRowsPerQuery for sanity; batch jobs are sized by that already.
index 0daa4ed..99e509c 100644 (file)
@@ -28,7 +28,6 @@ use Psr\Log\LoggerInterface;
 use Psr\Log\NullLogger;
 use Wikimedia\WaitConditionLoop;
 use BagOStuff;
-use DBMasterPos;
 
 /**
  * Class for ensuring a consistent ordering of events as seen by the user, despite replication.
index a8f664d..fc3ebe0 100644 (file)
@@ -2,6 +2,7 @@
 
 use Wikimedia\Rdbms\DatabaseDomain;
 use Wikimedia\Rdbms\ILoadBalancer;
+use Wikimedia\Rdbms\DBMasterPos;
 
 /**
  * Helper class to handle automatically marking connections as reusable (via RAII pattern)
index 72e39b7..0afce49 100644 (file)
@@ -29,6 +29,7 @@ use Wikimedia\ScopedCallback;
 use Wikimedia\Rdbms\TransactionProfiler;
 use Wikimedia\Rdbms\LikeMatch;
 use Wikimedia\Rdbms\DatabaseDomain;
+use Wikimedia\Rdbms\DBMasterPos;
 
 /**
  * Relational database abstraction object
index ceed7da..361fc50 100644 (file)
@@ -20,6 +20,8 @@
  * @file
  * @ingroup Database
  */
+use Wikimedia\Rdbms\DBMasterPos;
+use Wikimedia\Rdbms\MySQLMasterPos;
 
 /**
  * Database abstraction object for MySQL.
index f1613eb..9fbea51 100644 (file)
@@ -25,6 +25,7 @@
  */
 use Wikimedia\ScopedCallback;
 use Wikimedia\Rdbms\LikeMatch;
+use Wikimedia\Rdbms\DBMasterPos;
 
 /**
  * Basic database interface for live and lazy-loaded relation database handles
index eda0ff3..2f79ea9 100644 (file)
@@ -1,4 +1,7 @@
 <?php
+
+namespace Wikimedia\Rdbms;
+
 /**
  * An object representing a master or replica DB position in a replicated setup.
  *
index 7b49ce9..06776fe 100644 (file)
@@ -1,4 +1,9 @@
 <?php
+
+namespace Wikimedia\Rdbms;
+
+use InvalidArgumentException;
+
 /**
  * DBMasterPos class for MySQL/MariaDB
  *
index d6cff78..4e6f6b0 100644 (file)
@@ -29,7 +29,6 @@ use DBConnRef;
 use MaintainableDBConnRef;
 use DBError;
 use DBAccessError;
-use DBMasterPos;
 use DBTransactionError;
 use DBExpectedError;
 use Exception;
index 3442b73..900a79c 100644 (file)
@@ -27,6 +27,7 @@ use Wikimedia\Rdbms\TransactionProfiler;
 use Wikimedia\Rdbms\ILoadMonitor;
 use Wikimedia\Rdbms\DatabaseDomain;
 use Wikimedia\Rdbms\ILoadBalancer;
+use Wikimedia\Rdbms\DBMasterPos;
 
 /**
  * Database connection, tracking, load balancing, and transaction manager for a cluster
@@ -490,7 +491,10 @@ class LoadBalancer implements ILoadBalancer {
                $key = $this->srvCache->makeGlobalKey( __CLASS__, 'last-known-pos', $server );
                /** @var DBMasterPos $knownReachedPos */
                $knownReachedPos = $this->srvCache->get( $key );
-               if ( $knownReachedPos && $knownReachedPos->hasReached( $this->mWaitForPos ) ) {
+               if (
+                       $knownReachedPos instanceof DBMasterPos &&
+                       $knownReachedPos->hasReached( $this->mWaitForPos )
+               ) {
                        $this->replLogger->debug( __METHOD__ .
                                ": replica DB $server known to be caught up (pos >= $knownReachedPos)." );
                        return true;
index 833e38b..64a6aec 100644 (file)
@@ -19,6 +19,8 @@
  * @ingroup RevisionDelete
  */
 
+use MediaWiki\MediaWikiServices;
+
 /**
  * Abstract base class for a list of deletable items. The list class
  * needs to be able to make a query from a set of identifiers to pull
@@ -255,7 +257,8 @@ abstract class RevDelList extends RevisionListBase {
                $status->merge( $this->doPreCommitUpdates() );
                if ( !$status->isOK() ) {
                        // Fatal error, such as no configured archive directory or I/O failures
-                       wfGetLBFactory()->rollbackMasterChanges( __METHOD__ );
+                       $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+                       $lbFactory->rollbackMasterChanges( __METHOD__ );
                        return $status;
                }
 
index bf535a6..2d6ba4a 100644 (file)
@@ -23,6 +23,8 @@
  * @ingroup SpecialPage
  */
 
+use Mediawiki\MediaWikiServices;
+
 /**
  * A special page that allows users to export pages in a XML file
  *
@@ -374,7 +376,7 @@ class SpecialExport extends SpecialPage {
                        $buffer = WikiExporter::BUFFER;
                } else {
                        // Use an unbuffered query; histories may be very long!
-                       $lb = wfGetLBFactory()->newMainLB();
+                       $lb = MediaWikiServices::getInstance()->getDBLoadBalancerFactory()->newMainLB();
                        $db = $lb->getConnection( DB_REPLICA );
                        $buffer = WikiExporter::STREAM;
 
index da9e59e..14a610b 100644 (file)
@@ -6,27 +6,31 @@
         * @mixins OO.EmitterList
         *
         * @constructor
+        * @param {string} name Group name
         * @param {Object} [config] Configuration options
-        * @cfg {string} [name] Group name
         * @cfg {string} [type='send_unselected_if_any'] Group type
         * @cfg {string} [title] Group title
         * @cfg {string} [separator='|'] Value separator for 'string_options' groups
-        * @cfg {string} [exclusionType='default'] Group exclusion type
         * @cfg {boolean} [active] Group is active
+        * @cfg {boolean} [fullCoverage] This filters in this group collectively cover all results
         */
-       mw.rcfilters.dm.FilterGroup = function MwRcfiltersDmFilterGroup( config ) {
+       mw.rcfilters.dm.FilterGroup = function MwRcfiltersDmFilterGroup( name, config ) {
                config = config || {};
 
                // Mixin constructor
                OO.EventEmitter.call( this );
                OO.EmitterList.call( this );
 
-               this.name = config.name;
+               this.name = name;
                this.type = config.type || 'send_unselected_if_any';
                this.title = config.title;
                this.separator = config.separator || '|';
-               this.exclusionType = config.exclusionType || 'default';
+
                this.active = !!config.active;
+               this.fullCoverage = !!config.fullCoverage;
+
+               this.aggregate( { update: 'filterItemUpdate' } );
+               this.connect( this, { filterItemUpdate: 'onFilterItemUpdate' } );
        };
 
        /* Initialization */
        /* Methods */
 
        /**
-        * Check the active status of the group and set it accordingly.
+        * Respond to filterItem update event
         *
         * @fires update
         */
-       mw.rcfilters.dm.FilterGroup.prototype.checkActive = function () {
-               var active,
-                       count = 0;
-
-               // Recheck group activity
-               this.getItems().forEach( function ( filterItem ) {
-                       count += Number( filterItem.isSelected() );
-               } );
-
-               active = (
-                       count > 0 &&
-                       count < this.getItemCount()
-               );
+       mw.rcfilters.dm.FilterGroup.prototype.onFilterItemUpdate = function () {
+               // Update state
+               var active = this.areAnySelected();
 
                if ( this.active !== active ) {
                        this.active = active;
                return this.name;
        };
 
+       /**
+        * Check whether there are any items selected
+        *
+        * @return {boolean} Any items in the group are selected
+        */
+       mw.rcfilters.dm.FilterGroup.prototype.areAnySelected = function () {
+               return this.getItems().some( function ( filterItem ) {
+                       return filterItem.isSelected();
+               } );
+       };
+
+       /**
+        * Check whether all items selected
+        *
+        * @return {boolean} All items are selected
+        */
+       mw.rcfilters.dm.FilterGroup.prototype.areAllSelected = function () {
+               return this.getItems().every( function ( filterItem ) {
+                       return filterItem.isSelected();
+               } );
+       };
+
+       /**
+        * Get all selected items in this group
+        *
+        * @param {mw.rcfilters.dm.FilterItem} [excludeItem] Item to exclude from the list
+        * @return {mw.rcfilters.dm.FilterItem[]} Selected items
+        */
+       mw.rcfilters.dm.FilterGroup.prototype.getSelectedItems = function ( excludeItem ) {
+               var excludeName = ( excludeItem && excludeItem.getName() ) || '';
+
+               return this.getItems().filter( function ( item ) {
+                       return item.getName() !== excludeName && item.isSelected();
+               } );
+       };
+
+       /**
+        * Check whether all selected items are in conflict with the given item
+        *
+        * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item to test
+        * @return {boolean} All selected items are in conflict with this item
+        */
+       mw.rcfilters.dm.FilterGroup.prototype.areAllSelectedInConflictWith = function ( filterItem ) {
+               var selectedItems = this.getSelectedItems( filterItem );
+
+               return selectedItems.length > 0 && selectedItems.every( function ( selectedFilter ) {
+                       return selectedFilter.existsInConflicts( filterItem );
+               } );
+       };
+
+       /**
+        * Check whether any of the selected items are in conflict with the given item
+        *
+        * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item to test
+        * @return {boolean} Any of the selected items are in conflict with this item
+        */
+       mw.rcfilters.dm.FilterGroup.prototype.areAnySelectedInConflictWith = function ( filterItem ) {
+               var selectedItems = this.getSelectedItems( filterItem );
+
+               return selectedItems.length > 0 && selectedItems.some( function ( selectedFilter ) {
+                       return selectedFilter.existsInConflicts( filterItem );
+               } );
+       };
+
        /**
         * Get group type
         *
        };
 
        /**
-        * Get group exclusion type
+        * Check whether the group is defined as full coverage
         *
-        * @return {string} Exclusion type
+        * @return {boolean} Group is full coverage
         */
-       mw.rcfilters.dm.FilterGroup.prototype.getExclusionType = function () {
-               return this.exclusionType;
+       mw.rcfilters.dm.FilterGroup.prototype.isFullCoverage = function () {
+               return this.fullCoverage;
        };
 }( mediaWiki ) );
index 5dfb68d..39c7667 100644 (file)
@@ -6,6 +6,7 @@
         *
         * @constructor
         * @param {string} name Filter name
+        * @param {mw.rcfilters.dm.FilterGroup} groupModel Filter group model
         * @param {Object} config Configuration object
         * @cfg {string} [group] The group this item belongs to
         * @cfg {string} [label] The label for the filter
         * @cfg {boolean} [active=true] The filter is active and affecting the result
         * @cfg {string[]} [excludes=[]] A list of filter names this filter, if
         *  selected, makes inactive.
-        * @cfg {boolean} [default] The default state of this filter
+        * @cfg {boolean} [selected] The item is selected
+        * @cfg {string[]} [subset] Defining the names of filters that are a subset of this filter
+        * @cfg {string[]} [conflictsWith] Defining the names of filters that conflict with this item
         */
-       mw.rcfilters.dm.FilterItem = function MwRcfiltersDmFilterItem( name, config ) {
+       mw.rcfilters.dm.FilterItem = function MwRcfiltersDmFilterItem( name, groupModel, config ) {
                config = config || {};
 
                // Mixin constructor
                OO.EventEmitter.call( this );
 
                this.name = name;
-               this.group = config.group || '';
+               this.groupModel = groupModel;
+
                this.label = config.label || this.name;
                this.description = config.description;
-               this.default = !!config.default;
+               this.selected = !!config.selected;
+
+               // Interaction definitions
+               this.subset = config.subset || [];
+               this.conflicts = config.conflicts || [];
+               this.superset = [];
 
-               this.active = config.active === undefined ? true : !!config.active;
-               this.excludes = config.excludes || [];
-               this.selected = this.default;
+               // Interaction states
+               this.included = false;
+               this.conflicted = false;
+               this.fullyCovered = false;
        };
 
        /* Initialization */
                return this.name;
        };
 
+       /**
+        * Get the model of the group this filter belongs to
+        *
+        * @return {mw.rcfilters.dm.FilterGroup} Filter group model
+        */
+       mw.rcfilters.dm.FilterItem.prototype.getGroupModel = function () {
+               return this.groupModel;
+       };
+
        /**
         * Get the group name this filter belongs to
         *
         * @return {string} Filter group name
         */
-       mw.rcfilters.dm.FilterItem.prototype.getGroup = function () {
-               return this.group;
+       mw.rcfilters.dm.FilterItem.prototype.getGroupName = function () {
+               return this.groupModel.getName();
        };
 
        /**
                return this.default;
        };
 
+       /**
+        * Get filter subset
+        * This is a list of filter names that are defined to be included
+        * when this filter is selected.
+        *
+        * @return {string[]} Filter subset
+        */
+       mw.rcfilters.dm.FilterItem.prototype.getSubset = function () {
+               return this.subset;
+       };
+
+       /**
+        * Get filter superset
+        * This is a generated list of filters that define this filter
+        * to be included when either of them is selected.
+        *
+        * @return {string[]} Filter superset
+        */
+       mw.rcfilters.dm.FilterItem.prototype.getSuperset = function () {
+               return this.superset;
+       };
+
        /**
         * Get the selected state of this filter
         *
        };
 
        /**
-        * Check if this filter is active
+        * Check whether the filter is currently in a conflict state
+        *
+        * @return {boolean} Filter is in conflict state
+        */
+       mw.rcfilters.dm.FilterItem.prototype.isConflicted = function () {
+               return this.conflicted;
+       };
+
+       /**
+        * Check whether the filter is currently in an already included subset
         *
-        * @return {boolean} Filter is active
+        * @return {boolean} Filter is in an already-included subset
         */
-       mw.rcfilters.dm.FilterItem.prototype.isActive = function () {
-               return this.active;
+       mw.rcfilters.dm.FilterItem.prototype.isIncluded = function () {
+               return this.included;
        };
 
        /**
-        * Check if this filter has a list of excluded filters
+        * Check whether the filter is currently fully covered
         *
-        * @return {boolean} Filter has a list of excluded filters
+        * @return {boolean} Filter is in fully-covered state
         */
-       mw.rcfilters.dm.FilterItem.prototype.hasExcludedFilters = function () {
-               return !!this.excludes.length;
+       mw.rcfilters.dm.FilterItem.prototype.isFullyCovered = function () {
+               return this.fullyCovered;
        };
 
        /**
-        * Get this filter's list of excluded filters
+        * Get filter conflicts
         *
-        * @return {string[]} Array of excluded filter names
+        * @return {string[]} Filter conflicts
         */
-       mw.rcfilters.dm.FilterItem.prototype.getExcludedFilters = function () {
-               return this.excludes;
+       mw.rcfilters.dm.FilterItem.prototype.getConflicts = function () {
+               return this.conflicts;
        };
 
        /**
-        * Toggle the active state of the item
+        * Set filter conflicts
         *
-        * @param {boolean} [isActive] Filter is active
+        * @param {string[]} conflicts Filter conflicts
+        */
+       mw.rcfilters.dm.FilterItem.prototype.setConflicts = function ( conflicts ) {
+               this.conflicts = conflicts || [];
+       };
+
+       /**
+        * Set filter superset
+        *
+        * @param {string[]} superset Filter superset
+        */
+       mw.rcfilters.dm.FilterItem.prototype.setSuperset = function ( superset ) {
+               this.superset = superset || [];
+       };
+
+       /**
+        * Check whether a filter exists in the subset list for this filter
+        *
+        * @param {string} filterName Filter name
+        * @return {boolean} Filter name is in the subset list
+        */
+       mw.rcfilters.dm.FilterItem.prototype.existsInSubset = function ( filterName ) {
+               return this.subset.indexOf( filterName ) > -1;
+       };
+
+       /**
+        * Check whether this item has a potential conflict with the given item
+        *
+        * This checks whether the given item is in the list of conflicts of
+        * the current item, but makes no judgment about whether the conflict
+        * is currently at play (either one of the items may not be selected)
+        *
+        * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item
+        * @return {boolean} This item has a conflict with the given item
+        */
+       mw.rcfilters.dm.FilterItem.prototype.existsInConflicts = function ( filterItem ) {
+               return this.conflicts.indexOf( filterItem.getName() ) > -1;
+       };
+
+       /**
+        * Set the state of this filter as being conflicted
+        * (This means any filters in its conflicts are selected)
+        *
+        * @param {boolean} [conflicted] Filter is in conflict state
+        * @fires update
+        */
+       mw.rcfilters.dm.FilterItem.prototype.toggleConflicted = function ( conflicted ) {
+               conflicted = conflicted === undefined ? !this.conflicted : conflicted;
+
+               if ( this.conflicted !== conflicted ) {
+                       this.conflicted = conflicted;
+                       this.emit( 'update' );
+               }
+       };
+
+       /**
+        * Set the state of this filter as being already included
+        * (This means any filters in its superset are selected)
+        *
+        * @param {boolean} [included] Filter is included as part of a subset
         * @fires update
         */
-       mw.rcfilters.dm.FilterItem.prototype.toggleActive = function ( isActive ) {
-               isActive = isActive === undefined ? !this.active : isActive;
+       mw.rcfilters.dm.FilterItem.prototype.toggleIncluded = function ( included ) {
+               included = included === undefined ? !this.included : included;
 
-               if ( this.active !== isActive ) {
-                       this.active = isActive;
+               if ( this.included !== included ) {
+                       this.included = included;
                        this.emit( 'update' );
                }
        };
                        this.emit( 'update' );
                }
        };
+
+       /**
+        * Toggle the fully covered state of the item
+        *
+        * @param {boolean} [isFullyCovered] Filter is fully covered
+        * @fires update
+        */
+       mw.rcfilters.dm.FilterItem.prototype.toggleFullyCovered = function ( isFullyCovered ) {
+               isFullyCovered = isFullyCovered === undefined ? !this.fullycovered : isFullyCovered;
+
+               if ( this.fullyCovered !== isFullyCovered ) {
+                       this.fullyCovered = isFullyCovered;
+                       this.emit( 'update' );
+               }
+       };
 }( mediaWiki ) );
index 5bbeabf..13f7d31 100644 (file)
                OO.EmitterList.call( this );
 
                this.groups = {};
-               this.excludedByMap = {};
                this.defaultParams = {};
                this.defaultFiltersEmpty = null;
 
                // Events
                this.aggregate( { update: 'filterItemUpdate' } );
-               this.connect( this, { filterItemUpdate: 'onFilterItemUpdate' } );
+               this.connect( this, { filterItemUpdate: [ 'emit', 'itemUpdate' ] } );
        };
 
        /* Initialization */
        /* Methods */
 
        /**
-        * Respond to filter item change.
+        * Re-assess the states of filter items based on the interactions between them
         *
-        * @param {mw.rcfilters.dm.FilterItem} item Updated filter
-        * @fires itemUpdate
+        * @param {mw.rcfilters.dm.FilterItem} [item] Changed item. If not given, the
+        *  method will go over the state of all items
         */
-       mw.rcfilters.dm.FiltersViewModel.prototype.onFilterItemUpdate = function ( item ) {
-               // Reapply the active state of filters
-               this.reapplyActiveFilters( item );
+       mw.rcfilters.dm.FiltersViewModel.prototype.reassessFilterInteractions = function ( item ) {
+               var allSelected,
+                       model = this,
+                       iterationItems = item !== undefined ? [ item ] : this.getItems();
 
-               // Recheck group activity state
-               this.getGroup( item.getGroup() ).checkActive();
+               iterationItems.forEach( function ( checkedItem ) {
+                       var allCheckedItems = checkedItem.getSubset().concat( [ checkedItem.getName() ] ),
+                               groupModel = checkedItem.getGroupModel();
 
-               this.emit( 'itemUpdate', item );
-       };
+                       // Check for subsets (included filters) plus the item itself:
+                       allCheckedItems.forEach( function ( filterItemName ) {
+                               var itemInSubset = model.getItemByName( filterItemName );
 
-       /**
-        * Calculate the active state of the filters, based on selected filters in the group.
-        *
-        * @param {mw.rcfilters.dm.FilterItem} item Changed item
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.reapplyActiveFilters = function ( item ) {
-               var selectedItemsCount,
-                       group = item.getGroup(),
-                       model = this;
-               if (
-                       !this.getGroup( group ).getExclusionType() ||
-                       this.getGroup( group ).getExclusionType() === 'default'
-               ) {
-                       // Default behavior
-                       // If any parameter is selected, but:
-                       // - If there are unselected items in the group, they are inactive
-                       // - If the entire group is selected, all are inactive
-
-                       // Check what's selected in the group
-                       selectedItemsCount = this.getGroupFilters( group ).filter( function ( filterItem ) {
-                               return filterItem.isSelected();
-                       } ).length;
-
-                       this.getGroupFilters( group ).forEach( function ( filterItem ) {
-                               filterItem.toggleActive(
-                                       selectedItemsCount > 0 ?
-                                               // If some items are selected
-                                               (
-                                                       selectedItemsCount === model.groups[ group ].getItemCount() ?
-                                                       // If **all** items are selected, they're all inactive
-                                                       false :
-                                                       // If not all are selected, then the selected are active
-                                                       // and the unselected are inactive
-                                                       filterItem.isSelected()
-                                               ) :
-                                               // No item is selected, everything is active
-                                               true
+                               itemInSubset.toggleIncluded(
+                                       // If any of itemInSubset's supersets are selected, this item
+                                       // is included
+                                       itemInSubset.getSuperset().some( function ( supersetName ) {
+                                               return ( model.getItemByName( supersetName ).isSelected() );
+                                       } )
                                );
                        } );
-               } else if ( this.getGroup( group ).getExclusionType() === 'explicit' ) {
-                       // Explicit behavior
-                       // - Go over the list of excluded filters to change their
-                       //   active states accordingly
-
-                       // For each item in the list, see if there are other selected
-                       // filters that also exclude it. If it does, it will still be
-                       // inactive.
-
-                       item.getExcludedFilters().forEach( function ( filterName ) {
-                               var filterItem = model.getItemByName( filterName );
-
-                               // Note to reduce confusion:
-                               // - item is the filter whose state changed and should exclude the other filters
-                               //   in its list of exclusions
-                               // - filterItem is the filter that is potentially being excluded by the current item
-                               // - anotherExcludingFilter is any other filter that excludes filterItem; we must check
-                               //   if that filter is selected, because if it is, we should not touch the excluded item
-                               if (
-                                       // Check if there are any filters (other than the current one)
-                                       // that also exclude the filterName
-                                       !model.excludedByMap[ filterName ].some( function ( anotherExcludingFilterName ) {
-                                               var anotherExcludingFilter = model.getItemByName( anotherExcludingFilterName );
-
-                                               return (
-                                                       anotherExcludingFilterName !== item.getName() &&
-                                                       anotherExcludingFilter.isSelected()
-                                               );
-                                       } )
-                               ) {
-                                       // Only change the state for filters that aren't
-                                       // also affected by other excluding selected filters
-                                       filterItem.toggleActive( !item.isSelected() );
+
+                       // Update coverage for the changed group
+                       if ( groupModel.isFullCoverage() ) {
+                               allSelected = groupModel.areAllSelected();
+                               groupModel.getItems().forEach( function ( filterItem ) {
+                                       filterItem.toggleFullyCovered( allSelected );
+                               } );
+                       }
+               } );
+
+               // Check for conflicts
+               // In this case, we must go over all items, since
+               // conflicts are bidirectional and depend not only on
+               // individual items, but also on the selected states of
+               // the groups they're in.
+               this.getItems().forEach( function ( filterItem ) {
+                       var inConflict = false,
+                               filterItemGroup = filterItem.getGroupModel();
+
+                       // For each item, see if that item is still conflicting
+                       $.each( model.groups, function ( groupName, groupModel ) {
+                               if ( filterItem.getGroupName() === groupName ) {
+                                       // Check inside the group
+                                       inConflict = groupModel.areAnySelectedInConflictWith( filterItem );
+                               } else {
+                                       // According to the spec, if two items conflict from two different
+                                       // groups, the conflict only lasts if the groups **only have selected
+                                       // items that are conflicting**. If a group has selected items that
+                                       // are conflicting and non-conflicting, the scope of the result has
+                                       // expanded enough to completely remove the conflict.
+
+                                       // For example, see two groups with conflicts:
+                                       // userExpLevel: [
+                                       //   {
+                                       //      name: 'experienced',
+                                       //      conflicts: [ 'unregistered' ]
+                                       //   }
+                                       // ],
+                                       // registration: [
+                                       //   {
+                                       //      name: 'registered',
+                                       //   },
+                                       //   {
+                                       //      name: 'unregistered',
+                                       //   }
+                                       // ]
+                                       // If we select 'experienced', then 'unregistered' is in conflict (and vice versa),
+                                       // because, inherently, 'experienced' filter only includes registered users, and so
+                                       // both filters are in conflict with one another.
+                                       // However, the minute we select 'registered', the scope of our results
+                                       // has expanded to no longer have a conflict with 'experienced' filter, and
+                                       // so the conflict is removed.
+
+                                       // In our case, we need to check if the entire group conflicts with
+                                       // the entire item's group, so we follow the above spec
+                                       inConflict = (
+                                               // The foreign group is in conflict with this item
+                                               groupModel.areAllSelectedInConflictWith( filterItem ) &&
+                                               // Every selected member of the item's own group is also
+                                               // in conflict with the other group
+                                               filterItemGroup.getSelectedItems().every( function ( otherGroupItem ) {
+                                                       return groupModel.areAllSelectedInConflictWith( otherGroupItem );
+                                               } )
+                                       );
                                }
+
+                               // If we're in conflict, this will return 'false' which
+                               // will break the loop. Otherwise, we're not in conflict
+                               // and the loop continues
+                               return !inConflict;
                        } );
-               }
+
+                       // Toggle the item state
+                       filterItem.toggleConflicted( inConflict );
+               } );
        };
 
        /**
         * @param {Object} filters Filter group definition
         */
        mw.rcfilters.dm.FiltersViewModel.prototype.initializeFilters = function ( filters ) {
-               var i, filterItem, selectedFilterNames, excludedFilters,
+               var i, filterItem, selectedFilterNames,
                        model = this,
                        items = [],
-                       addToMap = function ( excludedFilters ) {
-                               excludedFilters.forEach( function ( filterName ) {
-                                       model.excludedByMap[ filterName ] = model.excludedByMap[ filterName ] || [];
-                                       model.excludedByMap[ filterName ].push( filterItem.getName() );
+                       addArrayElementsUnique = function ( arr, elements ) {
+                               elements = Array.isArray( elements ) ? elements : [ elements ];
+
+                               elements.forEach( function ( element ) {
+                                       if ( arr.indexOf( element ) === -1 ) {
+                                               arr.push( element );
+                                       }
                                } );
-                       };
+
+                               return arr;
+                       },
+                       conflictMap = {},
+                       supersetMap = {};
 
                // Reset
                this.clearItems();
                this.groups = {};
-               this.excludedByMap = {};
 
                $.each( filters, function ( group, data ) {
                        if ( !model.groups[ group ] ) {
-                               model.groups[ group ] = new mw.rcfilters.dm.FilterGroup( {
-                                       name: group,
+                               model.groups[ group ] = new mw.rcfilters.dm.FilterGroup( group, {
                                        type: data.type,
                                        title: data.title,
                                        separator: data.separator,
-                                       exclusionType: data.exclusionType
+                                       fullCoverage: !!data.fullCoverage
                                } );
                        }
 
                        selectedFilterNames = [];
                        for ( i = 0; i < data.filters.length; i++ ) {
-                               excludedFilters = data.filters[ i ].excludes || [];
-
-                               filterItem = new mw.rcfilters.dm.FilterItem( data.filters[ i ].name, {
+                               filterItem = new mw.rcfilters.dm.FilterItem( data.filters[ i ].name, model.groups[ group ], {
                                        group: group,
                                        label: data.filters[ i ].label,
                                        description: data.filters[ i ].description,
-                                       selected: data.filters[ i ].selected,
-                                       excludes: excludedFilters,
-                                       'default': data.filters[ i ].default
+                                       subset: data.filters[ i ].subset
                                } );
 
-                               // Map filters and what excludes them
-                               addToMap( excludedFilters );
+                               // For convenience, we should store each filter's "supersets" -- these are
+                               // the filters that have that item in their subset list. This will just
+                               // make it easier to go through whether the item has any other items
+                               // that affect it (and are selected) at any given time
+                               if ( data.filters[ i ].subset ) {
+                                       data.filters[ i ].subset.forEach( function ( subsetFilterName ) { // eslint-disable-line no-loop-func
+                                               supersetMap[ subsetFilterName ] = supersetMap[ subsetFilterName ] || [];
+                                               addArrayElementsUnique(
+                                                       supersetMap[ subsetFilterName ],
+                                                       filterItem.getName()
+                                               );
+                                       } );
+                               }
+
+                               // Conflicts are bi-directional, which means FilterA can define having
+                               // a conflict with FilterB, and this conflict should appear in **both**
+                               // filter definitions.
+                               // We need to remap all the 'conflicts' so they reflect the entire state
+                               // in either direction regardless of which filter defined the other as conflicting.
+                               if ( data.filters[ i ].conflicts ) {
+                                       conflictMap[ filterItem.getName() ] = conflictMap[ filterItem.getName() ] || [];
+                                       addArrayElementsUnique(
+                                               conflictMap[ filterItem.getName() ],
+                                               data.filters[ i ].conflicts
+                                       );
+
+                                       data.filters[ i ].conflicts.forEach( function ( conflictingFilterName ) { // eslint-disable-line no-loop-func
+                                               // Add this filter to the conflicts of each of the filters in its list
+                                               conflictMap[ conflictingFilterName ] = conflictMap[ conflictingFilterName ] || [];
+                                               addArrayElementsUnique(
+                                                       conflictMap[ conflictingFilterName ],
+                                                       filterItem.getName()
+                                               );
+                                       } );
+                               }
 
                                if ( data.type === 'send_unselected_if_any' ) {
                                        // Store the default parameter state
                        }
                } );
 
+               items.forEach( function ( filterItem ) {
+                       // Apply conflict map to the items
+                       // Now that we mapped all items and conflicts bi-directionally
+                       // we need to apply the definition to each filter again
+                       filterItem.setConflicts( conflictMap[ filterItem.getName() ] );
+
+                       // Apply the superset map
+                       filterItem.setSuperset( supersetMap[ filterItem.getName() ] );
+               } );
+
+               // Add items to the model
                this.addItems( items );
 
                this.emit( 'initialize' );
                return this.groups;
        };
 
-       /**
-        * Update the representation of the parameters. These are the back-end
-        * parameters representing the filters, but they represent the given
-        * current state regardless of validity.
-        *
-        * This should only run after filters are already set.
-        *
-        * @param {Object} params Parameter state
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.updateParameters = function ( params ) {
-               var model = this;
-
-               $.each( params, function ( name, value ) {
-                       // Only store the parameters that exist in the system
-                       if ( model.getItemByName( name ) ) {
-                               model.parameters[ name ] = value;
-                       }
-               } );
-       };
-
        /**
         * Get the value of a specific parameter
         *
                for ( i = 0; i < items.length; i++ ) {
                        result[ items[ i ].getName() ] = {
                                selected: items[ i ].isSelected(),
-                               active: items[ i ].isActive()
+                               conflicted: items[ i ].isConflicted(),
+                               included: items[ i ].isIncluded()
                        };
                }
 
                        filterItem = model.getItemByName( paramName );
                        // Ignore if no filter item exists
                        if ( filterItem ) {
-                               groupMap[ filterItem.getGroup() ] = groupMap[ filterItem.getGroup() ] || {};
+                               groupMap[ filterItem.getGroupName() ] = groupMap[ filterItem.getGroupName() ] || {};
 
                                // Mark the group if it has any items that are selected
-                               groupMap[ filterItem.getGroup() ].hasSelected = (
-                                       groupMap[ filterItem.getGroup() ].hasSelected ||
+                               groupMap[ filterItem.getGroupName() ].hasSelected = (
+                                       groupMap[ filterItem.getGroupName() ].hasSelected ||
                                        !!Number( paramValue )
                                );
 
                                // Add the relevant filter into the group map
-                               groupMap[ filterItem.getGroup() ].filters = groupMap[ filterItem.getGroup() ].filters || [];
-                               groupMap[ filterItem.getGroup() ].filters.push( filterItem );
+                               groupMap[ filterItem.getGroupName() ].filters = groupMap[ filterItem.getGroupName() ].filters || [];
+                               groupMap[ filterItem.getGroupName() ].filters.push( filterItem );
                        } else if ( model.groups.hasOwnProperty( paramName ) ) {
                                // This parameter represents a group (values are the filters)
                                // this is equivalent to checking if the group is 'string_options'
                // item label starting with the query string
                for ( i = 0; i < items.length; i++ ) {
                        if ( items[ i ].getLabel().toLowerCase().indexOf( query ) === 0 ) {
-                               result[ items[ i ].getGroup() ] = result[ items[ i ].getGroup() ] || [];
-                               result[ items[ i ].getGroup() ].push( items[ i ] );
+                               result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
+                               result[ items[ i ].getGroupName() ].push( items[ i ] );
                        }
                }
 
                if ( $.isEmptyObject( result ) ) {
                        // item containing the query string in their label, description, or group title
                        for ( i = 0; i < items.length; i++ ) {
-                               groupTitle = this.getGroup( items[ i ].getGroup() ).getTitle();
+                               groupTitle = items[ i ].getGroupModel().getTitle();
                                if (
                                        items[ i ].getLabel().toLowerCase().indexOf( query ) > -1 ||
                                        items[ i ].getDescription().toLowerCase().indexOf( query ) > -1 ||
                                        groupTitle.toLowerCase().indexOf( query ) > -1
                                ) {
-                                       result[ items[ i ].getGroup() ] = result[ items[ i ].getGroup() ] || [];
-                                       result[ items[ i ].getGroup() ].push( items[ i ] );
+                                       result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
+                                       result[ items[ i ].getGroupName() ].push( items[ i ] );
                                }
                        }
                }
index 88f32b4..ff34bb8 100644 (file)
 
        /**
         * Initialize the filter and parameter states
+        *
+        * @param {Object} filterStructure Filter definition and structure for the model
         */
-       mw.rcfilters.Controller.prototype.initialize = function () {
-               this.updateFromURL();
-       };
-
-       /**
-        * Update the model state based on the URL parameters.
-        */
-       mw.rcfilters.Controller.prototype.updateFromURL = function () {
+       mw.rcfilters.Controller.prototype.initialize = function ( filterStructure ) {
                var uri = new mw.Uri();
 
+               // Initialize the model
+               this.filtersModel.initializeFilters( filterStructure );
+
+               // Set filter states based on defaults and URL params
                this.filtersModel.updateFilters(
-                       // Translate the url params to filter select states
-                       this.filtersModel.getFiltersFromParameters( uri.query )
+                       this.filtersModel.getFiltersFromParameters(
+                               // Merge defaults with URL params for initialization
+                               $.extend(
+                                       true,
+                                       {},
+                                       this.filtersModel.getDefaultParams(),
+                                       // URI query overrides defaults
+                                       uri.query
+                               )
+                       )
                );
+
+               // Check all filter interactions
+               this.filtersModel.reassessFilterInteractions();
        };
 
        /**
                var obj = {};
 
                obj[ filterName ] = isSelected;
+
                this.filtersModel.updateFilters( obj );
                this.updateURL();
                this.updateChangesList();
+
+               // Check filter interactions
+               this.filtersModel.reassessFilterInteractions( this.filtersModel.getItemByName( filterName ) );
        };
 
        /**
index ef0489c..61df2e8 100644 (file)
                        new mw.rcfilters.ui.FormWrapperWidget(
                                changesListModel, $( '.rcoptions form' ) );
 
-                       filtersModel.initializeFilters( {
+                       controller.initialize( {
                                registration: {
                                        title: mw.msg( 'rcfilters-filtergroup-registration' ),
                                        type: 'send_unselected_if_any',
+                                       fullCoverage: true,
                                        filters: [
                                                {
                                                        name: 'hideliu',
                                        // ** In this case, the parameter name is the group name. **
                                        type: 'string_options',
                                        separator: ',',
+                                       fullCoverage: false,
                                        filters: [
                                                {
                                                        name: 'newcomer',
                                                        label: mw.msg( 'rcfilters-filter-userExpLevel-newcomer-label' ),
-                                                       description: mw.msg( 'rcfilters-filter-userExpLevel-newcomer-description' )
+                                                       description: mw.msg( 'rcfilters-filter-userExpLevel-newcomer-description' ),
+                                                       conflicts: [ 'hideanons' ]
                                                },
                                                {
                                                        name: 'learner',
                                                        label: mw.msg( 'rcfilters-filter-userExpLevel-learner-label' ),
-                                                       description: mw.msg( 'rcfilters-filter-userExpLevel-learner-description' )
+                                                       description: mw.msg( 'rcfilters-filter-userExpLevel-learner-description' ),
+                                                       conflicts: [ 'hideanons' ]
                                                },
                                                {
                                                        name: 'experienced',
                                                        label: mw.msg( 'rcfilters-filter-userExpLevel-experienced-label' ),
-                                                       description: mw.msg( 'rcfilters-filter-userExpLevel-experienced-description' )
+                                                       description: mw.msg( 'rcfilters-filter-userExpLevel-experienced-description' ),
+                                                       conflicts: [ 'hideanons' ]
                                                }
                                        ]
                                },
@@ -80,6 +85,7 @@
                                        // the functionality to the UI, whether we are dealing with 2
                                        // parameters in the group or more.
                                        type: 'send_unselected_if_any',
+                                       fullCoverage: true,
                                        filters: [
                                                {
                                                        name: 'hidemyself',
                                automated: {
                                        title: mw.msg( 'rcfilters-filtergroup-automated' ),
                                        type: 'send_unselected_if_any',
+                                       fullCoverage: true,
                                        filters: [
                                                {
                                                        name: 'hidebots',
                                significance: {
                                        title: mw.msg( 'rcfilters-filtergroup-significance' ),
                                        type: 'send_unselected_if_any',
+                                       fullCoverage: true,
                                        filters: [
                                                {
                                                        name: 'hideminor',
                                changetype: {
                                        title: mw.msg( 'rcfilters-filtergroup-changetype' ),
                                        type: 'send_unselected_if_any',
+                                       fullCoverage: true,
                                        filters: [
                                                {
                                                        name: 'hidepageedits',
                        $( '.rcoptions' ).before( filtersWidget.$element );
                        $( 'body' ).append( $overlay );
 
-                       // Initialize values
-                       controller.initialize();
-
                        // HACK: Remove old-style filter links for filters handled by the widget
                        // Ideally the widget would handle all filters and we'd just remove .rcshowhide entirely
                        $( '.rcshowhide' ).children().each( function () {
index 4ea88b5..8a9ad54 100644 (file)
@@ -7,4 +7,8 @@
                // Fix the positioning of the popup itself
                margin-top: 1em;
        }
+
+       &-muted {
+               opacity: 0.5;
+       }
 }
index c409d58..6c11cdb 100644 (file)
                color: #54595d;
        }
 
-       &-item-inactive {
-               opacity: 0.5;
-       }
-
        &-emptyFilters {
                color: #72777d;
        }
index 9f9e6fc..a874416 100644 (file)
@@ -20,7 +20,7 @@
                margin: 0 !important;
        }
 
-       &-inactive {
+       &-muted {
                opacity: 0.5;
        }
 }
index ca47f16..525f718 100644 (file)
@@ -57,6 +57,8 @@
                        .addClass( 'mw-rcfilters-ui-capsuleItemWidget' )
                        .on( 'mouseover', this.onHover.bind( this, true ) )
                        .on( 'mouseout', this.onHover.bind( this, false ) );
+
+               this.setCurrentMuteState();
        };
 
        OO.inheritClass( mw.rcfilters.ui.CapsuleItemWidget, OO.ui.CapsuleItemWidget );
         * Respond to model update event
         */
        mw.rcfilters.ui.CapsuleItemWidget.prototype.onModelUpdate = function () {
-               // Deal with active/inactive capsule filter items
+               this.setCurrentMuteState();
+       };
+
+       /**
+        * Set the current mute state for this item
+        */
+       mw.rcfilters.ui.CapsuleItemWidget.prototype.setCurrentMuteState = function () {
                this.$element
                        .toggleClass(
-                               'mw-rcfilters-ui-filterCapsuleMultiselectWidget-item-inactive',
-                               !this.model.isActive()
+                               'mw-rcfilters-ui-capsuleItemWidget-muted',
+                               this.model.isIncluded() ||
+                               this.model.isConflicted() ||
+                               this.model.isFullyCovered()
                        );
        };
 
index f9829d4..9bf26d1 100644 (file)
@@ -48,6 +48,7 @@
                // Event
                this.checkboxWidget.connect( this, { userChange: 'onCheckboxChange' } );
                this.model.connect( this, { update: 'onModelUpdate' } );
+               this.model.getGroupModel().connect( this, { update: 'onGroupModelUpdate' } );
 
                this.$element
                        .addClass( 'mw-rcfilters-ui-filterItemWidget' )
        mw.rcfilters.ui.FilterItemWidget.prototype.onModelUpdate = function () {
                this.checkboxWidget.setSelected( this.model.isSelected() );
 
+               this.setCurrentMuteState();
+       };
+
+       /**
+        * Respond to item group model update event
+        */
+       mw.rcfilters.ui.FilterItemWidget.prototype.onGroupModelUpdate = function () {
+               this.setCurrentMuteState();
+       };
+
+       /**
+        * Set the current mute state for this item
+        */
+       mw.rcfilters.ui.FilterItemWidget.prototype.setCurrentMuteState = function () {
                this.$element.toggleClass(
-                       'mw-rcfilters-ui-filterItemWidget-inactive',
-                       !this.model.isActive()
+                       'mw-rcfilters-ui-filterItemWidget-muted',
+                       this.model.isConflicted() ||
+                       this.model.isIncluded() ||
+                       this.model.isFullyCovered() ||
+                       (
+                               // Item is also muted when any of the items in its group is active
+                               this.model.getGroupModel().isActive() &&
+                               // But it isn't selected
+                               !this.model.isSelected()
+                       )
                );
        };
-
        /**
         * Get the name of this filter
         *
index ad58583..c989c83 100644 (file)
                                                                                booklet.setPage( '|results|' );
                                                                        } ).setDisabled( !paramsAreForced ) ).$element,
                                                                        new OO.ui.PopupButtonWidget( {
+                                                                               $overlay: $( '#mw-apisandbox-ui' ),
                                                                                framed: false,
                                                                                icon: 'info',
                                                                                popup: {
index 5abd2d4..f3d48e6 100644 (file)
                /**
                 * Massage parameters from the nice format we accept into a format suitable for the API.
                 *
+                * NOTE: A value of undefined/null in an array will be represented by Array#join()
+                * as the empty string. Should we filter silently? Warn? Leave as-is?
+                *
                 * @private
                 * @param {Object} parameters (modified in-place)
                 * @param {boolean} useUS Whether to use U+001F when joining multi-valued parameters.
index fceeb64..4df2df7 100644 (file)
@@ -8,6 +8,7 @@
  * @singleton
  */
 
+/* global mwNow */
 /* eslint-disable no-use-before-define */
 
 ( function ( $ ) {
                 *
                 * @return {number} Current time
                 */
-               now: ( function () {
-                       var perf = window.performance,
-                               navStart = perf && perf.timing && perf.timing.navigationStart;
-                       return navStart && typeof perf.now === 'function' ?
-                               function () { return navStart + perf.now(); } :
-                               function () { return +new Date(); };
-               }() ),
+               now: mwNow,
+               // mwNow is defined in startup.js
 
                /**
                 * Format a string. Replace $1, $2 ... $N with positional arguments.
                        return $.when.apply( $, all );
                } );
                loading.then( function () {
+                       /* global mwPerformance */
                        mwPerformance.mark( 'mwLoadEnd' );
                        mw.hook( 'resourceloader.loadEnd' ).fire();
                } );
index 20818d2..deb280a 100644 (file)
@@ -6,11 +6,19 @@
 
 /* global mw, $VARS, $CODE */
 
-// eslint-disable-next-line no-unused-vars
-var mediaWikiLoadStart = ( new Date() ).getTime(),
-       mwPerformance = ( window.performance && performance.mark ) ? performance : {
+var mwPerformance = ( window.performance && performance.mark ) ? performance : {
                mark: function () {}
-       };
+       },
+       // Define now() here to ensure valid comparison with mediaWikiLoadEnd (T153819).
+       mwNow = ( function () {
+               var perf = window.performance,
+                       navStart = perf && perf.timing && perf.timing.navigationStart;
+               return navStart && typeof perf.now === 'function' ?
+                       function () { return navStart + perf.now(); } :
+                       function () { return +new Date(); };
+       }() ),
+       // eslint-disable-next-line no-unused-vars
+       mediaWikiLoadStart = mwNow();
 
 mwPerformance.mark( 'mwLoadStart' );
 
index 1524b78..f54170b 100644 (file)
@@ -27,6 +27,7 @@
 
 use Wikimedia\Rdbms\TransactionProfiler;
 use Wikimedia\Rdbms\DatabaseDomain;
+use Wikimedia\Rdbms\MySQLMasterPos;
 
 /**
  * Fake class around abstract class so we can call concrete methods.
index 92c8add..4b857ce 100644 (file)
@@ -3,6 +3,7 @@
 use Wikimedia\Rdbms\LBFactorySimple;
 use Wikimedia\Rdbms\LBFactoryMulti;
 use Wikimedia\Rdbms\ChronologyProtector;
+use Wikimedia\Rdbms\MySQLMasterPos;
 
 /**
  * Holds tests for LBFactory abstract MediaWiki class.
index b843d17..e8260ac 100644 (file)
@@ -44,7 +44,9 @@ class SkinTemplateTest extends MediaWikiTestCase {
         * @return PHPUnit_Framework_MockObject_MockObject|OutputPage
         */
        private function getMockOutputPage( $isSyndicated, $html ) {
-               $mock = $this->getMock( OutputPage::class );
+               $mock = $this->getMockBuilder( OutputPage::class )
+                       ->disableOriginalConstructor()
+                       ->getMock();
                $mock->expects( $this->once() )
                        ->method( 'isSyndicated' )
                        ->will( $this->returnValue( $isSyndicated ) );
index 6a00ac9..3f5e8c3 100644 (file)
                return sequence( bodies );
        }
 
+       // Utility to make inline use with an assert easier
+       function match( text, pattern ) {
+               var m = text.match( pattern );
+               return m && m[ 1 ] || null;
+       }
+
        QUnit.test( 'get()', function ( assert ) {
                var api = new mw.Api();
 
                return api.post( { action: 'test' }, { contentType: 'multipart/form-data' } );
        } );
 
-       QUnit.test( 'Converting arrays to pipe-separated', function ( assert ) {
+       QUnit.test( 'Converting arrays to pipe-separated (string)', function ( assert ) {
                var api = new mw.Api();
 
                this.server.respond( function ( request ) {
-                       assert.ok( request.url.match( /test=foo%7Cbar%7Cbaz/ ), 'Pipe-separated value was submitted' );
+                       assert.equal( match( request.url, /test=([^&]+)/ ), 'foo%7Cbar%7Cbaz', 'Pipe-separated value was submitted' );
                        request.respond( 200, { 'Content-Type': 'application/json' }, '[]' );
                } );
 
                return api.get( { test: [ 'foo', 'bar', 'baz' ] } );
        } );
 
+       QUnit.test( 'Converting arrays to pipe-separated (mw.Title)', function ( assert ) {
+               var api = new mw.Api();
+
+               this.server.respond( function ( request ) {
+                       assert.equal( match( request.url, /test=([^&]+)/ ), 'Foo%7CBar', 'Pipe-separated value was submitted' );
+                       request.respond( 200, { 'Content-Type': 'application/json' }, '[]' );
+               } );
+
+               return api.get( { test: [ new mw.Title( 'Foo' ), new mw.Title( 'Bar' ) ] } );
+       } );
+
+       QUnit.test( 'Converting arrays to pipe-separated (misc primitives)', function ( assert ) {
+               var api = new mw.Api();
+
+               this.server.respond( function ( request ) {
+                       assert.equal( match( request.url, /test=([^&]+)/ ), 'true%7Cfalse%7C%7C%7C0%7C1%2E2', 'Pipe-separated value was submitted' );
+                       request.respond( 200, { 'Content-Type': 'application/json' }, '[]' );
+               } );
+
+               // undefined/null will become empty string
+               return api.get( { test: [ true, false, undefined, null, 0, 1.2 ] } );
+       } );
+
        QUnit.test( 'Omitting false booleans', function ( assert ) {
                var api = new mw.Api();
 
index 998817d..49a5b18 100644 (file)
                                group1: {
                                        title: 'Group 1',
                                        type: 'send_unselected_if_any',
-                                       exclusionType: 'default',
                                        filters: [
                                                {
                                                        name: 'hidefilter1',
                                        ]
                                }
                        },
+                       defaultFilterRepresentation = {
+                               // Group 1 and 2, "send_unselected_if_any", the values of the filters are "flipped" from the values of the parameters
+                               hidefilter1: false,
+                               hidefilter2: true,
+                               hidefilter3: false,
+                               hidefilter4: true,
+                               hidefilter5: false,
+                               hidefilter6: true
+                       },
                        model = new mw.rcfilters.dm.FiltersViewModel();
 
                model.initializeFilters( definition );
 
                assert.deepEqual(
-                       model.getFullState(),
+                       model.getSelectedState(),
                        {
-                               // Group 1
-                               hidefilter1: { selected: true, active: true },
-                               hidefilter2: { selected: false, active: true },
-                               hidefilter3: { selected: true, active: true },
-                               // Group 2
-                               hidefilter4: { selected: false, active: true },
-                               hidefilter5: { selected: true, active: true },
-                               hidefilter6: { selected: false, active: true },
+                               hidefilter1: false,
+                               hidefilter2: false,
+                               hidefilter3: false,
+                               hidefilter4: false,
+                               hidefilter5: false,
+                               hidefilter6: false
                        },
-                       'Initial state: all filters are active, and select states are default.'
+                       'Initial state: default filters are not selected (controller selects defaults explicitly).'
                );
 
-               // Default behavior for 'exclusion' type with only 1 item selected, means that:
-               // - The items in the same group that are *not* selected are *not* active
-               // - Items in other groups are unaffected (all active)
                model.updateFilters( {
                        hidefilter1: false,
-                       hidefilter2: false,
-                       hidefilter3: false,
-                       hidefilter4: false,
-                       hidefilter5: false,
-                       hidefilter6: true
+                       hidefilter3: false
                } );
-               assert.deepEqual(
-                       model.getFullState(),
-                       {
-                               // Group 1: not affected
-                               hidefilter1: { selected: false, active: true },
-                               hidefilter2: { selected: false, active: true },
-                               hidefilter3: { selected: false, active: true },
-                               // Group 2: affected
-                               hidefilter4: { selected: false, active: false },
-                               hidefilter5: { selected: false, active: false },
-                               hidefilter6: { selected: true, active: true },
-                       },
-                       'Default exclusion behavior with 1 item selected in the group.'
-               );
 
-               // Default behavior for 'exclusion' type with multiple items selected, but not all, means that:
-               // - The items in the same group that are *not* selected are *not* active
-               // - Items in other groups are unaffected (all active)
-               model.updateFilters( {
-                       // Literally updating filters to create a clean state
-                       hidefilter1: false,
-                       hidefilter2: false,
-                       hidefilter3: false,
-                       hidefilter4: false,
-                       hidefilter5: true,
-                       hidefilter6: true
-               } );
-               assert.deepEqual(
-                       model.getFullState(),
-                       {
-                               // Group 1: not affected
-                               hidefilter1: { selected: false, active: true },
-                               hidefilter2: { selected: false, active: true },
-                               hidefilter3: { selected: false, active: true },
-                               // Group 2: affected
-                               hidefilter4: { selected: false, active: false },
-                               hidefilter5: { selected: true, active: true },
-                               hidefilter6: { selected: true, active: true },
-                       },
-                       'Default exclusion behavior with multiple items (but not all) selected in the group.'
-               );
+               model.setFiltersToDefaults();
 
-               // Default behavior for 'exclusion' type with all items in the group selected, means that:
-               // - All items in the group are NOT active
-               // - Items in other groups are unaffected (all active)
-               model.updateFilters( {
-                       // Literally updating filters to create a clean state
-                       hidefilter1: false,
-                       hidefilter2: false,
-                       hidefilter3: false,
-                       hidefilter4: true,
-                       hidefilter5: true,
-                       hidefilter6: true
-               } );
                assert.deepEqual(
-                       model.getFullState(),
-                       {
-                               // Group 1: not affected
-                               hidefilter1: { selected: false, active: true },
-                               hidefilter2: { selected: false, active: true },
-                               hidefilter3: { selected: false, active: true },
-                               // Group 2: affected
-                               hidefilter4: { selected: true, active: false },
-                               hidefilter5: { selected: true, active: false },
-                               hidefilter6: { selected: true, active: false },
-                       },
-                       'Default exclusion behavior with all items in the group.'
+                       model.getSelectedState(),
+                       defaultFilterRepresentation,
+                       'Changing values of filters and then returning to defaults still results in default filters being selected.'
                );
        } );
 
-       QUnit.test( 'reapplyActiveFilters - "explicit" exclusion rules', function ( assert ) {
+       QUnit.test( 'Filter interaction: subsets', function ( assert ) {
                var definition = {
                                group1: {
                                        title: 'Group 1',
-                                       type: 'send_unselected_if_any',
-                                       exclusionType: 'explicit',
+                                       type: 'string_options',
                                        filters: [
                                                {
                                                        name: 'filter1',
-                                                       excludes: [ 'filter2', 'filter3' ],
                                                        label: 'Show filter 1',
-                                                       description: 'Description of Filter 1 in Group 1'
+                                                       description: 'Description of Filter 1 in Group 1',
+                                                       subset: [ 'filter2', 'filter5' ]
                                                },
                                                {
                                                        name: 'filter2',
-                                                       excludes: [ 'filter3' ],
                                                        label: 'Show filter 2',
                                                        description: 'Description of Filter 2 in Group 1'
                                                },
                                                {
                                                        name: 'filter3',
                                                        label: 'Show filter 3',
-                                                       excludes: [ 'filter1' ],
                                                        description: 'Description of Filter 3 in Group 1'
-                                               },
+                                               }
+                                       ]
+                               },
+                               group2: {
+                                       title: 'Group 2',
+                                       type: 'send_unselected_if_any',
+                                       filters: [
                                                {
                                                        name: 'filter4',
                                                        label: 'Show filter 4',
-                                                       description: 'Description of Filter 4 in Group 1'
+                                                       description: 'Description of Filter 1 in Group 2',
+                                                       subset: [ 'filter3', 'filter5' ]
+                                               },
+                                               {
+                                                       name: 'filter5',
+                                                       label: 'Show filter 5',
+                                                       description: 'Description of Filter 2 in Group 2'
+                                               },
+                                               {
+                                                       name: 'filter6',
+                                                       label: 'Show filter 6',
+                                                       description: 'Description of Filter 3 in Group 2'
                                                }
                                        ]
                                }
                        },
-                       defaultFilterRepresentation = {
-                               // Group 1 and 2, "send_unselected_if_any", the values of the filters are "flipped" from the values of the parameters
-                               hidefilter1: false,
-                               hidefilter2: true,
-                               hidefilter3: false,
-                               hidefilter4: true,
-                               hidefilter5: false,
-                               hidefilter6: true,
-                               // Group 3, "string_options", default values correspond to parameters and filters
-                               filter7: false,
-                               filter8: true,
-                               filter9: false
+                       baseFullState = {
+                               filter1: { selected: false, conflicted: false, included: false },
+                               filter2: { selected: false, conflicted: false, included: false },
+                               filter3: { selected: false, conflicted: false, included: false },
+                               filter4: { selected: false, conflicted: false, included: false },
+                               filter5: { selected: false, conflicted: false, included: false },
+                               filter6: { selected: false, conflicted: false, included: false }
                        },
                        model = new mw.rcfilters.dm.FiltersViewModel();
 
                model.initializeFilters( definition );
+               // Select a filter that has subset with another filter
+               model.updateFilters( {
+                       filter1: true
+               } );
 
+               model.reassessFilterInteractions( model.getItemByName( 'filter1' ) );
                assert.deepEqual(
                        model.getFullState(),
-                       {
-                               filter1: { selected: false, active: true },
-                               filter2: { selected: false, active: true },
-                               filter3: { selected: false, active: true },
-                               filter4: { selected: false, active: true }
-                       },
-                       'Initial state: all filters are active.'
+                       $.extend( true, {}, baseFullState, {
+                               filter1: { selected: true },
+                               filter2: { included: true },
+                               filter5: { included: true }
+                       } ),
+                       'Filters with subsets are represented in the model.'
                );
 
-               // "Explicit" behavior for 'exclusion' with one item checked:
-               // - Items in the 'excluded' list of the selected filter are inactive
+               // Select another filter that has a subset with the same previous filter
                model.updateFilters( {
-                       // Literally updating filters to create a clean state
-                       filter1: true, // Excludes 'hidefilter2', 'hidefilter3'
-                       filter2: false, // Excludes 'hidefilter3'
-                       filter3: false, // Excludes 'hidefilter1'
-                       filter4: false // No exclusion list
+                       filter4: true
                } );
+               model.reassessFilterInteractions( model.getItemByName( 'filter4' ) );
                assert.deepEqual(
                        model.getFullState(),
-                       {
-                               filter1: { selected: true, active: true },
-                               filter2: { selected: false, active: false },
-                               filter3: { selected: false, active: false },
-                               filter4: { selected: false, active: true }
-                       },
-                       '"Explicit" exclusion behavior with one item selected that has an exclusion list.'
+                       $.extend( true, {}, baseFullState, {
+                               filter1: { selected: true },
+                               filter2: { included: true },
+                               filter3: { included: true },
+                               filter4: { selected: true },
+                               filter5: { included: true }
+                       } ),
+                       'Filters that have multiple subsets are represented.'
                );
 
-               // "Explicit" behavior for 'exclusion' with two item checked:
-               // - Items in the 'excluded' list of each of the selected filter are inactive
+               // Remove one filter (but leave the other) that affects filter2
                model.updateFilters( {
-                       // Literally updating filters to create a clean state
-                       filter1: true, // Excludes 'hidefilter2', 'hidefilter3'
-                       filter2: false, // Excludes 'hidefilter3'
-                       filter3: true, // Excludes 'hidefilter1'
-                       filter4: false // No exclusion list
+                       filter1: false
                } );
+               model.reassessFilterInteractions( model.getItemByName( 'filter1' ) );
                assert.deepEqual(
                        model.getFullState(),
-                       {
-                               filter1: { selected: true, active: false },
-                               filter2: { selected: false, active: false },
-                               filter3: { selected: true, active: false },
-                               filter4: { selected: false, active: true }
+                       $.extend( true, {}, baseFullState, {
+                               filter2: { included: false },
+                               filter3: { included: true },
+                               filter4: { selected: true },
+                               filter5: { included: true }
+                       } ),
+                       'Removing a filter only un-includes its subset if there is no other filter affecting.'
+               );
+
+               model.updateFilters( {
+                       filter4: false
+               } );
+               model.reassessFilterInteractions( model.getItemByName( 'filter4' ) );
+               assert.deepEqual(
+                       model.getFullState(),
+                       baseFullState,
+                       'Removing all supersets also un-includes the subsets.'
+               );
+       } );
+
+       QUnit.test( 'Filter interaction: full coverage', function ( assert ) {
+               var definition = {
+                               group1: {
+                                       title: 'Group 1',
+                                       type: 'string_options',
+                                       fullCoverage: false,
+                                       filters: [
+                                               { name: 'filter1' },
+                                               { name: 'filter2' },
+                                               { name: 'filter3' },
+                                       ]
+                               },
+                               group2: {
+                                       title: 'Group 2',
+                                       type: 'send_unselected_if_any',
+                                       fullCoverage: true,
+                                       filters: [
+                                               { name: 'filter4' },
+                                               { name: 'filter5' },
+                                               { name: 'filter6' },
+                                       ]
+                               }
                        },
-                       '"Explicit" exclusion behavior with two selected items that both have an exclusion list.'
+                       isCapsuleItemMuted = function ( filterName ) {
+                               var itemModel = model.getItemByName( filterName ),
+                                       groupModel = itemModel.getGroupModel();
+
+                               // This is the logic inside the capsule widget
+                               return (
+                                       // The capsule item widget only appears if the item is selected
+                                       itemModel.isSelected() &&
+                                       // Muted state is only valid if group is full coverage and all items are selected
+                                       groupModel.isFullCoverage() && groupModel.areAllSelected()
+                               );
+                       },
+                       getCurrentItemsMutedState = function () {
+                               return {
+                                       filter1: isCapsuleItemMuted( 'filter1' ),
+                                       filter2: isCapsuleItemMuted( 'filter2' ),
+                                       filter3: isCapsuleItemMuted( 'filter3' ),
+                                       filter4: isCapsuleItemMuted( 'filter4' ),
+                                       filter5: isCapsuleItemMuted( 'filter5' ),
+                                       filter6: isCapsuleItemMuted( 'filter6' )
+                               };
+                       },
+                       baseMuteState = {
+                               filter1: false,
+                               filter2: false,
+                               filter3: false,
+                               filter4: false,
+                               filter5: false,
+                               filter6: false
+                       },
+                       model = new mw.rcfilters.dm.FiltersViewModel();
+
+               model.initializeFilters( definition );
+
+               // Starting state, no selection, all items are non-muted
+               assert.deepEqual(
+                       getCurrentItemsMutedState(),
+                       baseMuteState,
+                       'No selection - all items are non-muted'
+               );
+
+               // Select most (but not all) items in each group
+               model.updateFilters( {
+                       filter1: true,
+                       filter2: true,
+                       filter4: true,
+                       filter5: true
+               } );
+
+               // Both groups have multiple (but not all) items selected, all items are non-muted
+               assert.deepEqual(
+                       getCurrentItemsMutedState(),
+                       baseMuteState,
+                       'Not all items in the group selected - all items are non-muted'
                );
 
-               // "Explicit behavior" with two filters that exclude the same item
+               // Select all items in 'fullCoverage' group (group2)
+               model.updateFilters( {
+                       filter6: true
+               } );
+
+               // Group2 (full coverage) has all items selected, all its items are muted
+               assert.deepEqual(
+                       getCurrentItemsMutedState(),
+                       $.extend( {}, baseMuteState, {
+                               filter4: true,
+                               filter5: true,
+                               filter6: true
+                       } ),
+                       'All items in \'full coverage\' group are selected - all items in the group are muted'
+               );
 
-               // Two filters selected, both exclude 'hidefilter3'
+               // Select all items in non 'fullCoverage' group (group1)
                model.updateFilters( {
-                       // Literally updating filters to create a clean state
-                       filter1: true, // Excludes 'hidefilter2', 'hidefilter3'
-                       filter2: true, // Excludes 'hidefilter3'
-                       filter3: false, // Excludes 'hidefilter1'
-                       filter4: false // No exclusion list
+                       filter3: true
                } );
+
+               // Group1 (full coverage) has all items selected, no items in it are muted (non full coverage)
                assert.deepEqual(
-                       model.getFullState(),
-                       {
-                               filter1: { selected: true, active: true },
-                               filter2: { selected: true, active: false }, // Excluded by filter1
-                               filter3: { selected: false, active: false }, // Excluded by both filter1 and filter2
-                               filter4: { selected: false, active: true }
+                       getCurrentItemsMutedState(),
+                       $.extend( {}, baseMuteState, {
+                               filter4: true,
+                               filter5: true,
+                               filter6: true
+                       } ),
+                       'All items in a non \'full coverage\' group are selected - none of the items in the group are muted'
+               );
+
+               // Uncheck an item from each group
+               model.updateFilters( {
+                       filter3: false,
+                       filter5: false
+               } );
+               assert.deepEqual(
+                       getCurrentItemsMutedState(),
+                       baseMuteState,
+                       'Not all items in the group are checked - all items are non-muted regardless of group coverage'
+               );
+       } );
+
+       QUnit.test( 'Filter interaction: conflicts', function ( assert ) {
+               var definition = {
+                               group1: {
+                                       title: 'Group 1',
+                                       type: 'string_options',
+                                       filters: [
+                                               {
+                                                       name: 'filter1',
+                                                       conflicts: [ 'filter2', 'filter4' ]
+                                               },
+                                               {
+                                                       name: 'filter2',
+                                                       conflicts: [ 'filter6' ]
+                                               },
+                                               {
+                                                       name: 'filter3'
+                                               }
+                                       ]
+                               },
+                               group2: {
+                                       title: 'Group 2',
+                                       type: 'send_unselected_if_any',
+                                       filters: [
+                                               {
+                                                       name: 'filter4'
+                                               },
+                                               {
+                                                       name: 'filter5',
+                                                       conflicts: [ 'filter3' ]
+                                               },
+                                               {
+                                                       name: 'filter6',
+                                               }
+                                       ]
+                               }
                        },
-                       '"Explicit" exclusion behavior with two selected items that both exclude another item.'
+                       baseFullState = {
+                               filter1: { selected: false, conflicted: false, included: false },
+                               filter2: { selected: false, conflicted: false, included: false },
+                               filter3: { selected: false, conflicted: false, included: false },
+                               filter4: { selected: false, conflicted: false, included: false },
+                               filter5: { selected: false, conflicted: false, included: false },
+                               filter6: { selected: false, conflicted: false, included: false }
+                       },
+                       model = new mw.rcfilters.dm.FiltersViewModel();
+
+               model.initializeFilters( definition );
+
+               assert.deepEqual(
+                       model.getFullState(),
+                       baseFullState,
+                       'Initial state: no conflicts because no selections.'
                );
 
-               // Unselect filter2: filter3 should still be excluded, because filter1 excludes it and is selected
+               // Select a filter that has a conflict with another
                model.updateFilters( {
-                       filter2: false, // Excludes 'hidefilter3'
+                       filter1: true // conflicts: filter2, filter4
                } );
+
+               model.reassessFilterInteractions( model.getItemByName( 'filter1' ) );
+
                assert.deepEqual(
                        model.getFullState(),
-                       {
-                               filter1: { selected: true, active: true },
-                               filter2: { selected: false, active: false }, // Excluded by filter1
-                               filter3: { selected: false, active: false }, // Still excluded by filter1
-                               filter4: { selected: false, active: true }
-                       },
-                       '"Explicit" exclusion behavior unselecting one item that excludes another item, that is being excluded by a third active item.'
+                       $.extend( true, {}, baseFullState, {
+                               filter1: { selected: true },
+                               filter2: { conflicted: true },
+                               filter4: { conflicted: true },
+                       } ),
+                       'Selecting a filter set its conflicts list as "conflicted".'
                );
 
-               // Unselect filter1: filter3 should now be active, since both filters that exclude it are unselected
+               // Select one of the conflicts (both filters are now conflicted and selected)
                model.updateFilters( {
-                       filter1: false, // Excludes 'hidefilter3' and 'hidefilter2'
+                       filter4: true // conflicts: filter 1
                } );
+               model.reassessFilterInteractions( model.getItemByName( 'filter4' ) );
+
                assert.deepEqual(
                        model.getFullState(),
-                       {
-                               filter1: { selected: false, active: true },
-                               filter2: { selected: false, active: true }, // No longer excluded by filter1
-                               filter3: { selected: false, active: true }, // No longer excluded by either filter1 nor filter2
-                               filter4: { selected: false, active: true }
-                       },
-                       '"Explicit" exclusion behavior unselecting both items that excluded the same third item.'
+                       $.extend( true, {}, baseFullState, {
+                               filter1: { selected: true, conflicted: true },
+                               filter2: { conflicted: true },
+                               filter4: { selected: true, conflicted: true },
+                       } ),
+                       'Selecting a conflicting filter sets both sides to conflicted and selected.'
                );
 
+               // Select another filter from filter4 group, meaning:
+               // now filter1 no longer conflicts with filter4
+               model.updateFilters( {
+                       filter6: true // conflicts: filter2
+               } );
+               model.reassessFilterInteractions( model.getItemByName( 'filter6' ) );
+
+               assert.deepEqual(
+                       model.getFullState(),
+                       $.extend( true, {}, baseFullState, {
+                               filter1: { selected: true, conflicted: false }, // No longer conflicts (filter4 is not the only in the group)
+                               filter2: { conflicted: true }, // While not selected, still in conflict with filter1, which is selected
+                               filter4: { selected: true, conflicted: false }, // No longer conflicts with filter1
+                               filter6: { selected: true, conflicted: false }
+                       } ),
+                       'Selecting a non-conflicting filter from a conflicting group removes the conflict'
+               );
        } );
 }( mediaWiki, jQuery ) );
index bac8274..65b7263 100644 (file)
                );
        } );
 
+       QUnit.test( 'mw.now', function ( assert ) {
+               assert.equal( typeof mw.now(), 'number', 'Return a number' );
+               assert.equal(
+                       String( Math.round( mw.now() ) ).length,
+                       String( +new Date() ).length,
+                       'Match size of current timestamp'
+               );
+       } );
+
        QUnit.test( 'mw.Map', function ( assert ) {
                var arry, conf, funky, globalConf, nummy, someValues;