Merge "Support uploads with UTF-8 names on Windows"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Tue, 3 Oct 2017 23:28:38 +0000 (23:28 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Tue, 3 Oct 2017 23:28:38 +0000 (23:28 +0000)
20 files changed:
SECURITY [new file with mode: 0644]
includes/Message.php
includes/OutputPage.php
includes/Preferences.php
includes/Setup.php
includes/Status.php
includes/api/ApiBase.php
includes/cache/MessageCache.php
includes/parser/CoreParserFunctions.php
includes/parser/ParserOptions.php
resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js
resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js
resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.SavedQueriesModel.js
resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js
resources/src/mediawiki.rcfilters/mw.rcfilters.init.js
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterItemHighlightButton.js
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.HighlightColorPickerWidget.js
tests/qunit/QUnitTestResources.php
tests/qunit/suites/resources/mediawiki.rcfilters/dm.SavedQueriesModel.test.js [new file with mode: 0644]
tests/qunit/suites/resources/mediawiki.rcfilters/dm.SavedQueryItemModel.test.js [new file with mode: 0644]

diff --git a/SECURITY b/SECURITY
new file mode 100644 (file)
index 0000000..5c6a288
--- /dev/null
+++ b/SECURITY
@@ -0,0 +1,3 @@
+MediaWiki takes security very seriously. If you believe you have found a
+security issue, see <https://www.mediawiki.org/wiki/Reporting_security_bugs>
+for information on how to responsibly report it.
index 0240fa7..d119940 100644 (file)
@@ -732,8 +732,6 @@ class Message implements MessageSpecifier, Serializable {
                        if ( !$this->language instanceof Language || $this->language->getCode() != $lang ) {
                                $this->language = Language::factory( $lang );
                        }
-               } elseif ( $lang instanceof StubUserLang ) {
-                       $this->language = false;
                } else {
                        $type = gettype( $lang );
                        throw new MWException( __METHOD__ . " must be "
index 5d9006a..785641d 100644 (file)
@@ -1596,7 +1596,7 @@ class OutputPage extends ContextSource {
 
                if ( !$this->mParserOptions ) {
                        if ( !$this->getContext()->getUser()->isSafeToLoad() ) {
-                               // $wgUser isn't unstubbable yet, so don't try to get a
+                               // $wgUser isn't loaded yet, so don't try to get a
                                // ParserOptions for it. And don't cache this ParserOptions
                                // either.
                                $po = ParserOptions::newFromAnon();
index 55ac81e..96b002b 100644 (file)
@@ -934,6 +934,12 @@ class Preferences {
                $defaultPreferences['rcfilters-wl-saved-queries'] = [
                        'type' => 'api',
                ];
+               $defaultPreferences['rcfilters-saved-queries-versionbackup'] = [
+                       'type' => 'api',
+               ];
+               $defaultPreferences['rcfilters-wl-saved-queries-versionbackup'] = [
+                       'type' => 'api',
+               ];
                $defaultPreferences['rcfilters-rclimit'] = [
                        'type' => 'api',
                ];
index 68e3d96..0be5c6e 100644 (file)
@@ -811,7 +811,7 @@ $wgUser = RequestContext::getMain()->getUser(); // BackCompat
 /**
  * @var Language $wgLang
  */
-$wgLang = new StubUserLang;
+$wgLang = RequestContext::getMain()->getLanguage(); // BackCompat
 
 /**
  * @var OutputPage $wgOut
index a35af6e..5456ed0 100644 (file)
@@ -153,12 +153,9 @@ class Status extends StatusValue {
         * @return Language
         */
        protected function languageFromParam( $lang ) {
-               global $wgLang;
-
                if ( $lang === null ) {
-                       // @todo: Use RequestContext::getMain()->getLanguage() instead
-                       return $wgLang;
-               } elseif ( $lang instanceof Language || $lang instanceof StubUserLang ) {
+                       return RequestContext::getMain()->getLanguage();
+               } elseif ( $lang instanceof Language ) {
                        return $lang;
                } else {
                        return Language::factory( $lang );
index 80aeff5..9dd670a 100644 (file)
@@ -1385,7 +1385,7 @@ abstract class ApiBase extends ContextSource {
                $limit2 = $limit2 ?: self::LIMIT_SML2;
 
                // This is a bit awkward, but we want to avoid calling canApiHighLimits()
-               // because it unstubs $wgUser
+               // because it loads the user object
                $valuesList = $this->explodeMultiValue( $value, $limit2 + 1 );
                $sizeLimit = count( $valuesList ) > $limit1 && $this->mMainModule->canApiHighLimits()
                        ? $limit2
index 768f980..20cf64c 100644 (file)
@@ -187,7 +187,7 @@ class MessageCache {
 
                if ( !$this->mParserOptions ) {
                        if ( !$wgUser->isSafeToLoad() ) {
-                               // $wgUser isn't unstubbable yet, so don't try to get a
+                               // $wgUser isn't loaded yet, so don't try to get a
                                // ParserOptions for it. And don't cache this ParserOptions
                                // either.
                                $po = ParserOptions::newFromAnon();
@@ -874,7 +874,7 @@ class MessageCache {
         * the site language.
         *
         * @see MessageCache::get
-        * @param Language|StubObject $lang Preferred language
+        * @param Language $lang Preferred language
         * @param string $lckey Lowercase key for the message (as for localisation cache)
         * @param bool $useDB Whether to include messages from the wiki database
         * @return string|bool The message, or false if not found
@@ -899,7 +899,7 @@ class MessageCache {
         * Given a language, try and fetch messages from that language and its fallbacks.
         *
         * @see MessageCache::get
-        * @param Language|StubObject $lang Preferred language
+        * @param Language $lang Preferred language
         * @param string $lckey Lowercase key for the message (as for localisation cache)
         * @param bool $useDB Whether to include messages from the wiki database
         * @param bool[] $alreadyTried Contains true for each language that has been tried already
index 3d26262..bebf3f8 100644 (file)
@@ -493,7 +493,7 @@ class CoreParserFunctions {
         *
         * @param int|float $num
         * @param string $raw
-        * @param Language|StubUserLang $language
+        * @param Language $language
         * @return string
         */
        public static function formatRaw( $num, $raw, $language ) {
index c7146a1..ee0da2a 100644 (file)
@@ -944,9 +944,6 @@ class ParserOptions {
                }
                if ( $lang === null ) {
                        global $wgLang;
-                       if ( !StubObject::isRealObject( $wgLang ) ) {
-                               $wgLang->_unstub();
-                       }
                        $lang = $wgLang;
                }
                $this->initialiseFromUser( $user, $lang );
@@ -1014,7 +1011,7 @@ class ParserOptions {
         *
         * @since 1.30
         * @param User|null $user
-        * @param Language|StubObject|null $lang
+        * @param Language|null $lang
         * @return ParserOptions
         */
        public static function newCanonical( User $user = null, $lang = null ) {
index 4dc86f6..309978f 100644 (file)
                return this.type;
        };
 
+       /**
+        * Check whether this group is represented by a single parameter
+        * or whether each item is its own parameter
+        *
+        * @return {boolean} This group is a single parameter
+        */
+       mw.rcfilters.dm.FilterGroup.prototype.isPerGroupRequestParameter = function () {
+               return (
+                       this.getType() === 'string_options' ||
+                       this.getType() === 'single_option'
+               );
+       };
+
        /**
         * Get display group
         *
index edb3d0f..5013c08 100644 (file)
                return result;
        };
 
+       /**
+        * Get the parameter names that represent filters that are excluded
+        * from saved queries.
+        *
+        * @return {string[]} Parameter names
+        */
+       mw.rcfilters.dm.FiltersViewModel.prototype.getExcludedParams = function () {
+               var result = [];
+
+               $.each( this.groups, function ( name, model ) {
+                       if ( model.isExcludedFromSavedQueries() ) {
+                               if ( model.isPerGroupRequestParameter() ) {
+                                       result.push( name );
+                               } else {
+                                       // Each filter is its own param
+                                       result = result.concat( model.getItems().map( function ( filterItem ) {
+                                               return filterItem.getParamName();
+                                       } ) );
+                               }
+                       }
+               } );
+
+               return result;
+       };
+
        /**
         * Analyze the groups and their filters and output an object representing
         * the state of the parameters they represent.
index f878941..2b17897 100644 (file)
@@ -7,10 +7,11 @@
         * @mixins OO.EmitterList
         *
         * @constructor
+        * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters model
         * @param {Object} [config] Configuration options
         * @cfg {string} [default] Default query ID
         */
-       mw.rcfilters.dm.SavedQueriesModel = function MwRcfiltersDmSavedQueriesModel( config ) {
+       mw.rcfilters.dm.SavedQueriesModel = function MwRcfiltersDmSavedQueriesModel( filtersModel, config ) {
                config = config || {};
 
                // Mixin constructor
@@ -18,7 +19,8 @@
                OO.EmitterList.call( this );
 
                this.default = config.default;
-               this.baseState = {};
+               this.filtersModel = filtersModel;
+               this.converted = false;
 
                // Events
                this.aggregate( { update: 'itemUpdate' } );
@@ -58,6 +60,9 @@
         * Initialize the saved queries model by reading it from the user's settings.
         * The structure of the saved queries is:
         * {
+        *    version: (string) Version number; if version 2, the query represents
+        *             parameters. Otherwise, the older version represented filters
+        *             and needs to be readjusted,
         *    default: (string) Query ID
         *    queries:{
         *       query_id_1: {
         *
         * @param {Object} [savedQueries] An object with the saved queries with
         *  the above structure.
-        * @param {Object} [baseState] An object representing the base state
-        *  so we can normalize the data
-        * @param {string[]} [ignoreFilters] Filters to ignore and remove from
-        *  the data
         * @fires initialize
         */
-       mw.rcfilters.dm.SavedQueriesModel.prototype.initialize = function ( savedQueries, baseState, ignoreFilters ) {
-               var items = [],
-                       defaultItem = null;
+       mw.rcfilters.dm.SavedQueriesModel.prototype.initialize = function ( savedQueries ) {
+               var model = this,
+                       excludedParams = this.filtersModel.getExcludedParams();
 
                savedQueries = savedQueries || {};
-               ignoreFilters = ignoreFilters || {};
-
-               this.baseState = baseState;
 
                this.clearItems();
+               this.default = null;
+               this.converted = false;
+
+               if ( savedQueries.version !== '2' ) {
+                       // Old version dealt with filter names. We need to migrate to the new structure
+                       // The new structure:
+                       // {
+                       //   version: (string) '2',
+                       //   default: (string) Query ID,
+                       //   queries: {
+                       //     query_id: {
+                       //       label: (string) Name of the query
+                       //       data: {
+                       //         params: (object) Representing all the parameter states
+                       //         highlights: (object) Representing all the filter highlight states
+                       //     }
+                       //   }
+                       // }
+                       $.each( savedQueries.queries || {}, function ( id, obj ) {
+                               if ( obj.data && obj.data.filters ) {
+                                       obj.data = model.convertToParameters( obj.data );
+                               }
+                       } );
+
+                       this.converted = true;
+                       savedQueries.version = '2';
+               }
+
+               // Initialize the query items
                $.each( savedQueries.queries || {}, function ( id, obj ) {
-                       var item,
-                               normalizedData = $.extend( true, {}, baseState, obj.data ),
+                       var normalizedData = obj.data,
                                isDefault = String( savedQueries.default ) === String( id );
 
-                       // Backwards-compat fix: We stored the 'highlight' state with
-                       // "1" and "0" instead of true/false; for already-stored states,
-                       // we need to fix that.
-                       // NOTE: Since this feature is only available in beta, we should
-                       // not need this line when we release this to the general wikis.
-                       // This method will automatically fix all saved queries anyways
-                       // for existing users, who are only betalabs users at the moment.
-                       normalizedData.highlights.highlight = !!Number( normalizedData.highlights.highlight );
-
-                       // Backwards-compat fix: Remove sticky parameters from the 'ignoreFilters' list
-                       ignoreFilters.forEach( function ( name ) {
-                               delete normalizedData.filters[ name ];
-                       } );
+                       if ( normalizedData && normalizedData.params ) {
+                               // Backwards-compat fix: Remove excluded parameters from
+                               // the given data, if they exist
+                               excludedParams.forEach( function ( name ) {
+                                       delete normalizedData.params[ name ];
+                               } );
 
-                       item = new mw.rcfilters.dm.SavedQueryItemModel(
-                               id,
-                               obj.label,
-                               normalizedData,
-                               { 'default': isDefault }
-                       );
+                               id = String( id );
+                               model.addNewQuery( obj.label, normalizedData, isDefault, id );
 
-                       if ( isDefault ) {
-                               defaultItem = item;
+                               if ( isDefault ) {
+                                       model.default = id;
+                               }
                        }
+               } );
 
-                       items.push( item );
+               this.emit( 'initialize' );
+       };
+
+       /**
+        * Convert from representation of filters to representation of parameters
+        *
+        * @param {Object} data Query data
+        * @return {Object} New converted query data
+        */
+       mw.rcfilters.dm.SavedQueriesModel.prototype.convertToParameters = function ( data ) {
+               var newData = {},
+                       defaultFilters = this.filtersModel.getFiltersFromParameters( this.filtersModel.getDefaultParams() ),
+                       fullFilterRepresentation = $.extend( true, {}, defaultFilters, data.filters ),
+                       highlightEnabled = data.highlights.highlight;
+
+               delete data.highlights.highlight;
+
+               // Filters
+               newData.params = this.filtersModel.getParametersFromFilters( fullFilterRepresentation );
+
+               // Highlights (taking out 'highlight' itself, appending _color to keys)
+               newData.highlights = {};
+               Object.keys( data.highlights ).forEach( function ( highlightedFilterName ) {
+                       newData.highlights[ highlightedFilterName + '_color' ] = data.highlights[ highlightedFilterName ];
                } );
 
-               if ( defaultItem ) {
-                       this.default = defaultItem.getID();
+               // Add highlight and invert toggles to params
+               newData.params.highlight = String( Number( highlightEnabled || 0 ) );
+               newData.params.invert = String( Number( data.invert || 0 ) );
+
+               return newData;
+       };
+
+       /**
+        * Get an object representing the base state of parameters
+        * and highlights.
+        *
+        * This is meant to make sure that the saved queries that are
+        * in memory are always the same structure as what we would get
+        * by calling the current model's "getSelectedState" and by checking
+        * highlight items.
+        *
+        * In cases where a user saved a query when the system had a certain
+        * set of params, and then a filter was added to the system, we want
+        * to make sure that the stored queries can still be comparable to
+        * the current state, which means that we need the base state for
+        * two operations:
+        *
+        * - Saved queries are stored in "minimal" view (only changed params
+        *   are stored); When we initialize the system, we merge each minimal
+        *   query with the base state (using 'getMinimalParamList') so all
+        *   saved queries have the exact same structure as what we would get
+        *   by checking the getSelectedState of the filter.
+        * - When we save the queries, we minimize the object to only represent
+        *   whatever has actually changed, rather than store the entire
+        *   object. To check what actually is different so we can store it,
+        *   we need to obtain a base state to compare against, this is
+        *   what #getMinimalParamList does
+        *
+        * @return {Object} Base parameter state
+        */
+       mw.rcfilters.dm.SavedQueriesModel.prototype.getBaseParamState = function () {
+               var allParams,
+                       highlightedItems = {};
+
+               if ( !this.baseParamState ) {
+                       allParams = this.filtersModel.getParametersFromFilters( {} );
+
+                       // Prepare highlights
+                       this.filtersModel.getItemsSupportingHighlights().forEach( function ( item ) {
+                               highlightedItems[ item.getName() + '_color' ] = null;
+                       } );
+
+                       this.baseParamState = {
+                               params: $.extend( true, { invert: '0', highlight: '0' }, allParams ),
+                               highlights: highlightedItems
+                       };
                }
 
-               this.addItems( items );
+               return this.baseParamState;
+       };
 
-               this.emit( 'initialize' );
+       /**
+        * Get an object that holds only the parameters and highlights that have
+        * values different than the base value.
+        *
+        * This is the reverse of the normalization we do initially on loading and
+        * initializing the saved queries model.
+        *
+        * @param {Object} valuesObject Object representing the state of both
+        *  filters and highlights in its normalized version, to be minimized.
+        * @return {Object} Minimal filters and highlights list
+        */
+       mw.rcfilters.dm.SavedQueriesModel.prototype.getMinimalParamList = function ( valuesObject ) {
+               var result = { params: {}, highlights: {} },
+                       baseState = this.getBaseParamState();
+
+               // XOR results
+               $.each( valuesObject.params, function ( name, value ) {
+                       if ( baseState.params !== undefined && baseState.params[ name ] !== value ) {
+                               result.params[ name ] = value;
+                       }
+               } );
+
+               $.each( valuesObject.highlights, function ( name, value ) {
+                       if ( baseState.highlights !== undefined && baseState.highlights[ name ] !== value ) {
+                               result.highlights[ name ] = value;
+                       }
+               } );
+
+               return result;
        };
 
        /**
         *
         * @param {string} label Label for the new query
         * @param {Object} data Data for the new query
+        * @param {boolean} isDefault Item is default
+        * @param {string} [id] Query ID, if exists. If this isn't given, a random
+        *  new ID will be created.
         * @return {string} ID of the newly added query
         */
-       mw.rcfilters.dm.SavedQueriesModel.prototype.addNewQuery = function ( label, data ) {
-               var randomID = ( new Date() ).getTime(),
-                       normalizedData = $.extend( true, {}, this.baseState, data );
+       mw.rcfilters.dm.SavedQueriesModel.prototype.addNewQuery = function ( label, data, isDefault, id ) {
+               var randomID = String( id || ( new Date() ).getTime() ),
+                       normalizedData = this.getMinimalParamList( data );
 
                // Add item
                this.addItems( [
                        new mw.rcfilters.dm.SavedQueryItemModel(
                                randomID,
                                label,
-                               normalizedData
+                               normalizedData,
+                               { 'default': isDefault }
                        )
                ] );
 
+               if ( isDefault ) {
+                       this.setDefault( randomID );
+               }
+
                return randomID;
        };
 
         * @return {mw.rcfilters.dm.SavedQueryItemModel} Matching item model
         */
        mw.rcfilters.dm.SavedQueriesModel.prototype.findMatchingQuery = function ( fullQueryComparison ) {
-               var model = this;
-
-               fullQueryComparison = this.getDifferenceFromBase( fullQueryComparison );
+               // Minimize before comparison
+               fullQueryComparison = this.getMinimalParamList( fullQueryComparison );
 
                return this.getItems().filter( function ( item ) {
-                       var comparedData = model.getDifferenceFromBase( item.getData() );
                        return OO.compare(
-                               comparedData,
+                               item.getData(),
                                fullQueryComparison
                        );
                } )[ 0 ];
        };
 
-       /**
-        * Get a minimal representation of the state for comparison
-        *
-        * @param {Object} state Given state
-        * @return {Object} Minimal state
-        */
-       mw.rcfilters.dm.SavedQueriesModel.prototype.getDifferenceFromBase = function ( state ) {
-               var result = { filters: {}, highlights: {}, invert: state.invert },
-                       baseState = this.baseState;
-
-               // XOR results
-               $.each( state.filters, function ( name, value ) {
-                       if ( baseState.filters !== undefined && baseState.filters[ name ] !== value ) {
-                               result.filters[ name ] = value;
-                       }
-               } );
-
-               $.each( state.highlights, function ( name, value ) {
-                       if ( baseState.highlights !== undefined && baseState.highlights[ name ] !== value && name !== 'highlight' ) {
-                               result.highlights[ name ] = value;
-                       }
-               } );
-
-               return result;
-       };
        /**
         * Get query by its identifier
         *
                } )[ 0 ];
        };
 
+       /**
+        * Get an item's full data
+        *
+        * @param {string} queryID Query identifier
+        * @return {Object} Item's full data
+        */
+       mw.rcfilters.dm.SavedQueriesModel.prototype.getItemFullData = function ( queryID ) {
+               var item = this.getItemByID( queryID );
+
+               // Fill in the base params
+               return item ? $.extend( true, {}, this.getBaseParamState(), item.getData() ) : {};
+       };
+
        /**
         * Get the object representing the state of the entire model and items
         *
         * @return {Object} Object representing the state of the model and items
         */
        mw.rcfilters.dm.SavedQueriesModel.prototype.getState = function () {
-               var obj = { queries: {} };
+               var model = this,
+                       obj = { queries: {}, version: '2' };
 
                // Translate the items to the saved object
                this.getItems().forEach( function ( item ) {
                        var itemState = item.getState();
 
+                       itemState.data = model.getMinimalParamList( itemState.data );
+
                        obj.queries[ item.getID() ] = itemState;
                } );
 
        mw.rcfilters.dm.SavedQueriesModel.prototype.getDefault = function () {
                return this.default;
        };
+
+       /**
+        * Check if the saved queries were converted
+        *
+        * @return {boolean} Saved queries were converted from the previous
+        *  version to the new version
+        */
+       mw.rcfilters.dm.SavedQueriesModel.prototype.isConverted = function () {
+               return this.converted;
+       };
 }( mediaWiki, jQuery ) );
index ed2a73f..685adb6 100644 (file)
@@ -21,6 +21,7 @@
                this.baseFilterState = {};
                this.uriProcessor = null;
                this.initializing = false;
+               this.wereSavedQueriesSaved = false;
 
                this.prevLoggedItems = [];
 
                // Initialize the model
                this.filtersModel.initializeFilters( filterStructure, views );
 
-               this._buildBaseFilterState();
-
                this.uriProcessor = new mw.rcfilters.UriProcessor(
                        this.filtersModel
                );
                                parsedSavedQueries = {};
                        }
 
-                       // The queries are saved in a minimized state, so we need
-                       // to send over the base state so the saved queries model
-                       // can normalize them per each query item
-                       this.savedQueriesModel.initialize(
-                               parsedSavedQueries,
-                               this._getBaseFilterState(),
-                               // This is for backwards compatibility - delete all excluded filter states
-                               Object.keys( this.filtersModel.getExcludedFiltersState() )
-                       );
+                       // Initialize saved queries
+                       this.savedQueriesModel.initialize( parsedSavedQueries );
+                       if ( this.savedQueriesModel.isConverted() ) {
+                               // Since we know we converted, we're going to re-save
+                               // the queries so they are now migrated to the new format
+                               this._saveSavedQueries();
+                       }
                }
 
                // Check whether we need to load defaults.
         * @return {boolean} Defaults are all false
         */
        mw.rcfilters.Controller.prototype.areDefaultsEmpty = function () {
-               var defaultFilters = this.filtersModel.getFiltersFromParameters( this._getDefaultParams() );
+               var defaultParams = this._getDefaultParams(),
+                       defaultFilters = this.filtersModel.getFiltersFromParameters( defaultParams );
 
                this._deleteExcludedValuesFromFilterState( defaultFilters );
 
+               if ( Object.keys( defaultParams ).some( function ( paramName ) {
+                       return paramName.endsWith( '_color' ) && defaultParams[ paramName ] !== null;
+               } ) ) {
+                       // There are highlights in the defaults, they're definitely
+                       // not empty
+                       return false;
+               }
+
                // Defaults can change in a session, so we need to do this every time
                return Object.keys( defaultFilters ).every( function ( filterName ) {
                        return !defaultFilters[ filterName ];
         * @param {boolean} [setAsDefault=false] This query should be set as the default
         */
        mw.rcfilters.Controller.prototype.saveCurrentQuery = function ( label, setAsDefault ) {
-               var queryID,
-                       highlightedItems = {},
+               var highlightedItems = {},
                        highlightEnabled = this.filtersModel.isHighlightEnabled(),
                        selectedState = this.filtersModel.getSelectedState();
 
                // Prepare highlights
                this.filtersModel.getHighlightedItems().forEach( function ( item ) {
-                       highlightedItems[ item.getName() ] = highlightEnabled ?
+                       highlightedItems[ item.getName() + '_color' ] = highlightEnabled ?
                                item.getHighlightColor() : null;
                } );
-               // These are filter states; highlight is stored as boolean
-               highlightedItems.highlight = this.filtersModel.isHighlightEnabled();
 
                // Delete all excluded filters
                this._deleteExcludedValuesFromFilterState( selectedState );
 
                // Add item
-               queryID = this.savedQueriesModel.addNewQuery(
+               this.savedQueriesModel.addNewQuery(
                        label || mw.msg( 'rcfilters-savedqueries-defaultlabel' ),
                        {
-                               filters: selectedState,
-                               highlights: highlightedItems,
-                               invert: this.filtersModel.areNamespacesInverted()
-                       }
+                               params: $.extend(
+                                       true,
+                                       {
+                                               invert: String( Number( this.filtersModel.areNamespacesInverted() ) ),
+                                               highlight: String( Number( this.filtersModel.isHighlightEnabled() ) )
+                                       },
+                                       this.filtersModel.getParametersFromFilters( selectedState )
+                               ),
+                               highlights: highlightedItems
+                       },
+                       setAsDefault
                );
 
-               if ( setAsDefault ) {
-                       this.savedQueriesModel.setDefault( queryID );
-               }
-
                // Save item
                this._saveSavedQueries();
        };
         * @param {string} queryID Query id
         */
        mw.rcfilters.Controller.prototype.applySavedQuery = function ( queryID ) {
-               var data, highlights,
+               var highlights,
                        queryItem = this.savedQueriesModel.getItemByID( queryID ),
+                       data = this.savedQueriesModel.getItemFullData( queryID ),
                        currentMatchingQuery = this.findQueryMatchingCurrentState();
 
                if (
                                currentMatchingQuery.getID() !== queryItem.getID()
                        )
                ) {
-                       data = queryItem.getData();
                        highlights = data.highlights;
 
-                       // Backwards compatibility; initial version mispelled 'highlight' with 'highlights'
-                       highlights.highlight = highlights.highlights || highlights.highlight;
-
                        // Update model state from filters
                        this.filtersModel.toggleFiltersSelected(
                                // Merge filters with excluded values
-                               $.extend( true, {}, data.filters, this.filtersModel.getExcludedFiltersState() )
+                               $.extend(
+                                       true,
+                                       {},
+                                       this.filtersModel.getFiltersFromParameters( data.params ),
+                                       this.filtersModel.getExcludedFiltersState()
+                               )
                        );
 
                        // Update namespace inverted property
-                       this.filtersModel.toggleInvertedNamespaces( !!Number( data.invert ) );
+                       this.filtersModel.toggleInvertedNamespaces( !!Number( data.params.invert ) );
 
                        // Update highlight state
-                       this.filtersModel.toggleHighlight( !!Number( highlights.highlight ) );
+                       this.filtersModel.toggleHighlight( !!Number( data.params.highlight ) );
                        this.filtersModel.getItems().forEach( function ( filterItem ) {
-                               var color = highlights[ filterItem.getName() ];
+                               var color = highlights[ filterItem.getName() + '_color' ];
                                if ( color ) {
                                        filterItem.setHighlightColor( color );
                                } else {
 
                // Prepare highlights of the current query
                this.filtersModel.getItemsSupportingHighlights().forEach( function ( item ) {
-                       highlightedItems[ item.getName() ] = item.getHighlightColor();
+                       highlightedItems[ item.getName() + '_color' ] = item.getHighlightColor();
                } );
-               highlightedItems.highlight = this.filtersModel.isHighlightEnabled();
 
                // Remove anything that should be excluded from the saved query
                // this includes sticky filters and filters marked with 'excludedFromSavedQueries'
 
                return this.savedQueriesModel.findMatchingQuery(
                        {
-                               filters: selectedState,
-                               highlights: highlightedItems,
-                               invert: this.filtersModel.areNamespacesInverted()
+                               params: $.extend(
+                                       true,
+                                       {
+                                               highlight: String( Number( this.filtersModel.isHighlightEnabled() ) ),
+                                               invert: String( Number( this.filtersModel.areNamespacesInverted() ) )
+                                       },
+                                       this.filtersModel.getParametersFromFilters( selectedState )
+                               ),
+                               highlights: highlightedItems
                        }
                );
        };
                } );
        };
 
-       /**
-        * Get an object representing the base state of parameters
-        * and highlights.
-        *
-        * This is meant to make sure that the saved queries that are
-        * in memory are always the same structure as what we would get
-        * by calling the current model's "getSelectedState" and by checking
-        * highlight items.
-        *
-        * In cases where a user saved a query when the system had a certain
-        * set of filters, and then a filter was added to the system, we want
-        * to make sure that the stored queries can still be comparable to
-        * the current state, which means that we need the base state for
-        * two operations:
-        *
-        * - Saved queries are stored in "minimal" view (only changed filters
-        *   are stored); When we initialize the system, we merge each minimal
-        *   query with the base state (using 'getNormalizedFilters') so all
-        *   saved queries have the exact same structure as what we would get
-        *   by checking the getSelectedState of the filter.
-        * - When we save the queries, we minimize the object to only represent
-        *   whatever has actually changed, rather than store the entire
-        *   object. To check what actually is different so we can store it,
-        *   we need to obtain a base state to compare against, this is
-        *   what #_getMinimalFilterList does
-        */
-       mw.rcfilters.Controller.prototype._buildBaseFilterState = function () {
-               var defaultParams = this.filtersModel.getDefaultParams(),
-                       highlightedItems = {};
-
-               // Prepare highlights
-               this.filtersModel.getItemsSupportingHighlights().forEach( function ( item ) {
-                       highlightedItems[ item.getName() ] = null;
-               } );
-               highlightedItems.highlight = false;
-
-               this.baseFilterState = {
-                       filters: this.filtersModel.getFiltersFromParameters( defaultParams ),
-                       highlights: highlightedItems,
-                       invert: false
-               };
-       };
-
-       /**
-        * Get an object representing the base filter state of both
-        * filters and highlights. The structure is similar to what we use
-        * to store each query in the saved queries object:
-        * {
-        *    filters: {
-        *        filterName: (bool)
-        *    },
-        *    highlights: {
-        *        filterName: (string|null)
-        *    }
-        * }
-        *
-        * @return {Object} Object representing the base state of
-        *  parameters and highlights
-        */
-       mw.rcfilters.Controller.prototype._getBaseFilterState = function () {
-               return this.baseFilterState;
-       };
-
-       /**
-        * Get an object that holds only the parameters and highlights that have
-        * values different than the base default value.
-        *
-        * This is the reverse of the normalization we do initially on loading and
-        * initializing the saved queries model.
-        *
-        * @param {Object} valuesObject Object representing the state of both
-        *  filters and highlights in its normalized version, to be minimized.
-        * @return {Object} Minimal filters and highlights list
-        */
-       mw.rcfilters.Controller.prototype._getMinimalFilterList = function ( valuesObject ) {
-               var result = { filters: {}, highlights: {}, invert: valuesObject.invert },
-                       baseState = this._getBaseFilterState();
-
-               // XOR results
-               $.each( valuesObject.filters, function ( name, value ) {
-                       if ( baseState.filters !== undefined && baseState.filters[ name ] !== value ) {
-                               result.filters[ name ] = value;
-                       }
-               } );
-
-               $.each( valuesObject.highlights, function ( name, value ) {
-                       if ( baseState.highlights !== undefined && baseState.highlights[ name ] !== value ) {
-                               result.highlights[ name ] = value;
-                       }
-               } );
-
-               return result;
-       };
-
        /**
         * Save the current state of the saved queries model with all
         * query item representation in the user settings.
         */
        mw.rcfilters.Controller.prototype._saveSavedQueries = function () {
-               var stringified,
-                       state = this.savedQueriesModel.getState(),
-                       controller = this;
-
-               // Minimize before save
-               $.each( state.queries, function ( queryID, info ) {
-                       state.queries[ queryID ].data = controller._getMinimalFilterList( info.data );
-               } );
+               var stringified, oldPrefValue,
+                       backupPrefName = this.savedQueriesPreferenceName + '-versionbackup',
+                       state = this.savedQueriesModel.getState();
 
                // Stringify state
                stringified = JSON.stringify( state );
                        return;
                }
 
+               if ( !this.wereSavedQueriesSaved && this.savedQueriesModel.isConverted() ) {
+                       // The queries were converted from the previous version
+                       // Keep the old string in the [prefname]-versionbackup
+                       oldPrefValue = mw.user.options.get( this.savedQueriesPreferenceName );
+
+                       // Save the old preference in the backup preference
+                       new mw.Api().saveOption( backupPrefName, oldPrefValue );
+                       // Update the preference for this session
+                       mw.user.options.set( backupPrefName, oldPrefValue );
+               }
+
                // Save the preference
                new mw.Api().saveOption( this.savedQueriesPreferenceName, stringified );
                // Update the preference for this session
                mw.user.options.set( this.savedQueriesPreferenceName, stringified );
+
+               // Tag as already saved so we don't do this again
+               this.wereSavedQueriesSaved = true;
        };
 
        /**
         * @return {Object} Default parameters
         */
        mw.rcfilters.Controller.prototype._getDefaultParams = function () {
-               var data, queryHighlights,
-                       savedParams = {},
-                       savedHighlights = {},
-                       defaultSavedQueryItem = !mw.user.isAnon() && this.savedQueriesModel.getItemByID( this.savedQueriesModel.getDefault() );
-
-               if ( defaultSavedQueryItem ) {
-                       data = defaultSavedQueryItem.getData();
-
-                       queryHighlights = data.highlights || {};
-                       savedParams = this.filtersModel.getParametersFromFilters(
-                               $.extend( true, {}, data.filters, this.filtersModel.getStickyFiltersState() )
+               var savedFilters,
+                       data = ( !mw.user.isAnon() && this.savedQueriesModel.getItemFullData( this.savedQueriesModel.getDefault() ) ) || {};
+
+               if ( !$.isEmptyObject( data ) ) {
+                       // Merge saved filter state with sticky filter values
+                       savedFilters = $.extend(
+                               true, {},
+                               this.filtersModel.getFiltersFromParameters( data.params ),
+                               this.filtersModel.getStickyFiltersState()
                        );
 
-                       // Translate highlights to parameters
-                       savedHighlights.highlight = String( Number( queryHighlights.highlight ) );
-                       $.each( queryHighlights, function ( filterName, color ) {
-                               if ( filterName !== 'highlights' ) {
-                                       savedHighlights[ filterName + '_color' ] = color;
-                               }
-                       } );
-
-                       return $.extend( true, {}, savedParams, savedHighlights, { invert: String( Number( data.invert || 0 ) ) } );
+                       // Return parameter representation
+                       return $.extend( true, {},
+                               this.filtersModel.getParametersFromFilters( savedFilters ),
+                               data.highlights,
+                               { highlight: data.params.highlight, invert: data.params.invert }
+                       );
                }
-
                return this.filtersModel.getDefaultParams();
        };
 
index 73259f6..b11322e 100644 (file)
@@ -15,7 +15,7 @@
                                savedQueriesPreferenceName = mw.config.get( 'wgStructuredChangeFiltersSavedQueriesPreferenceName' ),
                                filtersModel = new mw.rcfilters.dm.FiltersViewModel(),
                                changesListModel = new mw.rcfilters.dm.ChangesListViewModel(),
-                               savedQueriesModel = new mw.rcfilters.dm.SavedQueriesModel(),
+                               savedQueriesModel = new mw.rcfilters.dm.SavedQueriesModel( filtersModel ),
                                controller = new mw.rcfilters.Controller(
                                        filtersModel, changesListModel, savedQueriesModel,
                                        {
index 7426123..c840d7c 100644 (file)
                this.model = model;
 
                // Event
-               this.model.connect( this, { update: 'onModelUpdate' } );
+               this.model.connect( this, { update: 'updateUiBasedOnModel' } );
                this.colorPickerWidget.connect( this, { chooseColor: 'onChooseColor' } );
                // This lives inside a MenuOptionWidget, which intercepts mousedown
                // to select the item. We want to prevent that when we click the highlight
                // button
                this.$element.on( 'mousedown', function ( e ) { e.stopPropagation(); } );
 
+               this.updateUiBasedOnModel();
+
                this.$element
                        .addClass( 'mw-rcfilters-ui-filterItemHighlightButton' );
        };
@@ -60,7 +62,7 @@
        /**
         * Respond to item model update event
         */
-       mw.rcfilters.ui.FilterItemHighlightButton.prototype.onModelUpdate = function () {
+       mw.rcfilters.ui.FilterItemHighlightButton.prototype.updateUiBasedOnModel = function () {
                var currentColor = this.model.getHighlightColor(),
                        widget = this;
 
index 570647e..ad3b304 100644 (file)
@@ -24,7 +24,7 @@
                this.controller = controller;
                this.model = model;
 
-               this.currentSelection = '';
+               this.currentSelection = 'none';
                this.buttonSelect = new OO.ui.ButtonSelectWidget( {
                        items: colors.map( function ( color ) {
                                return new OO.ui.ButtonOptionWidget( {
                        } ),
                        classes: 'mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect'
                } );
-               this.selectColor( 'none' );
 
                // Event
-               this.model.connect( this, { update: 'onModelUpdate' } );
+               this.model.connect( this, { update: 'updateUiBasedOnModel' } );
                this.buttonSelect.connect( this, { choose: 'onChooseColor' } );
 
+               this.updateUiBasedOnModel();
+
                this.$element
                        .addClass( 'mw-rcfilters-ui-highlightColorPickerWidget' )
                        .append(
@@ -73,7 +74,7 @@
        /**
         * Respond to item model update event
         */
-       mw.rcfilters.ui.HighlightColorPickerWidget.prototype.onModelUpdate = function () {
+       mw.rcfilters.ui.HighlightColorPickerWidget.prototype.updateUiBasedOnModel = function () {
                this.selectColor( this.model.getHighlightColor() || 'none' );
        };
 
index 7367560..cd0ac15 100644 (file)
@@ -93,6 +93,8 @@ return [
                        'tests/qunit/suites/resources/mediawiki.special/mediawiki.special.recentchanges.test.js',
                        'tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js',
                        'tests/qunit/suites/resources/mediawiki.rcfilters/dm.FilterItem.test.js',
+                       'tests/qunit/suites/resources/mediawiki.rcfilters/dm.SavedQueryItemModel.test.js',
+                       'tests/qunit/suites/resources/mediawiki.rcfilters/dm.SavedQueriesModel.test.js',
                        'tests/qunit/suites/resources/mediawiki.rcfilters/UriProcessor.test.js',
                        'tests/qunit/suites/resources/mediawiki/mediawiki.language.test.js',
                        'tests/qunit/suites/resources/mediawiki/mediawiki.cldr.test.js',
diff --git a/tests/qunit/suites/resources/mediawiki.rcfilters/dm.SavedQueriesModel.test.js b/tests/qunit/suites/resources/mediawiki.rcfilters/dm.SavedQueriesModel.test.js
new file mode 100644 (file)
index 0000000..324a652
--- /dev/null
@@ -0,0 +1,304 @@
+/* eslint-disable camelcase */
+( function ( mw ) {
+       var filterDefinition = [ {
+                       name: 'group1',
+                       type: 'send_unselected_if_any',
+                       filters: [
+                               // Note: The fact filter2 is default means that in the
+                               // filter representation, filter1 and filter3 are 'true'
+                               { name: 'filter1' },
+                               { name: 'filter2', default: true },
+                               { name: 'filter3' }
+                       ]
+               }, {
+                       name: 'group2',
+                       type: 'string_options',
+                       separator: ',',
+                       filters: [
+                               { name: 'filter4' },
+                               { name: 'filter5' },
+                               { name: 'filter6' }
+                       ]
+               }, {
+                       name: 'group3',
+                       type: 'boolean',
+                       isSticky: true,
+                       filters: [
+                               { name: 'group3option1' },
+                               { name: 'group3option2' },
+                               { name: 'group3option3' }
+                       ]
+               } ],
+               queriesFilterRepresentation = {
+                       queries: {
+                               1234: {
+                                       label: 'Item converted',
+                                       data: {
+                                               filters: {
+                                                       // - This value is true, but the original filter-representation
+                                                       // of the saved queries ran against defaults. Since filter1 was
+                                                       // set as default in the definition, the value would actually
+                                                       // not appear in the representation itself.
+                                                       // It is considered 'true', though, and should appear in the
+                                                       // converted result in its parameter representation.
+                                                       // >> group1__filter1: true,
+                                                       // - The reverse is true for filter3. Filter3 is set as default
+                                                       // but we don't want it in this representation of the saved query.
+                                                       // Since the filter representation ran against default values,
+                                                       // it will appear as 'false' value in this representation explicitly
+                                                       // and the resulting parameter representation should have that
+                                                       // as the result as well
+                                                       group1__filter3: false,
+                                                       group2__filter4: true,
+                                                       group3__group3option1: true
+                                               },
+                                               highlights: {
+                                                       highlight: true,
+                                                       filter1: 'c5',
+                                                       group3option1: 'c1'
+                                               },
+                                               invert: true
+                                       }
+                               }
+                       }
+               },
+               queriesParamRepresentation = {
+                       version: '2',
+                       queries: {
+                               1234: {
+                                       label: 'Item converted',
+                                       data: {
+                                               params: {
+                                                       // filter1 is 'true' so filter2 and filter3 are both '1'
+                                                       // in param representation
+                                                       filter2: '1', filter3: '1',
+                                                       // Group type string_options
+                                                       group2: 'filter4',
+                                                       // Note - Group3 is sticky, so it won't show in output
+                                                       // Invert/highlight toggles
+                                                       invert: '1',
+                                                       highlight: '1'
+                                               },
+                                               highlights: {
+                                                       filter1_color: 'c5',
+                                                       group3option1_color: 'c1'
+                                               }
+                                       }
+                               }
+                       }
+               };
+
+       QUnit.module( 'mediawiki.rcfilters - SavedQueriesModel' );
+
+       QUnit.test( 'Initializing queries', function ( assert ) {
+               var filtersModel = new mw.rcfilters.dm.FiltersViewModel(),
+                       queriesModel = new mw.rcfilters.dm.SavedQueriesModel( filtersModel ),
+                       exampleQueryStructure = {
+                               version: '2',
+                               default: '1234',
+                               queries: {
+                                       1234: {
+                                               label: 'Query 1234',
+                                               data: {
+                                                       params: {
+                                                               filter2: '1'
+                                                       },
+                                                       highlights: {
+                                                               filter5_color: 'c2'
+                                                       }
+                                               }
+                                       }
+                               }
+                       },
+                       cases = [
+                               {
+                                       input: {},
+                                       finalState: { version: '2', queries: {} },
+                                       msg: 'Empty initial query structure results in base saved queries structure.'
+                               },
+                               {
+                                       input: $.extend( true, {}, exampleQueryStructure ),
+                                       finalState: $.extend( true, {}, exampleQueryStructure ),
+                                       msg: 'Initialization of given query structure does not corrupt the structure.'
+                               },
+                               {
+                                       // Converting from old structure
+                                       input: $.extend( true, {}, queriesFilterRepresentation ),
+                                       finalState: $.extend( true, {}, queriesParamRepresentation ),
+                                       msg: 'Conversion from filter representation to parameters retains data.'
+                               },
+                               {
+                                       // Converting from old structure with default
+                                       input: $.extend( true, { default: '1234' }, queriesFilterRepresentation ),
+                                       finalState: $.extend( true, { default: '1234' }, queriesParamRepresentation ),
+                                       msg: 'Conversion from filter representation to parameters, with default set up, retains data.'
+                               },
+                               {
+                                       // New structure
+                                       input: $.extend( true, {}, queriesParamRepresentation ),
+                                       finalState: $.extend( true, {}, queriesParamRepresentation ),
+                                       msg: 'Parameter representation retains its queries structure'
+                               }
+                       ];
+
+               filtersModel.initializeFilters( filterDefinition );
+
+               cases.forEach( function ( testCase ) {
+                       queriesModel.initialize( testCase.input );
+                       assert.deepEqual(
+                               queriesModel.getState(),
+                               testCase.finalState,
+                               testCase.msg
+                       );
+               } );
+       } );
+
+       QUnit.test( 'Manipulating queries', function ( assert ) {
+               var id1, id2, item1, matchingItem,
+                       queriesStructure = {},
+                       filtersModel = new mw.rcfilters.dm.FiltersViewModel(),
+                       queriesModel = new mw.rcfilters.dm.SavedQueriesModel( filtersModel );
+
+               filtersModel.initializeFilters( filterDefinition );
+
+               // Start with an empty saved queries model
+               queriesModel.initialize( {} );
+
+               // Add items
+               id1 = queriesModel.addNewQuery(
+                       'New query 1',
+                       {
+                               params: {
+                                       group2: 'filter5',
+                                       highlight: '1'
+                               },
+                               highlights: {
+                                       filter1_color: 'c5',
+                                       group3option1_color: 'c1'
+                               }
+                       }
+               );
+               id2 = queriesModel.addNewQuery(
+                       'New query 2',
+                       {
+                               params: {
+                                       filter1: '1',
+                                       filter2: '1',
+                                       invert: '1'
+                               },
+                               highlights: {}
+                       }
+               );
+               item1 = queriesModel.getItemByID( id1 );
+
+               assert.equal(
+                       item1.getID(),
+                       id1,
+                       'Item created and its data retained successfully'
+               );
+
+               // NOTE: All other methods that the item itself returns are
+               // tested in the dm.SavedQueryItemModel.test.js file
+
+               // Build the query structure we expect per item
+               queriesStructure[ id1 ] = {
+                       label: 'New query 1',
+                       data: {
+                               params: {
+                                       group2: 'filter5',
+                                       highlight: '1'
+                               },
+                               highlights: {
+                                       filter1_color: 'c5',
+                                       group3option1_color: 'c1'
+                               }
+                       }
+               };
+               queriesStructure[ id2 ] = {
+                       label: 'New query 2',
+                       data: {
+                               params: {
+                                       filter1: '1',
+                                       filter2: '1',
+                                       invert: '1'
+                               },
+                               highlights: {}
+                       }
+               };
+
+               assert.deepEqual(
+                       queriesModel.getState(),
+                       {
+                               version: '2',
+                               queries: queriesStructure
+                       },
+                       'Full query represents current state of items'
+               );
+
+               // Add default
+               queriesModel.setDefault( id2 );
+
+               assert.deepEqual(
+                       queriesModel.getState(),
+                       {
+                               version: '2',
+                               default: id2,
+                               queries: queriesStructure
+                       },
+                       'Setting default is reflected in queries state'
+               );
+
+               // Remove default
+               queriesModel.setDefault( null );
+
+               assert.deepEqual(
+                       queriesModel.getState(),
+                       {
+                               version: '2',
+                               queries: queriesStructure
+                       },
+                       'Removing default is reflected in queries state'
+               );
+
+               // Find matching query
+               matchingItem = queriesModel.findMatchingQuery(
+                       {
+                               params: {
+                                       group2: 'filter5',
+                                       highlight: '1'
+                               },
+                               highlights: {
+                                       filter1_color: 'c5',
+                                       group3option1_color: 'c1'
+                               }
+                       }
+               );
+               assert.deepEqual(
+                       matchingItem.getID(),
+                       id1,
+                       'Finding matching item by identical state'
+               );
+
+               // Find matching query with 0-values (base state)
+               matchingItem = queriesModel.findMatchingQuery(
+                       {
+                               params: {
+                                       group2: 'filter5',
+                                       filter1: '0',
+                                       filter2: '0',
+                                       highlight: '1',
+                                       invert: '0'
+                               },
+                               highlights: {
+                                       filter1_color: 'c5',
+                                       group3option1_color: 'c1'
+                               }
+                       }
+               );
+               assert.deepEqual(
+                       matchingItem.getID(),
+                       id1,
+                       'Finding matching item by "dirty" state with 0-base values'
+               );
+       } );
+}( mediaWiki ) );
diff --git a/tests/qunit/suites/resources/mediawiki.rcfilters/dm.SavedQueryItemModel.test.js b/tests/qunit/suites/resources/mediawiki.rcfilters/dm.SavedQueryItemModel.test.js
new file mode 100644 (file)
index 0000000..a91dff9
--- /dev/null
@@ -0,0 +1,90 @@
+/* eslint-disable camelcase */
+( function ( mw ) {
+       var itemData = {
+               params: {
+                       param1: '1',
+                       param2: 'foo|bar',
+                       highlight: '1',
+                       invert: '0'
+               },
+               highlights: {
+                       param1_color: 'c1',
+                       param2_color: 'c2'
+               }
+       };
+
+       QUnit.module( 'mediawiki.rcfilters - SavedQueryItemModel' );
+
+       QUnit.test( 'Initializing and getters', function ( assert ) {
+               var model;
+
+               model = new mw.rcfilters.dm.SavedQueryItemModel(
+                       'randomID',
+                       'Some label',
+                       $.extend( true, {}, itemData )
+               );
+
+               assert.equal(
+                       model.getID(),
+                       'randomID',
+                       'Item ID is retained'
+               );
+
+               assert.equal(
+                       model.getLabel(),
+                       'Some label',
+                       'Item label is retained'
+               );
+
+               assert.deepEqual(
+                       model.getData(),
+                       itemData,
+                       'Item data is retained'
+               );
+
+               assert.ok(
+                       !model.isDefault(),
+                       'Item default state is retained.'
+               );
+       } );
+
+       QUnit.test( 'Default', function ( assert ) {
+               var model;
+
+               model = new mw.rcfilters.dm.SavedQueryItemModel(
+                       'randomID',
+                       'Some label',
+                       $.extend( true, {}, itemData )
+               );
+
+               assert.ok(
+                       !model.isDefault(),
+                       'Default state represented when item initialized with default:false.'
+               );
+
+               model.toggleDefault( true );
+               assert.ok(
+                       model.isDefault(),
+                       'Default state toggles to true successfully'
+               );
+
+               model.toggleDefault( false );
+               assert.ok(
+                       !model.isDefault(),
+                       'Default state toggles to false successfully'
+               );
+
+               // Reset
+               model = new mw.rcfilters.dm.SavedQueryItemModel(
+                       'randomID',
+                       'Some label',
+                       $.extend( true, {}, itemData ),
+                       { default: true }
+               );
+
+               assert.ok(
+                       model.isDefault(),
+                       'Default state represented when item initialized with default:true.'
+               );
+       } );
+}( mediaWiki ) );