diff options
Diffstat (limited to 'www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js')
-rw-r--r-- | www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js | 1562 |
1 files changed, 1562 insertions, 0 deletions
diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js new file mode 100644 index 00000000..2b42b5ab --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js @@ -0,0 +1,1562 @@ +/* eslint-disable camelcase */ +( function ( mw, $ ) { + var filterDefinition = [ { + name: 'group1', + type: 'send_unselected_if_any', + filters: [ + { + name: 'filter1', label: 'group1filter1-label', description: 'group1filter1-desc', + default: true, + cssClass: 'filter1class', + conflicts: [ { group: 'group2' } ], + subset: [ + { + group: 'group1', + filter: 'filter2' + }, + { + group: 'group1', + filter: 'filter3' + } + ] + }, + { + name: 'filter2', label: 'group1filter2-label', description: 'group1filter2-desc', + conflicts: [ { group: 'group2', filter: 'filter6' } ], + cssClass: 'filter2class', + subset: [ + { + group: 'group1', + filter: 'filter3' + } + ] + }, + // NOTE: This filter has no highlight! + { name: 'filter3', label: 'group1filter3-label', description: 'group1filter3-desc', default: true } + ] + }, { + name: 'group2', + type: 'send_unselected_if_any', + fullCoverage: true, + conflicts: [ { group: 'group1', filter: 'filter1' } ], + filters: [ + { name: 'filter4', label: 'group2filter4-label', description: 'group2filter4-desc', cssClass: 'filter4class' }, + { name: 'filter5', label: 'group2filter5-label', description: 'group2filter5-desc', default: true, cssClass: 'filter5class' }, + { + name: 'filter6', label: 'group2filter6-label', description: 'group2filter6-desc', cssClass: 'filter6class', + conflicts: [ { group: 'group1', filter: 'filter2' } ] + } + ] + }, { + name: 'group3', + type: 'string_options', + separator: ',', + default: 'filter8', + filters: [ + { name: 'filter7', label: 'group3filter7-label', description: 'group3filter7-desc', cssClass: 'filter7class' }, + { name: 'filter8', label: 'group3filter8-label', description: 'group3filter8-desc', cssClass: 'filter8class' }, + { name: 'filter9', label: 'group3filter9-label', description: 'group3filter9-desc', cssClass: 'filter9class' } + ] + }, { + name: 'group4', + type: 'single_option', + hidden: true, + default: 'option2', + filters: [ + // NOTE: The entire group has no highlight supported + { name: 'option1', label: 'group4option1-label', description: 'group4option1-desc' }, + { name: 'option2', label: 'group4option2-label', description: 'group4option2-desc' }, + { name: 'option3', label: 'group4option3-label', description: 'group4option3-desc' } + ] + }, { + name: 'group5', + type: 'single_option', + filters: [ + { name: 'option1', label: 'group5option1-label', description: 'group5option1-desc', cssClass: 'group5opt1class' }, + { name: 'option2', label: 'group5option2-label', description: 'group5option2-desc', cssClass: 'group5opt2class' }, + { name: 'option3', label: 'group5option3-label', description: 'group5option3-desc', cssClass: 'group5opt3class' } + ] + }, { + name: 'group6', + type: 'boolean', + sticky: true, + filters: [ + { name: 'group6option1', label: 'group6option1-label', description: 'group6option1-desc', cssClass: 'group6opt1class' }, + { name: 'group6option2', label: 'group6option2-label', description: 'group6option2-desc', default: true, cssClass: 'group6opt2class' }, + { name: 'group6option3', label: 'group6option3-label', description: 'group6option3-desc', default: true, cssClass: 'group6opt3class' } + ] + }, { + name: 'group7', + type: 'single_option', + sticky: true, + default: 'group7option2', + filters: [ + { name: 'group7option1', label: 'group7option1-label', description: 'group7option1-desc', cssClass: 'group7opt1class' }, + { name: 'group7option2', label: 'group7option2-label', description: 'group7option2-desc', cssClass: 'group7opt2class' }, + { name: 'group7option3', label: 'group7option3-label', description: 'group7option3-desc', cssClass: 'group7opt3class' } + ] + } ], + shortFilterDefinition = [ { + name: 'group1', + type: 'send_unselected_if_any', + filters: [ { name: 'filter1' }, { name: 'filter2' } ] + }, { + name: 'group2', + type: 'boolean', + hidden: true, + filters: [ { name: 'filter3' }, { name: 'filter4' } ] + }, { + name: 'group3', + type: 'string_options', + sticky: true, + default: 'filter6', + filters: [ { name: 'filter5' }, { name: 'filter6' }, { name: 'filter7' } ] + } ], + viewsDefinition = { + namespaces: { + label: 'Namespaces', + trigger: ':', + groups: [ { + name: 'namespace', + label: 'Namespaces', + type: 'string_options', + separator: ';', + filters: [ + { name: 0, label: 'Main', cssClass: 'namespace-0' }, + { name: 1, label: 'Talk', cssClass: 'namespace-1' }, + { name: 2, label: 'User', cssClass: 'namespace-2' }, + { name: 3, label: 'User talk', cssClass: 'namespace-3' } + ] + } ] + } + }, + defaultParameters = { + filter1: '1', + filter2: '0', + filter3: '1', + filter4: '0', + filter5: '1', + filter6: '0', + group3: 'filter8', + group4: 'option2', + group5: 'option1', + namespace: '' + }, + baseParamRepresentation = { + filter1: '0', + filter2: '0', + filter3: '0', + filter4: '0', + filter5: '0', + filter6: '0', + group3: '', + group4: 'option2', + group5: 'option1', + group6option1: '0', + group6option2: '1', + group6option3: '1', + group7: 'group7option2', + namespace: '' + }, + emptyParamRepresentation = { + filter1: '0', + filter2: '0', + filter3: '0', + filter4: '0', + filter5: '0', + filter6: '0', + group3: '', + group4: '', + group5: '', + group6option1: '0', + group6option2: '0', + group6option3: '0', + group7: '', + namespace: '', + // Null highlights + group1__filter1_color: null, + group1__filter2_color: null, + // group1__filter3_color: null, // Highlight isn't supported + group2__filter4_color: null, + group2__filter5_color: null, + group2__filter6_color: null, + group3__filter7_color: null, + group3__filter8_color: null, + group3__filter9_color: null, + // group4__option1_color: null, // Highlight isn't supported + // group4__option2_color: null, // Highlight isn't supported + // group4__option3_color: null, // Highlight isn't supported + group5__option1_color: null, + group5__option2_color: null, + group5__option3_color: null, + group6__group6option1_color: null, + group6__group6option2_color: null, + group6__group6option3_color: null, + group7__group7option1_color: null, + group7__group7option2_color: null, + group7__group7option3_color: null, + namespace__0_color: null, + namespace__1_color: null, + namespace__2_color: null, + namespace__3_color: null + }, + baseFilterRepresentation = { + group1__filter1: false, + group1__filter2: false, + group1__filter3: false, + group2__filter4: false, + group2__filter5: false, + group2__filter6: false, + group3__filter7: false, + group3__filter8: false, + group3__filter9: false, + // The 'single_value' type of group can't have empty value; it's either + // the default given or the first item that will get the truthy value + group4__option1: false, + group4__option2: true, // Default + group4__option3: false, + group5__option1: true, // No default set, first item is default value + group5__option2: false, + group5__option3: false, + group6__group6option1: false, + group6__group6option2: true, + group6__group6option3: true, + group7__group7option1: false, + group7__group7option2: true, + group7__group7option3: false, + namespace__0: false, + namespace__1: false, + namespace__2: false, + namespace__3: false + }, + baseFullFilterState = { + group1__filter1: { selected: false, conflicted: false, included: false }, + group1__filter2: { selected: false, conflicted: false, included: false }, + group1__filter3: { selected: false, conflicted: false, included: false }, + group2__filter4: { selected: false, conflicted: false, included: false }, + group2__filter5: { selected: false, conflicted: false, included: false }, + group2__filter6: { selected: false, conflicted: false, included: false }, + group3__filter7: { selected: false, conflicted: false, included: false }, + group3__filter8: { selected: false, conflicted: false, included: false }, + group3__filter9: { selected: false, conflicted: false, included: false }, + group4__option1: { selected: false, conflicted: false, included: false }, + group4__option2: { selected: true, conflicted: false, included: false }, + group4__option3: { selected: false, conflicted: false, included: false }, + group5__option1: { selected: true, conflicted: false, included: false }, + group5__option2: { selected: false, conflicted: false, included: false }, + group5__option3: { selected: false, conflicted: false, included: false }, + group6__group6option1: { selected: false, conflicted: false, included: false }, + group6__group6option2: { selected: true, conflicted: false, included: false }, + group6__group6option3: { selected: true, conflicted: false, included: false }, + group7__group7option1: { selected: false, conflicted: false, included: false }, + group7__group7option2: { selected: true, conflicted: false, included: false }, + group7__group7option3: { selected: false, conflicted: false, included: false }, + namespace__0: { selected: false, conflicted: false, included: false }, + namespace__1: { selected: false, conflicted: false, included: false }, + namespace__2: { selected: false, conflicted: false, included: false }, + namespace__3: { selected: false, conflicted: false, included: false } + }; + + QUnit.module( 'mediawiki.rcfilters - FiltersViewModel', QUnit.newMwEnvironment( { + messages: { + 'group1filter1-label': 'Group 1: Filter 1 title', + 'group1filter1-desc': 'Description of Filter 1 in Group 1', + 'group1filter2-label': 'Group 1: Filter 2 title', + 'group1filter2-desc': 'Description of Filter 2 in Group 1', + 'group1filter3-label': 'Group 1: Filter 3', + 'group1filter3-desc': 'Description of Filter 3 in Group 1', + + 'group2filter4-label': 'Group 2: Filter 4 title', + 'group2filter4-desc': 'Description of Filter 4 in Group 2', + 'group2filter5-label': 'Group 2: Filter 5', + 'group2filter5-desc': 'Description of Filter 5 in Group 2', + 'group2filter6-label': 'xGroup 2: Filter 6', + 'group2filter6-desc': 'Description of Filter 6 in Group 2' + } + } ) ); + + QUnit.test( 'Setting up filters', function ( assert ) { + var model = new mw.rcfilters.dm.FiltersViewModel(); + + model.initializeFilters( filterDefinition, viewsDefinition ); + + // Test that all items were created + assert.ok( + Object.keys( baseFilterRepresentation ).every( function ( filterName ) { + return model.getItemByName( filterName ) instanceof mw.rcfilters.dm.FilterItem; + } ), + 'Filters instantiated and stored correctly' + ); + + assert.deepEqual( + model.getSelectedState(), + baseFilterRepresentation, + 'Initial state of filters' + ); + + model.toggleFiltersSelected( { + group1__filter1: true, + group2__filter5: true, + group3__filter7: true + } ); + assert.deepEqual( + model.getSelectedState(), + $.extend( true, {}, baseFilterRepresentation, { + group1__filter1: true, + group2__filter5: true, + group3__filter7: true + } ), + 'Updating filter states correctly' + ); + } ); + + QUnit.test( 'Default filters', function ( assert ) { + var model = new mw.rcfilters.dm.FiltersViewModel(); + + model.initializeFilters( filterDefinition, viewsDefinition ); + + // Empty query = only default values + assert.deepEqual( + model.getDefaultParams(), + defaultParameters, + 'Default parameters are stored properly per filter and group (sticky groups are ignored)' + ); + } ); + + QUnit.test( 'Parameter minimal state', function ( assert ) { + var model = new mw.rcfilters.dm.FiltersViewModel(), + cases = [ + { + input: {}, + result: {}, + msg: 'Empty parameter representation produces an empty result' + }, + { + input: { + filter1: '1', + filter2: '0', + filter3: '0', + group3: '', + group4: 'option2' + }, + result: { + filter1: '1', + group4: 'option2' + }, + msg: 'Mixed input results in only non-falsey values as result' + }, + { + input: { + filter1: '0', + filter2: '0', + filter3: '0', + group3: '', + group4: '', + group1__filter1_color: null + }, + result: {}, + msg: 'An all-falsey input results in an empty result.' + }, + { + input: { + filter1: '0', + filter2: '0', + filter3: '0', + group3: '', + group4: '', + group1__filter1_color: 'c1' + }, + result: { + group1__filter1_color: 'c1' + }, + msg: 'An all-falsey input with highlight params result in only the highlight param.' + }, + { + input: { + group1__filter1_color: 'c1', + group1__filter3_color: 'c3' // Not supporting highlights + }, + result: { + group1__filter1_color: 'c1' + }, + msg: 'Unsupported highlights are removed.' + } + ]; + + model.initializeFilters( filterDefinition, viewsDefinition ); + + cases.forEach( function ( test ) { + assert.deepEqual( + model.getMinimizedParamRepresentation( test.input ), + test.result, + test.msg + ); + } ); + } ); + + QUnit.test( 'Parameter states', function ( assert ) { + // Some groups / params have their defaults immediately applied + // to their state. These include single_option which can never + // be empty, etc. These are these states: + var parametersWithoutExcluded, + appliedDefaultParameters = { + group4: 'option2', + group5: 'option1', + // Sticky, their defaults apply immediately + group6option2: '1', + group6option3: '1', + group7: 'group7option2' + }, + model = new mw.rcfilters.dm.FiltersViewModel(); + + model.initializeFilters( filterDefinition, viewsDefinition ); + assert.deepEqual( + model.getEmptyParameterState(), + emptyParamRepresentation, + 'Producing an empty parameter state' + ); + + model.toggleFiltersSelected( { + group1__filter1: true, + group3__filter7: true + } ); + + assert.deepEqual( + model.getCurrentParameterState(), + // appliedDefaultParams applies the default value to parameters + // who must have an initial value to begin with, so we have to + // take it into account in the current state + $.extend( true, {}, appliedDefaultParameters, { + filter2: '1', + filter3: '1', + group3: 'filter7' + } ), + 'Producing a current parameter state' + ); + + // Reset + model = new mw.rcfilters.dm.FiltersViewModel(); + model.initializeFilters( filterDefinition, viewsDefinition ); + + parametersWithoutExcluded = $.extend( true, {}, appliedDefaultParameters ); + delete parametersWithoutExcluded.group7; + delete parametersWithoutExcluded.group6option2; + delete parametersWithoutExcluded.group6option3; + + assert.deepEqual( + model.getCurrentParameterState( true ), + parametersWithoutExcluded, + 'Producing a current clean parameter state without excluded filters' + ); + } ); + + QUnit.test( 'Cleaning up parameter states', function ( assert ) { + var model = new mw.rcfilters.dm.FiltersViewModel(), + cases = [ + { + input: {}, + result: {}, + msg: 'Empty parameter representation produces an empty result' + }, + { + input: { + filter1: '1', // Regular (do not strip) + group6option1: '1' // Sticky + }, + result: { filter1: '1' }, + msg: 'Valid input strips all sticky params regardless of value' + } + ]; + + model.initializeFilters( filterDefinition, viewsDefinition ); + + cases.forEach( function ( test ) { + assert.deepEqual( + model.removeStickyParams( test.input ), + test.result, + test.msg + ); + } ); + + } ); + + QUnit.test( 'Finding matching filters', function ( assert ) { + var matches, + testCases = [ + { + query: 'group', + expectedMatches: { + group1: [ 'group1__filter1', 'group1__filter2', 'group1__filter3' ], + group2: [ 'group2__filter4', 'group2__filter5' ] + }, + reason: 'Finds filters starting with the query string' + }, + { + query: 'in Group 2', + expectedMatches: { + group2: [ 'group2__filter4', 'group2__filter5', 'group2__filter6' ] + }, + reason: 'Finds filters containing the query string in their description' + }, + { + query: 'title', + expectedMatches: { + group1: [ 'group1__filter1', 'group1__filter2' ], + group2: [ 'group2__filter4' ] + }, + reason: 'Finds filters containing the query string in their group title' + }, + { + query: ':Main', + expectedMatches: { + namespace: [ 'namespace__0' ] + }, + reason: 'Finds item in view when a prefix is used' + }, + { + query: ':group', + expectedMatches: {}, + reason: 'Finds no results if using namespaces prefix (:) to search for filter title' + } + ], + model = new mw.rcfilters.dm.FiltersViewModel(), + extractNames = function ( matches ) { + var result = {}; + Object.keys( matches ).forEach( function ( groupName ) { + result[ groupName ] = matches[ groupName ].map( function ( item ) { + return item.getName(); + } ); + } ); + return result; + }; + + model.initializeFilters( filterDefinition, viewsDefinition ); + + testCases.forEach( function ( testCase ) { + matches = model.findMatches( testCase.query ); + assert.deepEqual( + extractNames( matches ), + testCase.expectedMatches, + testCase.reason + ); + } ); + + matches = model.findMatches( 'foo' ); + assert.ok( + $.isEmptyObject( matches ), + 'findMatches returns an empty object when no results found' + ); + } ); + + QUnit.test( 'getParametersFromFilters', function ( assert ) { + var model = new mw.rcfilters.dm.FiltersViewModel(); + + model.initializeFilters( filterDefinition, viewsDefinition ); + + // Starting with all filters unselected + assert.deepEqual( + model.getParametersFromFilters(), + baseParamRepresentation, + 'Unselected filters return all parameters falsey or \'\'.' + ); + + // Select 1 filter + model.toggleFiltersSelected( { + group1__filter1: true + } ); + // Only one filter in one group + assert.deepEqual( + model.getParametersFromFilters(), + $.extend( true, {}, baseParamRepresentation, { + // Group 1 (one selected, the others are true) + filter2: '1', + filter3: '1' + } ), + 'One filter in one "send_unselected_if_any" group returns the other parameters truthy.' + ); + + // Select 2 filters + model.toggleFiltersSelected( { + group1__filter1: true, + group1__filter2: true + } ); + // Two selected filters in one group + assert.deepEqual( + model.getParametersFromFilters(), + $.extend( true, {}, baseParamRepresentation, { + // Group 1 (two selected, the other is true) + filter3: '1' + } ), + 'Two filters in one "send_unselected_if_any" group returns the other parameters truthy.' + ); + + // Select 3 filters + model.toggleFiltersSelected( { + group1__filter1: true, + group1__filter2: true, + group1__filter3: true + } ); + // All filters of the group are selected == this is the same as not selecting any + assert.deepEqual( + model.getParametersFromFilters(), + baseParamRepresentation, + 'All filters selected in one "send_unselected_if_any" group returns all parameters falsy.' + ); + + // Select 1 filter from string_options + model.toggleFiltersSelected( { + group3__filter7: true, + group3__filter8: false, + group3__filter9: false + } ); + // All filters of the group are selected == this is the same as not selecting any + assert.deepEqual( + model.getParametersFromFilters(), + $.extend( true, {}, baseParamRepresentation, { + group3: 'filter7' + } ), + 'One filter selected in "string_option" group returns that filter in the value.' + ); + + // Select 2 filters from string_options + model.toggleFiltersSelected( { + group3__filter7: true, + group3__filter8: true, + group3__filter9: false + } ); + // All filters of the group are selected == this is the same as not selecting any + assert.deepEqual( + model.getParametersFromFilters(), + $.extend( true, {}, baseParamRepresentation, { + group3: 'filter7,filter8' + } ), + 'Two filters selected in "string_option" group returns those filters in the value.' + ); + + // Select 3 filters from string_options + model.toggleFiltersSelected( { + group3__filter7: true, + group3__filter8: true, + group3__filter9: true + } ); + // All filters of the group are selected == this is the same as not selecting any + assert.deepEqual( + model.getParametersFromFilters(), + $.extend( true, {}, baseParamRepresentation, { + group3: 'all' + } ), + 'All filters selected in "string_option" group returns \'all\'.' + ); + + // Reset + model = new mw.rcfilters.dm.FiltersViewModel(); + model.initializeFilters( filterDefinition, viewsDefinition ); + + // Select an option from single_option group + model.toggleFiltersSelected( { + group4__option2: true + } ); + // All filters of the group are selected == this is the same as not selecting any + assert.deepEqual( + model.getParametersFromFilters(), + $.extend( true, {}, baseParamRepresentation, { + group4: 'option2' + } ), + 'Selecting an option from "single_option" group returns that option as a value.' + ); + + // Select a different option from single_option group + model.toggleFiltersSelected( { + group4__option3: true + } ); + // All filters of the group are selected == this is the same as not selecting any + assert.deepEqual( + model.getParametersFromFilters(), + $.extend( true, {}, baseParamRepresentation, { + group4: 'option3' + } ), + 'Selecting a different option from "single_option" group changes the selection.' + ); + } ); + + QUnit.test( 'getParametersFromFilters (custom object)', function ( assert ) { + // This entire test uses different base definition than the global one + // on purpose, to verify that the values inserted as a custom object + // are the ones we expect in return + var originalState, + model = new mw.rcfilters.dm.FiltersViewModel(), + definition = [ { + name: 'group1', + title: 'Group 1', + type: 'send_unselected_if_any', + filters: [ + { name: 'hidefilter1', label: 'Hide filter 1', description: '' }, + { name: 'hidefilter2', label: 'Hide filter 2', description: '' }, + { name: 'hidefilter3', label: 'Hide filter 3', description: '' } + ] + }, { + name: 'group2', + title: 'Group 2', + type: 'send_unselected_if_any', + filters: [ + { name: 'hidefilter4', label: 'Hide filter 4', description: '' }, + { name: 'hidefilter5', label: 'Hide filter 5', description: '' }, + { name: 'hidefilter6', label: 'Hide filter 6', description: '' } + ] + }, { + name: 'group3', + title: 'Group 3', + type: 'string_options', + separator: ',', + filters: [ + { name: 'filter7', label: 'Hide filter 7', description: '' }, + { name: 'filter8', label: 'Hide filter 8', description: '' }, + { name: 'filter9', label: 'Hide filter 9', description: '' } + ] + }, { + name: 'group4', + title: 'Group 4', + type: 'single_option', + filters: [ + { name: 'filter10', label: 'Hide filter 10', description: '' }, + { name: 'filter11', label: 'Hide filter 11', description: '' }, + { name: 'filter12', label: 'Hide filter 12', description: '' } + ] + } ], + baseResult = { + hidefilter1: '0', + hidefilter2: '0', + hidefilter3: '0', + hidefilter4: '0', + hidefilter5: '0', + hidefilter6: '0', + group3: '', + group4: '' + }, + cases = [ + { + // This is mocking the cases above, both + // - 'Two filters in one "send_unselected_if_any" group returns the other parameters truthy.' + // - 'Two filters selected in "string_option" group returns those filters in the value.' + input: { + group1__hidefilter1: true, + group1__hidefilter2: true, + group1__hidefilter3: false, + group2__hidefilter4: false, + group2__hidefilter5: false, + group2__hidefilter6: false, + group3__filter7: true, + group3__filter8: true, + group3__filter9: false + }, + expected: $.extend( true, {}, baseResult, { + // Group 1 (two selected, the others are true) + hidefilter3: '1', + // Group 3 (two selected) + group3: 'filter7,filter8' + } ), + msg: 'Given an explicit (complete) filter state object, the result is the same as if the object given represented the model state.' + }, + { + // This is mocking case above + // - 'One filter in one "send_unselected_if_any" group returns the other parameters truthy.' + input: { + group1__hidefilter1: 1 + }, + expected: $.extend( true, {}, baseResult, { + // Group 1 (one selected, the others are true) + hidefilter2: '1', + hidefilter3: '1' + } ), + msg: 'Given an explicit (incomplete) filter state object, the result is the same as if the object give represented the model state.' + }, + { + input: { + group4__filter10: true + }, + expected: $.extend( true, {}, baseResult, { + group4: 'filter10' + } ), + msg: 'Given a single value for "single_option" that option is represented in the result.' + }, + { + input: { + group4__filter10: true, + group4__filter11: true + }, + expected: $.extend( true, {}, baseResult, { + group4: 'filter10' + } ), + msg: 'Given more than one true value for "single_option" (which should not happen!) only the first value counts, and the second is ignored.' + }, + { + input: {}, + expected: baseResult, + msg: 'Given an explicit empty object, the result is all filters set to their falsey unselected value.' + } + ]; + + model.initializeFilters( definition ); + // Store original state + originalState = model.getSelectedState(); + + // Test each case + cases.forEach( function ( test ) { + assert.deepEqual( + model.getParametersFromFilters( test.input ), + test.expected, + test.msg + ); + } ); + + // After doing the above tests, make sure the actual state + // of the filter stayed the same + assert.deepEqual( + model.getSelectedState(), + originalState, + 'Running the method with external definition to parse does not actually change the state of the model' + ); + } ); + + QUnit.test( 'getFiltersFromParameters', function ( assert ) { + var model = new mw.rcfilters.dm.FiltersViewModel(); + + model.initializeFilters( filterDefinition, viewsDefinition ); + + // Empty query = only default values + assert.deepEqual( + model.getFiltersFromParameters( {} ), + baseFilterRepresentation, + 'Empty parameter query results in an object representing all filters set to their base state' + ); + + assert.deepEqual( + model.getFiltersFromParameters( { + filter2: '1' + } ), + $.extend( {}, baseFilterRepresentation, { + group1__filter1: true, // The text is "show filter 1" + group1__filter2: false, // The text is "show filter 2" + group1__filter3: true // The text is "show filter 3" + } ), + 'One truthy parameter in a group whose other parameters are true by default makes the rest of the filters in the group false (unchecked)' + ); + + assert.deepEqual( + model.getFiltersFromParameters( { + filter1: '1', + filter2: '1', + filter3: '1' + } ), + $.extend( {}, baseFilterRepresentation, { + group1__filter1: false, // The text is "show filter 1" + group1__filter2: false, // The text is "show filter 2" + group1__filter3: false // The text is "show filter 3" + } ), + 'All paremeters in the same \'send_unselected_if_any\' group false is equivalent to none are truthy (checked) in the interface' + ); + + // The ones above don't update the model, so we have a clean state. + // getFiltersFromParameters is stateless; any change is unaffected by the current state + // This test is demonstrating wrong usage of the method; + // We should be aware that getFiltersFromParameters is stateless, + // so each call gives us a filter state that only reflects the query given. + // This means that the two calls to toggleFiltersSelected() below collide. + // The result of the first is overridden by the result of the second, + // since both get a full state object from getFiltersFromParameters that **only** relates + // to the input it receives. + model.toggleFiltersSelected( + model.getFiltersFromParameters( { + filter1: '1' + } ) + ); + + model.toggleFiltersSelected( + model.getFiltersFromParameters( { + filter6: '1' + } ) + ); + + // The result here is ignoring the first toggleFiltersSelected call + assert.deepEqual( + model.getSelectedState(), + $.extend( {}, baseFilterRepresentation, { + group2__filter4: true, + group2__filter5: true, + group2__filter6: false + } ), + 'getFiltersFromParameters does not care about previous or existing state.' + ); + + // Reset + model = new mw.rcfilters.dm.FiltersViewModel(); + model.initializeFilters( filterDefinition, viewsDefinition ); + + model.toggleFiltersSelected( + model.getFiltersFromParameters( { + group3: 'filter7' + } ) + ); + assert.deepEqual( + model.getSelectedState(), + $.extend( {}, baseFilterRepresentation, { + group3__filter7: true, + group3__filter8: false, + group3__filter9: false + } ), + 'A \'string_options\' parameter containing 1 value, results in the corresponding filter as checked' + ); + + model.toggleFiltersSelected( + model.getFiltersFromParameters( { + group3: 'filter7,filter8' + } ) + ); + assert.deepEqual( + model.getSelectedState(), + $.extend( {}, baseFilterRepresentation, { + group3__filter7: true, + group3__filter8: true, + group3__filter9: false + } ), + 'A \'string_options\' parameter containing 2 values, results in both corresponding filters as checked' + ); + + model.toggleFiltersSelected( + model.getFiltersFromParameters( { + group3: 'filter7,filter8,filter9' + } ) + ); + assert.deepEqual( + model.getSelectedState(), + $.extend( {}, baseFilterRepresentation, { + group3__filter7: true, + group3__filter8: true, + group3__filter9: true + } ), + 'A \'string_options\' parameter containing all values, results in all filters of the group as checked.' + ); + + model.toggleFiltersSelected( + model.getFiltersFromParameters( { + group3: 'filter7,all,filter9' + } ) + ); + assert.deepEqual( + model.getSelectedState(), + $.extend( {}, baseFilterRepresentation, { + group3__filter7: true, + group3__filter8: true, + group3__filter9: true + } ), + 'A \'string_options\' parameter containing the value \'all\', results in all filters of the group as checked.' + ); + + model.toggleFiltersSelected( + model.getFiltersFromParameters( { + group3: 'filter7,foo,filter9' + } ) + ); + assert.deepEqual( + model.getSelectedState(), + $.extend( {}, baseFilterRepresentation, { + group3__filter7: true, + group3__filter8: false, + group3__filter9: true + } ), + 'A \'string_options\' parameter containing an invalid value, results in the invalid value ignored and the valid corresponding filters checked.' + ); + + model.toggleFiltersSelected( + model.getFiltersFromParameters( { + group4: 'option1' + } ) + ); + assert.deepEqual( + model.getSelectedState(), + $.extend( {}, baseFilterRepresentation, { + group4__option1: true, + group4__option2: false + } ), + 'A \'single_option\' parameter reflects a single selected value.' + ); + + assert.deepEqual( + model.getFiltersFromParameters( { + group4: 'option1,option2' + } ), + baseFilterRepresentation, + 'An invalid \'single_option\' parameter is ignored.' + ); + + // Change to one value + model.toggleFiltersSelected( + model.getFiltersFromParameters( { + group4: 'option1' + } ) + ); + // Change again to another value + model.toggleFiltersSelected( + model.getFiltersFromParameters( { + group4: 'option2' + } ) + ); + assert.deepEqual( + model.getSelectedState(), + $.extend( {}, baseFilterRepresentation, { + group4__option2: true + } ), + 'A \'single_option\' parameter always reflects the latest selected value.' + ); + } ); + + QUnit.test( 'sanitizeStringOptionGroup', function ( assert ) { + var model = new mw.rcfilters.dm.FiltersViewModel(); + + model.initializeFilters( filterDefinition, viewsDefinition ); + + assert.deepEqual( + model.sanitizeStringOptionGroup( 'group1', [ 'filter1', 'filter1', 'filter2' ] ), + [ 'filter1', 'filter2' ], + 'Remove duplicate values' + ); + + assert.deepEqual( + model.sanitizeStringOptionGroup( 'group1', [ 'filter1', 'foo', 'filter2' ] ), + [ 'filter1', 'filter2' ], + 'Remove invalid values' + ); + + assert.deepEqual( + model.sanitizeStringOptionGroup( 'group1', [ 'filter1', 'all', 'filter2' ] ), + [ 'all' ], + 'If any value is "all", the only value is "all".' + ); + } ); + + QUnit.test( 'Filter interaction: subsets', function ( assert ) { + var model = new mw.rcfilters.dm.FiltersViewModel(); + + model.initializeFilters( filterDefinition, viewsDefinition ); + + // Select a filter that has subset with another filter + model.toggleFiltersSelected( { + group1__filter1: true + } ); + + model.reassessFilterInteractions( model.getItemByName( 'group1__filter1' ) ); + assert.deepEqual( + model.getFullState(), + $.extend( true, {}, baseFullFilterState, { + group1__filter1: { selected: true }, + group1__filter2: { included: true }, + group1__filter3: { included: true }, + // Conflicts are affected + group2__filter4: { conflicted: true }, + group2__filter5: { conflicted: true }, + group2__filter6: { conflicted: true } + } ), + 'Filters with subsets are represented in the model.' + ); + + // Select another filter that has a subset with the same previous filter + model.toggleFiltersSelected( { + group1__filter2: true + } ); + model.reassessFilterInteractions( model.getItemByName( 'filter2' ) ); + assert.deepEqual( + model.getFullState(), + $.extend( true, {}, baseFullFilterState, { + group1__filter1: { selected: true }, + group1__filter2: { selected: true, included: true }, + group1__filter3: { included: true }, + // Conflicts are affected + group2__filter6: { conflicted: true } + } ), + 'Filters that have multiple subsets are represented.' + ); + + // Remove one filter (but leave the other) that affects filter3 + model.toggleFiltersSelected( { + group1__filter1: false + } ); + model.reassessFilterInteractions( model.getItemByName( 'group1__filter1' ) ); + assert.deepEqual( + model.getFullState(), + $.extend( true, {}, baseFullFilterState, { + group1__filter2: { selected: true, included: false }, + group1__filter3: { included: true }, + // Conflicts are affected + group2__filter6: { conflicted: true } + } ), + 'Removing a filter only un-includes its subset if there is no other filter affecting.' + ); + + model.toggleFiltersSelected( { + group1__filter2: false + } ); + model.reassessFilterInteractions( model.getItemByName( 'group1__filter2' ) ); + assert.deepEqual( + model.getFullState(), + baseFullFilterState, + 'Removing all supersets also un-includes the subsets.' + ); + } ); + + QUnit.test( 'Filter interaction: full coverage', function ( assert ) { + var model = new mw.rcfilters.dm.FiltersViewModel(), + 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 { + group1__filter1: isCapsuleItemMuted( 'group1__filter1' ), + group1__filter2: isCapsuleItemMuted( 'group1__filter2' ), + group1__filter3: isCapsuleItemMuted( 'group1__filter3' ), + group2__filter4: isCapsuleItemMuted( 'group2__filter4' ), + group2__filter5: isCapsuleItemMuted( 'group2__filter5' ), + group2__filter6: isCapsuleItemMuted( 'group2__filter6' ) + }; + }, + baseMuteState = { + group1__filter1: false, + group1__filter2: false, + group1__filter3: false, + group2__filter4: false, + group2__filter5: false, + group2__filter6: false + }; + + model.initializeFilters( filterDefinition, viewsDefinition ); + + // Starting state, no selection, all items are non-muted + assert.deepEqual( + getCurrentItemsMutedState(), + baseMuteState, + 'No selection - all items are non-muted' + ); + + // Select most (but not all) items in each group + model.toggleFiltersSelected( { + group1__filter1: true, + group1__filter2: true, + group2__filter4: true, + group2__filter5: true + } ); + + // Both groups have multiple (but not all) items selected, all items are non-muted + assert.deepEqual( + getCurrentItemsMutedState(), + baseMuteState, + 'Not all items in the group selected - all items are non-muted' + ); + + // Select all items in 'fullCoverage' group (group2) + model.toggleFiltersSelected( { + group2__filter6: true + } ); + + // Group2 (full coverage) has all items selected, all its items are muted + assert.deepEqual( + getCurrentItemsMutedState(), + $.extend( {}, baseMuteState, { + group2__filter4: true, + group2__filter5: true, + group2__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.toggleFiltersSelected( { + group1__filter3: true + } ); + + // Group1 (full coverage) has all items selected, no items in it are muted (non full coverage) + assert.deepEqual( + getCurrentItemsMutedState(), + $.extend( {}, baseMuteState, { + group2__filter4: true, + group2__filter5: true, + group2__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.toggleFiltersSelected( { + group1__filter3: false, + group2__filter5: false + } ); + assert.deepEqual( + getCurrentItemsMutedState(), + baseMuteState, + 'Not all items in the group are checked - all items are non-muted regardless of group coverage' + ); + } ); + + QUnit.test( 'Filter interaction: conflicts', function ( assert ) { + var model = new mw.rcfilters.dm.FiltersViewModel(); + + model.initializeFilters( filterDefinition, viewsDefinition ); + + assert.deepEqual( + model.getFullState(), + baseFullFilterState, + 'Initial state: no conflicts because no selections.' + ); + + // Select a filter that has a conflict with an entire group + model.toggleFiltersSelected( { + group1__filter1: true // conflicts: entire of group 2 ( filter4, filter5, filter6) + } ); + + model.reassessFilterInteractions( model.getItemByName( 'group1__filter1' ) ); + + assert.deepEqual( + model.getFullState(), + $.extend( true, {}, baseFullFilterState, { + group1__filter1: { selected: true }, + group2__filter4: { conflicted: true }, + group2__filter5: { conflicted: true }, + group2__filter6: { conflicted: true }, + // Subsets are affected by the selection + group1__filter2: { included: true }, + group1__filter3: { included: true } + } ), + 'Selecting a filter that conflicts with a group sets all the conflicted group items as "conflicted".' + ); + + // Select one of the conflicts (both filters are now conflicted and selected) + model.toggleFiltersSelected( { + group2__filter4: true // conflicts: filter 1 + } ); + model.reassessFilterInteractions( model.getItemByName( 'group2__filter4' ) ); + + assert.deepEqual( + model.getFullState(), + $.extend( true, {}, baseFullFilterState, { + group1__filter1: { selected: true, conflicted: true }, + group2__filter4: { selected: true, conflicted: true }, + group2__filter5: { conflicted: true }, + group2__filter6: { conflicted: true }, + // Subsets are affected by the selection + group1__filter2: { included: true }, + group1__filter3: { included: true } + } ), + 'Selecting a conflicting filter inside a group, sets both sides to conflicted and selected.' + ); + + // Reset + model = new mw.rcfilters.dm.FiltersViewModel(); + model.initializeFilters( filterDefinition, viewsDefinition ); + + // Select a filter that has a conflict with a specific filter + model.toggleFiltersSelected( { + group1__filter2: true // conflicts: filter6 + } ); + model.reassessFilterInteractions( model.getItemByName( 'group1__filter2' ) ); + + assert.deepEqual( + model.getFullState(), + $.extend( true, {}, baseFullFilterState, { + group1__filter2: { selected: true }, + group2__filter6: { conflicted: true }, + // Subsets are affected by the selection + group1__filter3: { included: true } + } ), + 'Selecting a filter that conflicts with another filter sets the other as "conflicted".' + ); + + // Select the conflicting filter + model.toggleFiltersSelected( { + group2__filter6: true // conflicts: filter2 + } ); + + model.reassessFilterInteractions( model.getItemByName( 'group2__filter6' ) ); + + assert.deepEqual( + model.getFullState(), + $.extend( true, {}, baseFullFilterState, { + group1__filter2: { selected: true, conflicted: true }, + group2__filter6: { selected: true, conflicted: true }, + // This is added to the conflicts because filter6 is part of group2, + // who is in conflict with filter1; note that filter2 also conflicts + // with filter6 which means that filter1 conflicts with filter6 (because it's in group2) + // and also because its **own sibling** (filter2) is **also** in conflict with the + // selected items in group2 (filter6) + group1__filter1: { conflicted: true }, + + // Subsets are affected by the selection + group1__filter3: { included: true } + } ), + 'Selecting a conflicting filter with an individual filter, sets both sides to conflicted and selected.' + ); + + // Now choose a non-conflicting filter from the group + model.toggleFiltersSelected( { + group2__filter5: true + } ); + + model.reassessFilterInteractions( model.getItemByName( 'group2__filter5' ) ); + + assert.deepEqual( + model.getFullState(), + $.extend( true, {}, baseFullFilterState, { + group1__filter2: { selected: true }, + group2__filter6: { selected: true }, + group2__filter5: { selected: true }, + // Filter6 and filter1 are no longer in conflict because + // filter5, while it is in conflict with filter1, it is + // not in conflict with filter2 - and since filter2 is + // selected, it removes the conflict bidirectionally + + // Subsets are affected by the selection + group1__filter3: { included: true } + } ), + 'Selecting a non-conflicting filter within the group of a conflicting filter removes the conflicts.' + ); + + // Followup on the previous test, unselect filter2 so filter1 + // is now the only one selected in its own group, and since + // it is in conflict with the entire of group2, it means + // filter1 is once again conflicted + model.toggleFiltersSelected( { + group1__filter2: false + } ); + + model.reassessFilterInteractions( model.getItemByName( 'group1__filter2' ) ); + + assert.deepEqual( + model.getFullState(), + $.extend( true, {}, baseFullFilterState, { + group1__filter1: { conflicted: true }, + group2__filter6: { selected: true }, + group2__filter5: { selected: true } + } ), + 'Unselecting an item that did not conflict returns the conflict state.' + ); + + // Followup #2: Now actually select filter1, and make everything conflicted + model.toggleFiltersSelected( { + group1__filter1: true + } ); + + model.reassessFilterInteractions( model.getItemByName( 'group1__filter1' ) ); + + assert.deepEqual( + model.getFullState(), + $.extend( true, {}, baseFullFilterState, { + group1__filter1: { selected: true, conflicted: true }, + group2__filter6: { selected: true, conflicted: true }, + group2__filter5: { selected: true, conflicted: true }, + group2__filter4: { conflicted: true }, // Not selected but conflicted because it's in group2 + // Subsets are affected by the selection + group1__filter2: { included: true }, + group1__filter3: { included: true } + } ), + 'Selecting an item that conflicts with a whole group makes all selections in that group conflicted.' + ); + + /* Simple case */ + // Reset + model = new mw.rcfilters.dm.FiltersViewModel(); + model.initializeFilters( filterDefinition, viewsDefinition ); + + // Select a filter that has a conflict with a specific filter + model.toggleFiltersSelected( { + group1__filter2: true // conflicts: filter6 + } ); + + model.reassessFilterInteractions( model.getItemByName( 'group1__filter2' ) ); + + assert.deepEqual( + model.getFullState(), + $.extend( true, {}, baseFullFilterState, { + group1__filter2: { selected: true }, + group2__filter6: { conflicted: true }, + // Subsets are affected by the selection + group1__filter3: { included: true } + } ), + 'Simple case: Selecting a filter that conflicts with another filter sets the other as "conflicted".' + ); + + model.toggleFiltersSelected( { + group1__filter3: true // conflicts: filter6 + } ); + + model.reassessFilterInteractions( model.getItemByName( 'group1__filter3' ) ); + + assert.deepEqual( + model.getFullState(), + $.extend( true, {}, baseFullFilterState, { + group1__filter2: { selected: true }, + // Subsets are affected by the selection + group1__filter3: { selected: true, included: true } + } ), + 'Simple case: Selecting a filter that is not in conflict removes the conflict.' + ); + } ); + + QUnit.test( 'Filter highlights', function ( assert ) { + // We are using a different (smaller) definition here than the global one + var definition = [ { + name: 'group1', + title: 'Group 1', + type: 'string_options', + filters: [ + { name: 'filter1', cssClass: 'class1', label: '1', description: '1' }, + { name: 'filter2', cssClass: 'class2', label: '2', description: '2' }, + { name: 'filter3', cssClass: 'class3', label: '3', description: '3' }, + { name: 'filter4', cssClass: 'class4', label: '4', description: '4' }, + { name: 'filter5', cssClass: 'class5', label: '5', description: '5' }, + { name: 'filter6', label: '6', description: '6' } + ] + } ], + model = new mw.rcfilters.dm.FiltersViewModel(); + + model.initializeFilters( definition ); + + assert.ok( + !model.isHighlightEnabled(), + 'Initially, highlight is disabled.' + ); + + model.toggleHighlight( true ); + assert.ok( + model.isHighlightEnabled(), + 'Highlight is enabled on toggle.' + ); + + model.setHighlightColor( 'group1__filter1', 'color1' ); + model.setHighlightColor( 'group1__filter2', 'color2' ); + + assert.deepEqual( + model.getHighlightedItems().map( function ( item ) { + return item.getName(); + } ), + [ + 'group1__filter1', + 'group1__filter2' + ], + 'Highlighted items are highlighted.' + ); + + assert.equal( + model.getItemByName( 'group1__filter1' ).getHighlightColor(), + 'color1', + 'Item highlight color is set.' + ); + + model.setHighlightColor( 'group1__filter1', 'color1changed' ); + assert.equal( + model.getItemByName( 'group1__filter1' ).getHighlightColor(), + 'color1changed', + 'Item highlight color is changed on setHighlightColor.' + ); + + model.clearHighlightColor( 'group1__filter1' ); + assert.deepEqual( + model.getHighlightedItems().map( function ( item ) { + return item.getName(); + } ), + [ + 'group1__filter2' + ], + 'Clear highlight from an item results in the item no longer being highlighted.' + ); + + // Reset + model = new mw.rcfilters.dm.FiltersViewModel(); + model.initializeFilters( definition ); + + model.setHighlightColor( 'group1__filter1', 'color1' ); + model.setHighlightColor( 'group1__filter2', 'color2' ); + model.setHighlightColor( 'group1__filter3', 'color3' ); + + assert.deepEqual( + model.getHighlightedItems().map( function ( item ) { + return item.getName(); + } ), + [ + 'group1__filter1', + 'group1__filter2', + 'group1__filter3' + ], + 'Even if highlights are not enabled, the items remember their highlight state' + // NOTE: When actually displaying the highlights, the UI checks whether + // highlighting is generally active and then goes over the highlighted + // items. The item models, however, and the view model in general, still + // retains the knowledge about which filters have different colors, so we + // can seamlessly return to the colors the user previously chose if they + // reapply highlights. + ); + + // Reset + model = new mw.rcfilters.dm.FiltersViewModel(); + model.initializeFilters( definition ); + + model.setHighlightColor( 'group1__filter1', 'color1' ); + model.setHighlightColor( 'group1__filter6', 'color6' ); + + assert.deepEqual( + model.getHighlightedItems().map( function ( item ) { + return item.getName(); + } ), + [ + 'group1__filter1' + ], + 'Items without a specified class identifier are not highlighted.' + ); + } ); + + QUnit.test( 'emptyAllFilters', function ( assert ) { + var model = new mw.rcfilters.dm.FiltersViewModel(); + + model.initializeFilters( shortFilterDefinition, null ); + + model.toggleFiltersSelected( { + group1__filter1: true, + group2__filter4: true, // hidden + group3__filter5: true // sticky + } ); + + model.emptyAllFilters(); + + assert.deepEqual( + model.getSelectedState( true ), + { + group3__filter5: true, + group3__filter6: true + }, + 'Emptying filters does not affect sticky filters' + ); + } ); + + QUnit.test( 'areVisibleFiltersEmpty', function ( assert ) { + var model = new mw.rcfilters.dm.FiltersViewModel(); + model.initializeFilters( shortFilterDefinition, null ); + + model.emptyAllFilters(); + assert.ok( model.areVisibleFiltersEmpty() ); + + model.toggleFiltersSelected( { + group3__filter5: true // sticky + } ); + assert.ok( model.areVisibleFiltersEmpty() ); + + model.toggleFiltersSelected( { + group1__filter1: true + } ); + assert.notOk( model.areVisibleFiltersEmpty() ); + } ); +}( mediaWiki, jQuery ) ); |