RCFilters UI: Define interaction states for filters
authorMoriel Schottlender <moriel@gmail.com>
Thu, 2 Feb 2017 20:13:00 +0000 (12:13 -0800)
committerRoan Kattouw <roan.kattouw@gmail.com>
Fri, 10 Feb 2017 12:49:09 +0000 (12:49 +0000)
This patch sets up the ground for all three interaction types:
'subset', 'conflict' and 'coverage' as toggle-able properties
of the item and group models, and sets up the widgets' initial
logic abou their own "mute" state.

The patch includes the basic logic for two interactions:
- Subsets (and 'supersets' that are derived from them)
- Coverage

Direct conflict states will be defined in an upcoming commit.

Bug: T156864
Bug: T156861
Bug: T156860
Change-Id: If20bbe9f1442cfcfce046e56f6150b38dd3a4efc

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
tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js

index da9e59e..6d9611e 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();
+               } );
+       };
+
        /**
         * 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..acf53c1 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.hasConflictWith = 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..14306f1 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
-                               );
-                       } );
-               } 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()
-                                               );
+                               itemInSubset.toggleIncluded(
+                                       // If any of itemInSubset's supersets are selected, this item
+                                       // is included
+                                       itemInSubset.getSuperset().some( function ( supersetName ) {
+                                               return ( model.getItemByName( supersetName ).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 );
+                               } );
+                       }
+               } );
        };
 
        /**
         * @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 998817d..884be8c 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 }
-                       },
-                       '"Explicit" exclusion behavior with two selected items that both have an exclusion list.'
+                       $.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.'
                );
 
-               // "Explicit behavior" with two filters that exclude the same item
-
-               // Two filters selected, both exclude 'hidefilter3'
                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
+                       filter4: false
                } );
+               model.reassessFilterInteractions( model.getItemByName( 'filter4' ) );
                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 }
+                       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 exclude another item.'
+                       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'
                );
 
-               // Unselect filter2: filter3 should still be excluded, because filter1 excludes it and is selected
+               // Select most (but not all) items in each group
                model.updateFilters( {
-                       filter2: false, // Excludes 'hidefilter3'
+                       filter1: true,
+                       filter2: true,
+                       filter4: true,
+                       filter5: true
                } );
+
+               // Both groups have multiple (but not all) items selected, all items are non-muted
                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.'
+                       getCurrentItemsMutedState(),
+                       baseMuteState,
+                       'Not all items in the group selected - all items are non-muted'
                );
 
-               // Unselect filter1: filter3 should now be active, since both filters that exclude it are unselected
+               // Select all items in 'fullCoverage' group (group2)
                model.updateFilters( {
-                       filter1: false, // Excludes 'hidefilter3' and 'hidefilter2'
+                       filter6: true
                } );
+
+               // Group2 (full coverage) has all items selected, all its items are muted
                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.'
+                       getCurrentItemsMutedState(),
+                       $.extend( {}, baseMuteState, {
+                               filter4: true,
+                               filter5: true,
+                               filter6: true
+                       } ),
+                       'All items in \'full coverage\' group are selected - all items in the group are muted'
+               );
+
+               // Select all items in non 'fullCoverage' group (group1)
+               model.updateFilters( {
+                       filter3: true
+               } );
+
+               // Group1 (full coverage) has all items selected, no items in it are muted (non full coverage)
+               assert.deepEqual(
+                       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'
+               );
        } );
 }( mediaWiki, jQuery ) );