diff options
Diffstat (limited to 'www/wiki/tests/qunit')
67 files changed, 15704 insertions, 0 deletions
diff --git a/www/wiki/tests/qunit/.htaccess b/www/wiki/tests/qunit/.htaccess new file mode 100644 index 00000000..605d2f4c --- /dev/null +++ b/www/wiki/tests/qunit/.htaccess @@ -0,0 +1 @@ +Allow from all diff --git a/www/wiki/tests/qunit/QUnitTestResources.php b/www/wiki/tests/qunit/QUnitTestResources.php new file mode 100644 index 00000000..785e1146 --- /dev/null +++ b/www/wiki/tests/qunit/QUnitTestResources.php @@ -0,0 +1,149 @@ +<?php + +/* Modules registered when $wgEnableJavaScriptTest is true */ + +return [ + + /* Utilities */ + + 'test.sinonjs' => [ + 'scripts' => [ + 'tests/qunit/suites/resources/test.sinonjs/index.js', + 'resources/lib/sinonjs/sinon-1.17.3.js', + // We want tests to work in IE, but can't include this as it + // will break the placeholders in Sinon because the hack it uses + // to hijack IE globals relies on running in the global scope + // and in ResourceLoader this won't be running in the global scope. + // Including it results (among other things) in sandboxed timers + // being broken due to Date inheritance being undefined. + // 'resources/lib/sinonjs/sinon-ie-1.15.4.js', + ], + 'targets' => [ 'desktop', 'mobile' ], + ], + + 'test.mediawiki.qunit.testrunner' => [ + 'scripts' => [ + 'tests/qunit/data/testrunner.js', + ], + 'dependencies' => [ + // Test runner configures QUnit but can't have it as dependency, + // see SpecialJavaScriptTest::viewQUnit. + 'jquery.getAttrs', + 'mediawiki.page.ready', + 'mediawiki.page.startup', + 'test.sinonjs', + ], + 'targets' => [ 'desktop', 'mobile' ], + ], + + /* + Test suites for MediaWiki core modules + These must have a dependency on test.mediawiki.qunit.testrunner! + */ + + 'test.mediawiki.qunit.suites' => [ + 'scripts' => [ + 'tests/qunit/suites/resources/startup.test.js', + 'tests/qunit/suites/resources/jquery/jquery.accessKeyLabel.test.js', + 'tests/qunit/suites/resources/jquery/jquery.color.test.js', + 'tests/qunit/suites/resources/jquery/jquery.colorUtil.test.js', + 'tests/qunit/suites/resources/jquery/jquery.getAttrs.test.js', + 'tests/qunit/suites/resources/jquery/jquery.hidpi.test.js', + 'tests/qunit/suites/resources/jquery/jquery.highlightText.test.js', + 'tests/qunit/suites/resources/jquery/jquery.lengthLimit.test.js', + 'tests/qunit/suites/resources/jquery/jquery.localize.test.js', + 'tests/qunit/suites/resources/jquery/jquery.makeCollapsible.test.js', + 'tests/qunit/suites/resources/jquery/jquery.tabIndex.test.js', + 'tests/qunit/suites/resources/jquery/jquery.tablesorter.test.js', + 'tests/qunit/suites/resources/jquery/jquery.tablesorter.parsers.test.js', + 'tests/qunit/suites/resources/jquery/jquery.textSelection.test.js', + 'tests/qunit/data/mediawiki.jqueryMsg.data.js', + 'tests/qunit/suites/resources/mediawiki/mediawiki.requestIdleCallback.test.js', + 'tests/qunit/suites/resources/mediawiki/mediawiki.errorLogger.test.js', + 'tests/qunit/suites/resources/mediawiki/mediawiki.jqueryMsg.test.js', + 'tests/qunit/suites/resources/mediawiki/mediawiki.jscompat.test.js', + 'tests/qunit/suites/resources/mediawiki/mediawiki.messagePoster.factory.test.js', + 'tests/qunit/suites/resources/mediawiki/mediawiki.RegExp.test.js', + 'tests/qunit/suites/resources/mediawiki/mediawiki.String.byteLength.test.js', + 'tests/qunit/suites/resources/mediawiki/mediawiki.String.trimByteLength.test.js', + 'tests/qunit/suites/resources/mediawiki/mediawiki.storage.test.js', + 'tests/qunit/suites/resources/mediawiki/mediawiki.template.test.js', + 'tests/qunit/suites/resources/mediawiki/mediawiki.template.mustache.test.js', + 'tests/qunit/suites/resources/mediawiki/mediawiki.test.js', + 'tests/qunit/suites/resources/mediawiki/mediawiki.loader.test.js', + 'tests/qunit/suites/resources/mediawiki/mediawiki.html.test.js', + 'tests/qunit/suites/resources/mediawiki/mediawiki.inspect.test.js', + 'tests/qunit/suites/resources/mediawiki/mediawiki.Title.test.js', + 'tests/qunit/suites/resources/mediawiki/mediawiki.toc.test.js', + 'tests/qunit/suites/resources/mediawiki/mediawiki.track.test.js', + 'tests/qunit/suites/resources/mediawiki/mediawiki.Uri.test.js', + 'tests/qunit/suites/resources/mediawiki/mediawiki.user.test.js', + 'tests/qunit/suites/resources/mediawiki/mediawiki.util.test.js', + 'tests/qunit/suites/resources/mediawiki/mediawiki.viewport.test.js', + 'tests/qunit/suites/resources/mediawiki.api/mediawiki.api.test.js', + 'tests/qunit/suites/resources/mediawiki.api/mediawiki.api.category.test.js', + 'tests/qunit/suites/resources/mediawiki.api/mediawiki.api.edit.test.js', + 'tests/qunit/suites/resources/mediawiki.api/mediawiki.api.messages.test.js', + 'tests/qunit/suites/resources/mediawiki.api/mediawiki.api.options.test.js', + 'tests/qunit/suites/resources/mediawiki.api/mediawiki.api.parse.test.js', + 'tests/qunit/suites/resources/mediawiki.api/mediawiki.api.upload.test.js', + 'tests/qunit/suites/resources/mediawiki.api/mediawiki.api.watch.test.js', + 'tests/qunit/suites/resources/mediawiki.api/mediawiki.ForeignApi.test.js', + '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', + 'tests/qunit/suites/resources/mediawiki/mediawiki.cookie.test.js', + 'tests/qunit/suites/resources/mediawiki/mediawiki.experiments.test.js', + 'tests/qunit/suites/resources/mediawiki/mediawiki.visibleTimeout.test.js', + ], + 'dependencies' => [ + 'jquery.accessKeyLabel', + 'jquery.color', + 'jquery.colorUtil', + 'jquery.getAttrs', + 'jquery.hidpi', + 'jquery.highlightText', + 'jquery.lengthLimit', + 'jquery.localize', + 'jquery.makeCollapsible', + 'jquery.tabIndex', + 'jquery.tablesorter', + 'jquery.textSelection', + 'mediawiki.api', + 'mediawiki.api.category', + 'mediawiki.api.messages', + 'mediawiki.api.options', + 'mediawiki.api.parse', + 'mediawiki.api.upload', + 'mediawiki.api.watch', + 'mediawiki.ForeignApi.core', + 'mediawiki.jqueryMsg', + 'mediawiki.messagePoster', + 'mediawiki.RegExp', + 'mediawiki.String', + 'mediawiki.storage', + 'mediawiki.Title', + 'mediawiki.toc', + 'mediawiki.Uri', + 'mediawiki.user', + 'mediawiki.template.mustache', + 'mediawiki.template', + 'mediawiki.util', + 'mediawiki.viewport', + 'mediawiki.special.recentchanges', + 'mediawiki.rcfilters.filters.dm', + 'mediawiki.language', + 'mediawiki.cldr', + 'mediawiki.cookie', + 'mediawiki.experiments', + 'mediawiki.inspect', + 'mediawiki.visibleTimeout', + 'test.mediawiki.qunit.testrunner', + ], + ] +]; diff --git a/www/wiki/tests/qunit/data/defineCallMwLoaderTestCallback.js b/www/wiki/tests/qunit/data/defineCallMwLoaderTestCallback.js new file mode 100644 index 00000000..641071a2 --- /dev/null +++ b/www/wiki/tests/qunit/data/defineCallMwLoaderTestCallback.js @@ -0,0 +1 @@ +module.exports = 'Defined.'; diff --git a/www/wiki/tests/qunit/data/generateJqueryMsgData.php b/www/wiki/tests/qunit/data/generateJqueryMsgData.php new file mode 100644 index 00000000..e4f87f81 --- /dev/null +++ b/www/wiki/tests/qunit/data/generateJqueryMsgData.php @@ -0,0 +1,148 @@ +<?php +/** + * This PHP script defines the spec that the mediawiki.jqueryMsg module should conform to. + * + * It does this by looking up the results of various kinds of string parsing, with various + * languages, in the current installation of MediaWiki. It then outputs a static specification, + * mapping expected inputs to outputs, which can be used fed into a unit test framework. + * (QUnit, Jasmine, anything, it just outputs an object with key/value pairs). + * + * This is similar to Michael Dale (mdale@mediawiki.org)'s parser tests, except that it doesn't + * look up the API results while doing the test, so the test run is much faster (at the cost + * of being out of date in rare circumstances. But mostly the parsing that we are doing in + * Javascript doesn't change much). + */ + +/* + * @example QUnit + * <code> + QUnit.test( 'Output matches PHP parser', function ( assert ) { + mw.messages.set( mw.libs.phpParserData.messages ); + $.each( mw.libs.phpParserData.tests, function ( i, test ) { + QUnit.stop(); + getMwLanguage( test.lang, function ( langClass ) { + var parser = new mw.jqueryMsg.Parser( { language: langClass } ); + assert.equal( + parser.parse( test.key, test.args ).html(), + test.result, + test.name + ); + QUnit.start(); + } ); + } ); + }); + * </code> + * + * @example Jasmine + * <code> + describe( 'match output to output from PHP parser', function () { + mw.messages.set( mw.libs.phpParserData.messages ); + $.each( mw.libs.phpParserData.tests, function ( i, test ) { + it( 'should parse ' + test.name, function () { + var langClass; + runs( function () { + getMwLanguage( test.lang, function ( gotIt ) { + langClass = gotIt; + }); + }); + waitsFor( function () { + return langClass !== undefined; + }, 'Language class should be loaded', 1000 ); + runs( function () { + console.log( test.lang, 'running tests' ); + var parser = new mw.jqueryMsg.Parser( { language: langClass } ); + expect( + parser.parse( test.key, test.args ).html() + ).toEqual( test.result ); + } ); + } ); + } ); + } ); + * </code> + */ + +require __DIR__ . '/../../../maintenance/Maintenance.php'; + +class GenerateJqueryMsgData extends Maintenance { + + public static $keyToTestArgs = [ + 'undelete_short' => [ + [ 0 ], + [ 1 ], + [ 2 ], + [ 5 ], + [ 21 ], + [ 101 ] + ], + 'category-subcat-count' => [ + [ 0, 10 ], + [ 1, 1 ], + [ 1, 2 ], + [ 3, 30 ] + ] + ]; + + public function __construct() { + parent::__construct(); + $this->mDescription = 'Create a specification for message parsing ini JSON format'; + // add any other options here + } + + public function execute() { + list( $messages, $tests ) = $this->getMessagesAndTests(); + $this->writeJavascriptFile( $messages, $tests, __DIR__ . '/mediawiki.jqueryMsg.data.js' ); + } + + private function getMessagesAndTests() { + $messages = []; + $tests = []; + foreach ( [ 'en', 'fr', 'ar', 'jp', 'zh' ] as $languageCode ) { + foreach ( self::$keyToTestArgs as $key => $testArgs ) { + foreach ( $testArgs as $args ) { + // Get the raw message, without any transformations. + $template = wfMessage( $key )->inLanguage( $languageCode )->plain(); + + // Get the magic-parsed version with args. + $result = wfMessage( $key, $args )->inLanguage( $languageCode )->text(); + + // Record the template, args, language, and expected result + // fake multiple languages by flattening them together. + $langKey = $languageCode . '_' . $key; + $messages[$langKey] = $template; + $tests[] = [ + 'name' => $languageCode . ' ' . $key . ' ' . implode( ',', $args ), + 'key' => $langKey, + 'args' => $args, + 'result' => $result, + 'lang' => $languageCode + ]; + } + } + } + return [ $messages, $tests ]; + } + + private function writeJavascriptFile( $messages, $tests, $dataSpecFile ) { + $phpParserData = [ + 'messages' => $messages, + 'tests' => $tests, + ]; + + $output = + "// This file stores the output from the PHP parser for various messages, arguments,\n" + . "// languages, and parser modes. Intended for use by a unit test framework by looping\n" + . "// through the object and comparing its parser return value with the 'result' property.\n" + . '// Last generated with ' . basename( __FILE__ ) . ' at ' . gmdate( 'r' ) . "\n" + . "/* eslint-disable */\n" + . "\n" + . 'mediaWiki.libs.phpParserData = ' . FormatJson::encode( $phpParserData, true ) . ";\n"; + + $fp = file_put_contents( $dataSpecFile, $output ); + if ( $fp === false ) { + die( "Couldn't write to $dataSpecFile." ); + } + } +} + +$maintClass = "GenerateJqueryMsgData"; +require_once RUN_MAINTENANCE_IF_MAIN; diff --git a/www/wiki/tests/qunit/data/load.mock.php b/www/wiki/tests/qunit/data/load.mock.php new file mode 100644 index 00000000..23009498 --- /dev/null +++ b/www/wiki/tests/qunit/data/load.mock.php @@ -0,0 +1,107 @@ +<?php +/** + * Mock load.php with pre-defined test modules. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + * @package MediaWiki + * @author Lupo + * @since 1.20 + */ +header( 'Content-Type: text/javascript; charset=utf-8' ); + +$moduleImplementations = [ + 'testUsesMissing' => " +mw.loader.implement( 'testUsesMissing', function () { + mw.loader.testFail( 'Module usesMissing script should not run.' ); +}, {}, {}); +", + + 'testUsesNestedMissing' => " +mw.loader.implement( 'testUsesNestedMissing', function () { + mw.loader.testFail('Module testUsesNestedMissing script should not run.' ); +}, {}, {}); +", + + 'testSkipped' => " +mw.loader.implement( 'testSkipped', function () { + mw.loader.testFail( false, 'Module testSkipped was supposed to be skipped.' ); +}, {}, {}); +", + + 'testNotSkipped' => " +mw.loader.implement( 'testNotSkipped', function () {}, {}, {}); +", + + 'testUsesSkippable' => " +mw.loader.implement( 'testUsesSkippable', function () {}, {}, {}); +", + + 'testUrlInc' => " +mw.loader.implement( 'testUrlInc', function () {} ); +", + 'testUrlInc.a' => " +mw.loader.implement( 'testUrlInc.a', function () {} ); +", + 'testUrlInc.b' => " +mw.loader.implement( 'testUrlInc.b', function () {} ); +", + 'testUrlOrder' => " +mw.loader.implement( 'testUrlOrder', function () {} ); +", + 'testUrlOrder.a' => " +mw.loader.implement( 'testUrlOrder.a', function () {} ); +", + 'testUrlOrder.b' => " +mw.loader.implement( 'testUrlOrder.b', function () {} ); +", +]; + +$response = ''; + +// Does not support the full behaviour of ResourceLoaderContext::expandModuleNames(), +// Only supports dotless module names joined by comma, +// with the exception of the hardcoded cases for testUrl*. +if ( isset( $_GET['modules'] ) ) { + if ( $_GET['modules'] === 'testUrlInc,testUrlIncDump|testUrlInc.a,b' ) { + $modules = [ 'testUrlInc', 'testUrlIncDump', 'testUrlInc.a', 'testUrlInc.b' ]; + } elseif ( $_GET['modules'] === 'testUrlOrder,testUrlOrderDump|testUrlOrder.a,b' ) { + $modules = [ 'testUrlOrder', 'testUrlOrderDump', 'testUrlOrder.a', 'testUrlOrder.b' ]; + } else { + $modules = explode( ',', $_GET['modules'] ); + } + foreach ( $modules as $module ) { + if ( isset( $moduleImplementations[$module] ) ) { + $response .= $moduleImplementations[$module]; + } elseif ( preg_match( '/^test.*Dump$/', $module ) === 1 ) { + $queryModules = $_GET['modules']; + $queryVersion = isset( $_GET['version'] ) ? strval( $_GET['version'] ) : null; + $response .= 'mw.loader.implement( ' . json_encode( $module ) + . ', function ( $, jQuery, require, module ) {' + . 'module.exports.query = { ' + . 'modules: ' . json_encode( $queryModules ) . ',' + . 'version: ' . json_encode( $queryVersion ) + . ' };' + . '} );'; + } else { + // Default + $response .= 'mw.loader.state(' . json_encode( $module ) . ', "missing" );' . "\n"; + } + } +} + +echo $response; diff --git a/www/wiki/tests/qunit/data/mediawiki.jqueryMsg.data.js b/www/wiki/tests/qunit/data/mediawiki.jqueryMsg.data.js new file mode 100644 index 00000000..90dc1b28 --- /dev/null +++ b/www/wiki/tests/qunit/data/mediawiki.jqueryMsg.data.js @@ -0,0 +1,492 @@ +// This file stores the output from the PHP parser for various messages, arguments, +// languages, and parser modes. Intended for use by a unit test framework by looping +// through the object and comparing its parser return value with the 'result' property. +// Last generated with generateJqueryMsgData.php at Fri, 10 Jul 2015 11:44:08 +0000 +/* eslint-disable */ + +mediaWiki.libs.phpParserData = { + "messages": { + "en_undelete_short": "Undelete {{PLURAL:$1|one edit|$1 edits}}", + "en_category-subcat-count": "{{PLURAL:$2|This category has only the following subcategory.|This category has the following {{PLURAL:$1|subcategory|$1 subcategories}}, out of $2 total.}}", + "fr_undelete_short": "Restaurer $1 modification{{PLURAL:$1||s}}", + "fr_category-subcat-count": "Cette cat\u00e9gorie comprend {{PLURAL:$2|la sous-cat\u00e9gorie|$2 sous-cat\u00e9gories, dont {{PLURAL:$1|celle|les $1}}}} ci-dessous.", + "ar_undelete_short": "\u0627\u0633\u062a\u0631\u062c\u0627\u0639 {{PLURAL:$1||\u062a\u0639\u062f\u064a\u0644 \u0648\u0627\u062d\u062f|\u062a\u0639\u062f\u064a\u0644\u064a\u0646|$1 \u062a\u0639\u062f\u064a\u0644\u0627\u062a|$1 \u062a\u0639\u062f\u064a\u0644\u0627\u064b|$1 \u062a\u0639\u062f\u064a\u0644}}", + "ar_category-subcat-count": "{{PLURAL:$2|\u0647\u0630\u0627 \u0627\u0644\u062a\u0635\u0646\u064a\u0641 \u064a\u062d\u0648\u064a \u0627\u0644\u062a\u0635\u0646\u064a\u0641 \u0627\u0644\u0641\u0631\u0639\u064a \u0627\u0644\u062a\u0627\u0644\u064a|\u0647\u0630\u0627 \u0627\u0644\u062a\u0635\u0646\u064a\u0641 \u064a\u062d\u0648\u064a {{PLURAL:$1||\u0627\u0644\u062a\u0635\u0646\u064a\u0641 \u0627\u0644\u0641\u0631\u0639\u064a|\u062a\u0635\u0646\u064a\u0641\u064a\u0646 \u0641\u0631\u0639\u064a\u064a\u0646|$1 \u062a\u0635\u0646\u064a\u0641\u0627\u062a \u0641\u0631\u0639\u064a\u0629}}\u060c \u0645\u0646 \u0625\u062c\u0645\u0627\u0644\u064a $2.}}", + "jp_undelete_short": "Undelete {{PLURAL:$1|one edit|$1 edits}}", + "jp_category-subcat-count": "{{PLURAL:$2|This category has only the following subcategory.|This category has the following {{PLURAL:$1|subcategory|$1 subcategories}}, out of $2 total.}}", + "zh_undelete_short": "\u8fd8\u539f{{PLURAL:$1|$1\u4e2a\u7f16\u8f91}}", + "zh_category-subcat-count": "{{PLURAL:$2|\u672c\u5206\u7c7b\u53ea\u6709\u4ee5\u4e0b\u5b50\u5206\u7c7b\u3002|\u672c\u5206\u7c7b\u6709\u4ee5\u4e0b$1\u4e2a\u5b50\u5206\u7c7b\uff0c\u5171\u6709$2\u4e2a\u5b50\u5206\u7c7b\u3002}}" + }, + "tests": [ + { + "name": "en undelete_short 0", + "key": "en_undelete_short", + "args": [ + 0 + ], + "result": "Undelete 0 edits", + "lang": "en" + }, + { + "name": "en undelete_short 1", + "key": "en_undelete_short", + "args": [ + 1 + ], + "result": "Undelete one edit", + "lang": "en" + }, + { + "name": "en undelete_short 2", + "key": "en_undelete_short", + "args": [ + 2 + ], + "result": "Undelete 2 edits", + "lang": "en" + }, + { + "name": "en undelete_short 5", + "key": "en_undelete_short", + "args": [ + 5 + ], + "result": "Undelete 5 edits", + "lang": "en" + }, + { + "name": "en undelete_short 21", + "key": "en_undelete_short", + "args": [ + 21 + ], + "result": "Undelete 21 edits", + "lang": "en" + }, + { + "name": "en undelete_short 101", + "key": "en_undelete_short", + "args": [ + 101 + ], + "result": "Undelete 101 edits", + "lang": "en" + }, + { + "name": "en category-subcat-count 0,10", + "key": "en_category-subcat-count", + "args": [ + 0, + 10 + ], + "result": "This category has the following 0 subcategories, out of 10 total.", + "lang": "en" + }, + { + "name": "en category-subcat-count 1,1", + "key": "en_category-subcat-count", + "args": [ + 1, + 1 + ], + "result": "This category has only the following subcategory.", + "lang": "en" + }, + { + "name": "en category-subcat-count 1,2", + "key": "en_category-subcat-count", + "args": [ + 1, + 2 + ], + "result": "This category has the following subcategory, out of 2 total.", + "lang": "en" + }, + { + "name": "en category-subcat-count 3,30", + "key": "en_category-subcat-count", + "args": [ + 3, + 30 + ], + "result": "This category has the following 3 subcategories, out of 30 total.", + "lang": "en" + }, + { + "name": "fr undelete_short 0", + "key": "fr_undelete_short", + "args": [ + 0 + ], + "result": "Restaurer 0 modification", + "lang": "fr" + }, + { + "name": "fr undelete_short 1", + "key": "fr_undelete_short", + "args": [ + 1 + ], + "result": "Restaurer 1 modification", + "lang": "fr" + }, + { + "name": "fr undelete_short 2", + "key": "fr_undelete_short", + "args": [ + 2 + ], + "result": "Restaurer 2 modifications", + "lang": "fr" + }, + { + "name": "fr undelete_short 5", + "key": "fr_undelete_short", + "args": [ + 5 + ], + "result": "Restaurer 5 modifications", + "lang": "fr" + }, + { + "name": "fr undelete_short 21", + "key": "fr_undelete_short", + "args": [ + 21 + ], + "result": "Restaurer 21 modifications", + "lang": "fr" + }, + { + "name": "fr undelete_short 101", + "key": "fr_undelete_short", + "args": [ + 101 + ], + "result": "Restaurer 101 modifications", + "lang": "fr" + }, + { + "name": "fr category-subcat-count 0,10", + "key": "fr_category-subcat-count", + "args": [ + 0, + 10 + ], + "result": "Cette cat\u00e9gorie comprend 10 sous-cat\u00e9gories, dont celle ci-dessous.", + "lang": "fr" + }, + { + "name": "fr category-subcat-count 1,1", + "key": "fr_category-subcat-count", + "args": [ + 1, + 1 + ], + "result": "Cette cat\u00e9gorie comprend la sous-cat\u00e9gorie ci-dessous.", + "lang": "fr" + }, + { + "name": "fr category-subcat-count 1,2", + "key": "fr_category-subcat-count", + "args": [ + 1, + 2 + ], + "result": "Cette cat\u00e9gorie comprend 2 sous-cat\u00e9gories, dont celle ci-dessous.", + "lang": "fr" + }, + { + "name": "fr category-subcat-count 3,30", + "key": "fr_category-subcat-count", + "args": [ + 3, + 30 + ], + "result": "Cette cat\u00e9gorie comprend 30 sous-cat\u00e9gories, dont les 3 ci-dessous.", + "lang": "fr" + }, + { + "name": "ar undelete_short 0", + "key": "ar_undelete_short", + "args": [ + 0 + ], + "result": "\u0627\u0633\u062a\u0631\u062c\u0627\u0639 ", + "lang": "ar" + }, + { + "name": "ar undelete_short 1", + "key": "ar_undelete_short", + "args": [ + 1 + ], + "result": "\u0627\u0633\u062a\u0631\u062c\u0627\u0639 \u062a\u0639\u062f\u064a\u0644 \u0648\u0627\u062d\u062f", + "lang": "ar" + }, + { + "name": "ar undelete_short 2", + "key": "ar_undelete_short", + "args": [ + 2 + ], + "result": "\u0627\u0633\u062a\u0631\u062c\u0627\u0639 \u062a\u0639\u062f\u064a\u0644\u064a\u0646", + "lang": "ar" + }, + { + "name": "ar undelete_short 5", + "key": "ar_undelete_short", + "args": [ + 5 + ], + "result": "\u0627\u0633\u062a\u0631\u062c\u0627\u0639 5 \u062a\u0639\u062f\u064a\u0644\u0627\u062a", + "lang": "ar" + }, + { + "name": "ar undelete_short 21", + "key": "ar_undelete_short", + "args": [ + 21 + ], + "result": "\u0627\u0633\u062a\u0631\u062c\u0627\u0639 21 \u062a\u0639\u062f\u064a\u0644\u0627\u064b", + "lang": "ar" + }, + { + "name": "ar undelete_short 101", + "key": "ar_undelete_short", + "args": [ + 101 + ], + "result": "\u0627\u0633\u062a\u0631\u062c\u0627\u0639 101 \u062a\u0639\u062f\u064a\u0644", + "lang": "ar" + }, + { + "name": "ar category-subcat-count 0,10", + "key": "ar_category-subcat-count", + "args": [ + 0, + 10 + ], + "result": "\u0647\u0630\u0627 \u0627\u0644\u062a\u0635\u0646\u064a\u0641 \u064a\u062d\u0648\u064a \u060c \u0645\u0646 \u0625\u062c\u0645\u0627\u0644\u064a 10.", + "lang": "ar" + }, + { + "name": "ar category-subcat-count 1,1", + "key": "ar_category-subcat-count", + "args": [ + 1, + 1 + ], + "result": "\u0647\u0630\u0627 \u0627\u0644\u062a\u0635\u0646\u064a\u0641 \u064a\u062d\u0648\u064a \u0627\u0644\u062a\u0635\u0646\u064a\u0641 \u0627\u0644\u0641\u0631\u0639\u064a\u060c \u0645\u0646 \u0625\u062c\u0645\u0627\u0644\u064a 1.", + "lang": "ar" + }, + { + "name": "ar category-subcat-count 1,2", + "key": "ar_category-subcat-count", + "args": [ + 1, + 2 + ], + "result": "\u0647\u0630\u0627 \u0627\u0644\u062a\u0635\u0646\u064a\u0641 \u064a\u062d\u0648\u064a \u0627\u0644\u062a\u0635\u0646\u064a\u0641 \u0627\u0644\u0641\u0631\u0639\u064a\u060c \u0645\u0646 \u0625\u062c\u0645\u0627\u0644\u064a 2.", + "lang": "ar" + }, + { + "name": "ar category-subcat-count 3,30", + "key": "ar_category-subcat-count", + "args": [ + 3, + 30 + ], + "result": "\u0647\u0630\u0627 \u0627\u0644\u062a\u0635\u0646\u064a\u0641 \u064a\u062d\u0648\u064a 3 \u062a\u0635\u0646\u064a\u0641\u0627\u062a \u0641\u0631\u0639\u064a\u0629\u060c \u0645\u0646 \u0625\u062c\u0645\u0627\u0644\u064a 30.", + "lang": "ar" + }, + { + "name": "jp undelete_short 0", + "key": "jp_undelete_short", + "args": [ + 0 + ], + "result": "Undelete 0 edits", + "lang": "jp" + }, + { + "name": "jp undelete_short 1", + "key": "jp_undelete_short", + "args": [ + 1 + ], + "result": "Undelete one edit", + "lang": "jp" + }, + { + "name": "jp undelete_short 2", + "key": "jp_undelete_short", + "args": [ + 2 + ], + "result": "Undelete 2 edits", + "lang": "jp" + }, + { + "name": "jp undelete_short 5", + "key": "jp_undelete_short", + "args": [ + 5 + ], + "result": "Undelete 5 edits", + "lang": "jp" + }, + { + "name": "jp undelete_short 21", + "key": "jp_undelete_short", + "args": [ + 21 + ], + "result": "Undelete 21 edits", + "lang": "jp" + }, + { + "name": "jp undelete_short 101", + "key": "jp_undelete_short", + "args": [ + 101 + ], + "result": "Undelete 101 edits", + "lang": "jp" + }, + { + "name": "jp category-subcat-count 0,10", + "key": "jp_category-subcat-count", + "args": [ + 0, + 10 + ], + "result": "This category has the following 0 subcategories, out of 10 total.", + "lang": "jp" + }, + { + "name": "jp category-subcat-count 1,1", + "key": "jp_category-subcat-count", + "args": [ + 1, + 1 + ], + "result": "This category has only the following subcategory.", + "lang": "jp" + }, + { + "name": "jp category-subcat-count 1,2", + "key": "jp_category-subcat-count", + "args": [ + 1, + 2 + ], + "result": "This category has the following subcategory, out of 2 total.", + "lang": "jp" + }, + { + "name": "jp category-subcat-count 3,30", + "key": "jp_category-subcat-count", + "args": [ + 3, + 30 + ], + "result": "This category has the following 3 subcategories, out of 30 total.", + "lang": "jp" + }, + { + "name": "zh undelete_short 0", + "key": "zh_undelete_short", + "args": [ + 0 + ], + "result": "\u8fd8\u539f0\u4e2a\u7f16\u8f91", + "lang": "zh" + }, + { + "name": "zh undelete_short 1", + "key": "zh_undelete_short", + "args": [ + 1 + ], + "result": "\u8fd8\u539f1\u4e2a\u7f16\u8f91", + "lang": "zh" + }, + { + "name": "zh undelete_short 2", + "key": "zh_undelete_short", + "args": [ + 2 + ], + "result": "\u8fd8\u539f2\u4e2a\u7f16\u8f91", + "lang": "zh" + }, + { + "name": "zh undelete_short 5", + "key": "zh_undelete_short", + "args": [ + 5 + ], + "result": "\u8fd8\u539f5\u4e2a\u7f16\u8f91", + "lang": "zh" + }, + { + "name": "zh undelete_short 21", + "key": "zh_undelete_short", + "args": [ + 21 + ], + "result": "\u8fd8\u539f21\u4e2a\u7f16\u8f91", + "lang": "zh" + }, + { + "name": "zh undelete_short 101", + "key": "zh_undelete_short", + "args": [ + 101 + ], + "result": "\u8fd8\u539f101\u4e2a\u7f16\u8f91", + "lang": "zh" + }, + { + "name": "zh category-subcat-count 0,10", + "key": "zh_category-subcat-count", + "args": [ + 0, + 10 + ], + "result": "\u672c\u5206\u7c7b\u6709\u4ee5\u4e0b0\u4e2a\u5b50\u5206\u7c7b\uff0c\u5171\u670910\u4e2a\u5b50\u5206\u7c7b\u3002", + "lang": "zh" + }, + { + "name": "zh category-subcat-count 1,1", + "key": "zh_category-subcat-count", + "args": [ + 1, + 1 + ], + "result": "\u672c\u5206\u7c7b\u53ea\u6709\u4ee5\u4e0b\u5b50\u5206\u7c7b\u3002", + "lang": "zh" + }, + { + "name": "zh category-subcat-count 1,2", + "key": "zh_category-subcat-count", + "args": [ + 1, + 2 + ], + "result": "\u672c\u5206\u7c7b\u6709\u4ee5\u4e0b1\u4e2a\u5b50\u5206\u7c7b\uff0c\u5171\u67092\u4e2a\u5b50\u5206\u7c7b\u3002", + "lang": "zh" + }, + { + "name": "zh category-subcat-count 3,30", + "key": "zh_category-subcat-count", + "args": [ + 3, + 30 + ], + "result": "\u672c\u5206\u7c7b\u6709\u4ee5\u4e0b3\u4e2a\u5b50\u5206\u7c7b\uff0c\u5171\u670930\u4e2a\u5b50\u5206\u7c7b\u3002", + "lang": "zh" + } + ] +}; diff --git a/www/wiki/tests/qunit/data/mwLoaderTestCallback.js b/www/wiki/tests/qunit/data/mwLoaderTestCallback.js new file mode 100644 index 00000000..dd034115 --- /dev/null +++ b/www/wiki/tests/qunit/data/mwLoaderTestCallback.js @@ -0,0 +1 @@ +mediaWiki.loader.testCallback(); diff --git a/www/wiki/tests/qunit/data/requireCallMwLoaderTestCallback.js b/www/wiki/tests/qunit/data/requireCallMwLoaderTestCallback.js new file mode 100644 index 00000000..815a3b48 --- /dev/null +++ b/www/wiki/tests/qunit/data/requireCallMwLoaderTestCallback.js @@ -0,0 +1,6 @@ +module.exports = { + immediate: require( 'test.require.define' ), + later: function () { + return require( 'test.require.define' ); + } +}; diff --git a/www/wiki/tests/qunit/data/styleTest.css.php b/www/wiki/tests/qunit/data/styleTest.css.php new file mode 100644 index 00000000..0e845811 --- /dev/null +++ b/www/wiki/tests/qunit/data/styleTest.css.php @@ -0,0 +1,61 @@ +<?php +/** + * Dynamically create a simple stylesheet for unit tests in MediaWiki. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + * @package MediaWiki + * @author Timo Tijhof + * @since 1.20 + */ +header( 'Content-Type: text/css; charset=utf-8' ); + +/** + * Allows characters in ranges [a-z], [A-Z] and [0-9], + * in addition to a dot ("."), dash ("-"), space (" ") and hash ("#"). + * @since 1.20 + * + * @param string $val + * @return string Value with any illegal characters removed. + */ +function cssfilter( $val ) { + return preg_replace( '/[^A-Za-z0-9\.\- #]/', '', $val ); +} + +// Do basic sanitization +$params = array_map( 'cssfilter', $_GET ); + +// Defaults +$selector = isset( $params['selector'] ) ? $params['selector'] : '.mw-test-example'; +$property = isset( $params['prop'] ) ? $params['prop'] : 'float'; +$value = isset( $params['val'] ) ? $params['val'] : 'right'; +$wait = isset( $params['wait'] ) ? (int)$params['wait'] : 0; // seconds + +sleep( $wait ); + +$css = " +/** + * Generated " . gmdate( 'r' ) . ". + * Waited {$wait}s. + */ + +$selector { + $property: $value; +} +"; + +echo trim( $css ) . "\n"; diff --git a/www/wiki/tests/qunit/data/testrunner.js b/www/wiki/tests/qunit/data/testrunner.js new file mode 100644 index 00000000..06c146c2 --- /dev/null +++ b/www/wiki/tests/qunit/data/testrunner.js @@ -0,0 +1,652 @@ +/* global sinon */ +( function ( $, mw, QUnit ) { + 'use strict'; + + var addons, nested; + + /** + * Make a safe copy of localEnv: + * - Creates a new object that inherits, instead of modifying the original. + * This prevents recursion in the event that a test suite stores inherits + * hooks object statically and passes it to multiple QUnit.module() calls. + * - Supporting QUnit 1.x 'setup' and 'teardown' hooks + * (deprecated in QUnit 1.16, removed in QUnit 2). + */ + function makeSafeEnv( localEnv ) { + var wrap = localEnv ? Object.create( localEnv ) : {}; + if ( wrap.setup ) { + wrap.beforeEach = wrap.beforeEach || wrap.setup; + } + if ( wrap.teardown ) { + wrap.afterEach = wrap.afterEach || wrap.teardown; + } + return wrap; + } + + /** + * Add bogus to url to prevent IE crazy caching + * + * @param {string} value a relative path (eg. 'data/foo.js' + * or 'data/test.php?foo=bar'). + * @return {string} Such as 'data/foo.js?131031765087663960' + */ + QUnit.fixurl = function ( value ) { + return value + ( /\?/.test( value ) ? '&' : '?' ) + + String( new Date().getTime() ) + + String( parseInt( Math.random() * 100000, 10 ) ); + }; + + /** + * Configuration + */ + + // For each test() that is asynchronous, allow this time to pass before + // killing the test and assuming timeout failure. + QUnit.config.testTimeout = 60 * 1000; + + // Reduce default animation duration from 400ms to 0ms for unit tests + // eslint-disable-next-line no-underscore-dangle + $.fx.speeds._default = 0; + + // Add a checkbox to QUnit header to toggle MediaWiki ResourceLoader debug mode. + QUnit.config.urlConfig.push( { + id: 'debug', + label: 'Enable ResourceLoaderDebug', + tooltip: 'Enable debug mode in ResourceLoader', + value: 'true' + } ); + + /** + * SinonJS + * + * Glue code for nicer integration with QUnit setup/teardown + * Inspired by http://sinonjs.org/releases/sinon-qunit-1.0.0.js + */ + sinon.assert.fail = function ( msg ) { + QUnit.assert.ok( false, msg ); + }; + sinon.assert.pass = function ( msg ) { + QUnit.assert.ok( true, msg ); + }; + sinon.config = { + injectIntoThis: true, + injectInto: null, + properties: [ 'spy', 'stub', 'mock', 'sandbox' ], + // Don't fake timers by default + useFakeTimers: false, + useFakeServer: false + }; + // Extend QUnit.module with: + // - Add support for QUnit 1.x 'setup' and 'teardown' hooks + // - Add a Sinon sandbox to the test context. + // - Add a test fixture to the test context. + ( function () { + var orgModule = QUnit.module; + QUnit.module = function ( name, localEnv, executeNow ) { + var orgExecute, orgBeforeEach, orgAfterEach; + if ( nested ) { + // In a nested module, don't re-add our hooks, QUnit does that already. + return orgModule.apply( this, arguments ); + } + if ( arguments.length === 2 && typeof localEnv === 'function' ) { + executeNow = localEnv; + localEnv = undefined; + } + if ( executeNow ) { + // Wrap executeNow() so that we can detect nested modules + orgExecute = executeNow; + executeNow = function () { + var ret; + nested = true; + ret = orgExecute.apply( this, arguments ); + nested = false; + return ret; + }; + } + + localEnv = makeSafeEnv( localEnv ); + orgBeforeEach = localEnv.beforeEach; + orgAfterEach = localEnv.afterEach; + + localEnv.beforeEach = function () { + // Sinon sandbox + var config = sinon.getConfig( sinon.config ); + config.injectInto = this; + sinon.sandbox.create( config ); + + // Fixture element + this.fixture = document.createElement( 'div' ); + this.fixture.id = 'qunit-fixture'; + document.body.appendChild( this.fixture ); + + if ( orgBeforeEach ) { + return orgBeforeEach.apply( this, arguments ); + } + }; + localEnv.afterEach = function () { + var ret; + if ( orgAfterEach ) { + ret = orgAfterEach.apply( this, arguments ); + } + this.sandbox.verifyAndRestore(); + this.fixture.parentNode.removeChild( this.fixture ); + return ret; + }; + + return orgModule( name, localEnv, executeNow ); + }; + }() ); + + /** + * Reset mw.config and others to a fresh copy of the live config for each test(), + * and restore it back to the live one afterwards. + * + * @param {Object} [localEnv] + * @example (see test suite at the bottom of this file) + * </code> + */ + QUnit.newMwEnvironment = ( function () { + var warn, error, liveConfig, liveMessages, + MwMap = mw.config.constructor, // internal use only + ajaxRequests = []; + + liveConfig = mw.config; + liveMessages = mw.messages; + + function suppressWarnings() { + if ( warn === undefined ) { + warn = mw.log.warn; + error = mw.log.error; + mw.log.warn = mw.log.error = $.noop; + } + } + + function restoreWarnings() { + // Guard against calls not balanced with suppressWarnings() + if ( warn !== undefined ) { + mw.log.warn = warn; + mw.log.error = error; + warn = error = undefined; + } + } + + function freshConfigCopy( custom ) { + var copy; + // Tests should mock all factors that directly influence the tested code. + // For backwards compatibility though we set mw.config to a fresh copy of the live + // config. This way any modifications made to mw.config during the test will not + // affect other tests, nor the global scope outside the test runner. + // This is a shallow copy, since overriding an array or object value via "custom" + // should replace it. Setting a config property means you override it, not extend it. + // NOTE: It is important that we suppress warnings because extend() will also access + // deprecated properties and trigger deprecation warnings from mw.log#deprecate. + suppressWarnings(); + copy = $.extend( {}, liveConfig.get(), custom ); + restoreWarnings(); + + return copy; + } + + function freshMessagesCopy( custom ) { + return $.extend( /* deep */true, {}, liveMessages.get(), custom ); + } + + /** + * @param {jQuery.Event} event + * @param {jqXHR} jqXHR + * @param {Object} ajaxOptions + */ + function trackAjax( event, jqXHR, ajaxOptions ) { + ajaxRequests.push( { xhr: jqXHR, options: ajaxOptions } ); + } + + return function ( orgEnv ) { + var localEnv, orgBeforeEach, orgAfterEach; + + localEnv = makeSafeEnv( orgEnv ); + // MediaWiki env testing + localEnv.config = localEnv.config || {}; + localEnv.messages = localEnv.messages || {}; + + orgBeforeEach = localEnv.beforeEach; + orgAfterEach = localEnv.afterEach; + + localEnv.beforeEach = function () { + // Greetings, mock environment! + mw.config = new MwMap(); + mw.config.set( freshConfigCopy( localEnv.config ) ); + mw.messages = new MwMap(); + mw.messages.set( freshMessagesCopy( localEnv.messages ) ); + // Update reference to mw.messages + mw.jqueryMsg.setParserDefaults( { + messages: mw.messages + } ); + + this.suppressWarnings = suppressWarnings; + this.restoreWarnings = restoreWarnings; + + // Start tracking ajax requests + $( document ).on( 'ajaxSend', trackAjax ); + + if ( orgBeforeEach ) { + return orgBeforeEach.apply( this, arguments ); + } + }; + localEnv.afterEach = function () { + var timers, pending, $activeLen, ret; + + if ( orgAfterEach ) { + ret = orgAfterEach.apply( this, arguments ); + } + + // Stop tracking ajax requests + $( document ).off( 'ajaxSend', trackAjax ); + + // As a convenience feature, automatically restore warnings if they're + // still suppressed by the end of the test. + restoreWarnings(); + + // Farewell, mock environment! + mw.config = liveConfig; + mw.messages = liveMessages; + // Restore reference to mw.messages + mw.jqueryMsg.setParserDefaults( { + messages: liveMessages + } ); + + // Tests should use fake timers or wait for animations to complete + // Check for incomplete animations/requests/etc and throw if there are any. + if ( $.timers && $.timers.length !== 0 ) { + timers = $.timers.length; + $.each( $.timers, function ( i, timer ) { + var node = timer.elem; + mw.log.warn( 'Unfinished animation #' + i + ' in ' + timer.queue + ' queue on ' + + mw.html.element( node.nodeName.toLowerCase(), $( node ).getAttrs() ) + ); + } ); + // Force animations to stop to give the next test a clean start + $.timers = []; + $.fx.stop(); + + throw new Error( 'Unfinished animations: ' + timers ); + } + + // Test should use fake XHR, wait for requests, or call abort() + $activeLen = $.active; + if ( $activeLen !== undefined && $activeLen !== 0 ) { + pending = ajaxRequests.filter( function ( ajax ) { + return ajax.xhr.state() === 'pending'; + } ); + if ( pending.length !== $activeLen ) { + mw.log.warn( 'Pending requests does not match jQuery.active count' ); + } + // Force requests to stop to give the next test a clean start + ajaxRequests.forEach( function ( ajax, i ) { + mw.log.warn( + 'AJAX request #' + i + ' (state: ' + ajax.xhr.state() + ')', + ajax.options + ); + ajax.xhr.abort(); + } ); + ajaxRequests = []; + + throw new Error( 'Pending AJAX requests: ' + pending.length + ' (active: ' + $activeLen + ')' ); + } + + return ret; + }; + return localEnv; + }; + }() ); + + // $.when stops as soon as one fails, which makes sense in most + // practical scenarios, but not in a unit test where we really do + // need to wait until all of them are finished. + QUnit.whenPromisesComplete = function () { + var altPromises = []; + + $.each( arguments, function ( i, arg ) { + var alt = $.Deferred(); + altPromises.push( alt ); + + // Whether this one fails or not, forwards it to + // the 'done' (resolve) callback of the alternative promise. + arg.always( alt.resolve ); + } ); + + return $.when.apply( $, altPromises ); + }; + + /** + * Recursively convert a node to a plain object representing its structure. + * Only considers attributes and contents (elements and text nodes). + * Attribute values are compared strictly and not normalised. + * + * @param {Node} node + * @return {Object|string} Plain JavaScript value representing the node. + */ + function getDomStructure( node ) { + var $node, children, processedChildren, i, len, el; + $node = $( node ); + if ( node.nodeType === Node.ELEMENT_NODE ) { + children = $node.contents(); + processedChildren = []; + for ( i = 0, len = children.length; i < len; i++ ) { + el = children[ i ]; + if ( el.nodeType === Node.ELEMENT_NODE || el.nodeType === Node.TEXT_NODE ) { + processedChildren.push( getDomStructure( el ) ); + } + } + + return { + tagName: node.tagName, + attributes: $node.getAttrs(), + contents: processedChildren + }; + } else { + // Should be text node + return $node.text(); + } + } + + /** + * Gets structure of node for this HTML. + * + * @param {string} html HTML markup for one or more nodes. + */ + function getHtmlStructure( html ) { + var el = $( '<div>' ).append( html )[ 0 ]; + return getDomStructure( el ); + } + + /** + * Add-on assertion helpers + */ + // Define the add-ons + addons = { + + // Expect boolean true + assertTrue: function ( actual, message ) { + this.pushResult( { + result: actual === true, + actual: actual, + expected: true, + message: message + } ); + }, + + // Expect boolean false + assertFalse: function ( actual, message ) { + this.pushResult( { + result: actual === false, + actual: actual, + expected: false, + message: message + } ); + }, + + // Expect numerical value less than X + lt: function ( actual, expected, message ) { + this.pushResult( { + result: actual < expected, + actual: actual, + expected: 'less than ' + expected, + message: message + } ); + }, + + // Expect numerical value less than or equal to X + ltOrEq: function ( actual, expected, message ) { + this.pushResult( { + result: actual <= expected, + actual: actual, + expected: 'less than or equal to ' + expected, + message: message + } ); + }, + + // Expect numerical value greater than X + gt: function ( actual, expected, message ) { + this.pushResult( { + result: actual > expected, + actual: actual, + expected: 'greater than ' + expected, + message: message + } ); + }, + + // Expect numerical value greater than or equal to X + gtOrEq: function ( actual, expected, message ) { + this.pushResult( { + result: actual >= true, + actual: actual, + expected: 'greater than or equal to ' + expected, + message: message + } ); + }, + + /** + * Asserts that two HTML strings are structurally equivalent. + * + * @param {string} actualHtml Actual HTML markup. + * @param {string} expectedHtml Expected HTML markup + * @param {string} message Assertion message. + */ + htmlEqual: function ( actualHtml, expectedHtml, message ) { + var actual = getHtmlStructure( actualHtml ), + expected = getHtmlStructure( expectedHtml ); + this.pushResult( { + result: QUnit.equiv( actual, expected ), + actual: actual, + expected: expected, + message: message + } ); + }, + + /** + * Asserts that two HTML strings are not structurally equivalent. + * + * @param {string} actualHtml Actual HTML markup. + * @param {string} expectedHtml Expected HTML markup. + * @param {string} message Assertion message. + */ + notHtmlEqual: function ( actualHtml, expectedHtml, message ) { + var actual = getHtmlStructure( actualHtml ), + expected = getHtmlStructure( expectedHtml ); + + this.pushResult( { + result: !QUnit.equiv( actual, expected ), + actual: actual, + expected: expected, + message: message, + negative: true + } ); + } + }; + + $.extend( QUnit.assert, addons ); + + /** + * Small test suite to confirm proper functionality of the utilities and + * initializations defined above in this file. + */ + QUnit.module( 'testrunner', QUnit.newMwEnvironment( { + setup: function () { + this.mwHtmlLive = mw.html; + mw.html = { + escape: function () { + return 'mocked'; + } + }; + }, + teardown: function () { + mw.html = this.mwHtmlLive; + }, + config: { + testVar: 'foo' + }, + messages: { + testMsg: 'Foo.' + } + } ) ); + + QUnit.test( 'Setup', function ( assert ) { + assert.equal( mw.html.escape( 'foo' ), 'mocked', 'setup() callback was ran.' ); + assert.equal( mw.config.get( 'testVar' ), 'foo', 'config object applied' ); + assert.equal( mw.messages.get( 'testMsg' ), 'Foo.', 'messages object applied' ); + + mw.config.set( 'testVar', 'bar' ); + mw.messages.set( 'testMsg', 'Bar.' ); + } ); + + QUnit.test( 'Teardown', function ( assert ) { + assert.equal( mw.config.get( 'testVar' ), 'foo', 'config object restored and re-applied after test()' ); + assert.equal( mw.messages.get( 'testMsg' ), 'Foo.', 'messages object restored and re-applied after test()' ); + } ); + + QUnit.test( 'Loader status', function ( assert ) { + var i, len, state, + modules = mw.loader.getModuleNames(), + error = [], + missing = []; + + for ( i = 0, len = modules.length; i < len; i++ ) { + state = mw.loader.getState( modules[ i ] ); + if ( state === 'error' ) { + error.push( modules[ i ] ); + } else if ( state === 'missing' ) { + missing.push( modules[ i ] ); + } + } + + assert.deepEqual( error, [], 'Modules in error state' ); + assert.deepEqual( missing, [], 'Modules in missing state' ); + } ); + + QUnit.test( 'assert.htmlEqual', function ( assert ) { + assert.htmlEqual( + '<div><p class="some classes" data-length="10">Child paragraph with <a href="http://example.com">A link</a></p>Regular text<span>A span</span></div>', + '<div><p data-length=\'10\' class=\'some classes\'>Child paragraph with <a href=\'http://example.com\' >A link</a></p>Regular text<span>A span</span></div>', + 'Attribute order, spacing and quotation marks (equal)' + ); + + assert.notHtmlEqual( + '<div><p class="some classes" data-length="10">Child paragraph with <a href="http://example.com">A link</a></p>Regular text<span>A span</span></div>', + '<div><p data-length=\'10\' class=\'some more classes\'>Child paragraph with <a href=\'http://example.com\' >A link</a></p>Regular text<span>A span</span></div>', + 'Attribute order, spacing and quotation marks (not equal)' + ); + + assert.htmlEqual( + '<label for="firstname" accesskey="f" class="important">First</label><input id="firstname" /><label for="lastname" accesskey="l" class="minor">Last</label><input id="lastname" />', + '<label for="firstname" accesskey="f" class="important">First</label><input id="firstname" /><label for="lastname" accesskey="l" class="minor">Last</label><input id="lastname" />', + 'Multiple root nodes (equal)' + ); + + assert.notHtmlEqual( + '<label for="firstname" accesskey="f" class="important">First</label><input id="firstname" /><label for="lastname" accesskey="l" class="minor">Last</label><input id="lastname" />', + '<label for="firstname" accesskey="f" class="important">First</label><input id="firstname" /><label for="lastname" accesskey="l" class="important" >Last</label><input id="lastname" />', + 'Multiple root nodes (not equal, last label node is different)' + ); + + assert.htmlEqual( + 'fo"o<br/>b>ar', + 'fo"o<br/>b>ar', + 'Extra escaping is equal' + ); + assert.notHtmlEqual( + 'foo<br/>bar', + 'foo<br/>bar', + 'Text escaping (not equal)' + ); + + assert.htmlEqual( + 'foo<a href="http://example.com">example</a>bar', + 'foo<a href="http://example.com">example</a>bar', + 'Outer text nodes are compared (equal)' + ); + + assert.notHtmlEqual( + 'foo<a href="http://example.com">example</a>bar', + 'foo<a href="http://example.com">example</a>quux', + 'Outer text nodes are compared (last text node different)' + ); + } ); + + QUnit.module( 'testrunner-after', QUnit.newMwEnvironment() ); + + QUnit.test( 'Teardown', function ( assert ) { + assert.equal( mw.html.escape( '<' ), '<', 'teardown() callback was ran.' ); + assert.equal( mw.config.get( 'testVar' ), null, 'config object restored to live in next module()' ); + assert.equal( mw.messages.get( 'testMsg' ), null, 'messages object restored to live in next module()' ); + } ); + + QUnit.module( 'testrunner-each', { + beforeEach: function () { + this.mwHtmlLive = mw.html; + }, + afterEach: function () { + mw.html = this.mwHtmlLive; + } + } ); + QUnit.test( 'beforeEach', function ( assert ) { + assert.ok( this.mwHtmlLive, 'setup() ran' ); + mw.html = null; + } ); + QUnit.test( 'afterEach', function ( assert ) { + assert.equal( mw.html.escape( '<' ), '<', 'afterEach() ran' ); + } ); + + QUnit.module( 'testrunner-each-compat', { + setup: function () { + this.mwHtmlLive = mw.html; + }, + teardown: function () { + mw.html = this.mwHtmlLive; + } + } ); + QUnit.test( 'setup', function ( assert ) { + assert.ok( this.mwHtmlLive, 'setup() ran' ); + mw.html = null; + } ); + QUnit.test( 'teardown', function ( assert ) { + assert.equal( mw.html.escape( '<' ), '<', 'teardown() ran' ); + } ); + + // Regression test for 'this.sandbox undefined' error, fixed by + // ensuring Sinon setup/teardown is not re-run on inner module. + QUnit.module( 'testrunner-nested', function () { + QUnit.module( 'testrunner-nested-inner', function () { + QUnit.test( 'Dummy', function ( assert ) { + assert.ok( true, 'Nested modules supported' ); + } ); + } ); + } ); + + QUnit.module( 'testrunner-hooks-outer', function () { + var beforeHookWasExecuted = false, + afterHookWasExecuted = false; + QUnit.module( 'testrunner-hooks', { + before: function () { + beforeHookWasExecuted = true; + + // This way we can be sure that module `testrunner-hook-after` will always + // be executed after module `testrunner-hooks` + QUnit.module( 'testrunner-hooks-after' ); + QUnit.test( + '`after` hook for module `testrunner-hooks` was executed', + function ( assert ) { + assert.ok( afterHookWasExecuted ); + } + ); + }, + after: function () { + afterHookWasExecuted = true; + } + } ); + + QUnit.test( '`before` hook was executed', function ( assert ) { + assert.ok( beforeHookWasExecuted ); + } ); + } ); + +}( jQuery, mediaWiki, QUnit ) ); diff --git a/www/wiki/tests/qunit/suites/resources/jquery/jquery.accessKeyLabel.test.js b/www/wiki/tests/qunit/suites/resources/jquery/jquery.accessKeyLabel.test.js new file mode 100644 index 00000000..e4b61572 --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/jquery/jquery.accessKeyLabel.test.js @@ -0,0 +1,121 @@ +( function ( $ ) { + var getAccessKeyPrefixTestData, updateTooltipAccessKeysTestData; + + QUnit.module( 'jquery.accessKeyLabel', QUnit.newMwEnvironment( { + messages: { + brackets: '[$1]', + 'word-separator': ' ' + } + } ) ); + + getAccessKeyPrefixTestData = [ + // ua string, platform string, expected prefix + // Internet Explorer + [ 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1)', 'Win32', 'alt-' ], + [ 'Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; Trident/6.0)', 'Win32', 'alt-' ], + [ 'Mozilla/5.0 (Windows NT 6.3; Win64; x64; Trident/7.0; rv:11.0) like Gecko', 'Win64', 'alt-' ], + [ 'Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.10136', 'Win64', 'alt-' ], + // Firefox + [ 'Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10.5; en-US; rv:1.9.1.19) Gecko/20110420 Firefox/3.5.19', 'MacIntel', 'ctrl-' ], + [ 'Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.9.2.17) Gecko/20110422 Ubuntu/10.10 (maverick) Firefox/3.6.17', 'Linux i686', 'alt-shift-' ], + [ 'Mozilla/5.0 (Windows NT 6.0; rv:2.0.1) Gecko/20100101 Firefox/4.0.1', 'Win32', 'alt-shift-' ], + [ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.12; rv:50.0) Gecko/20100101 Firefox/50.0', 'MacIntel', 'ctrl-option-' ], + [ 'Mozilla/5.0 (X11; Linux x86_64; rv:17.0) Gecko/20121202 Firefox/17.0 Iceweasel/17.0.1', 'Linux 1686', 'alt-shift-' ], + [ 'Mozilla/5.0 (Windows NT 5.2; U; de; rv:1.8.0) Gecko/20060728 Firefox/1.5.0', 'Win32', 'alt-' ], + // Safari / Konqueror + [ 'Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_7; nl-nl) AppleWebKit/531.22.7 (KHTML, like Gecko) Version/4.0.5 Safari/531.22.7', 'MacIntel', 'ctrl-option-' ], + [ 'Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_5_7; de-de) AppleWebKit/525.28.3 (KHTML, like Gecko) Version/3.2.3 Safari/525.28.3', 'MacIntel', 'ctrl-' ], + [ 'Mozilla/5.0 (Windows; U; Windows NT 5.1; cs-CZ) AppleWebKit/525.28.3 (KHTML, like Gecko) Version/3.2.3 Safari/525.29', 'Win32', 'alt-' ], + [ 'Mozilla/5.0 (Windows; U; Windows NT 6.0; cs-CZ) AppleWebKit/533.21.1 (KHTML, like Gecko) Version/4.0.5 Safari/531.22.7', 'Win32', 'alt-' ], + [ 'Mozilla/5.0 (X11; Linux i686) KHTML/4.9.1 (like Gecko) Konqueror/4.9', 'Linux i686', 'ctrl-' ], + // Opera + [ 'Opera/9.80 (Windows NT 5.1)', 'Win32', 'shift-esc-' ], + [ 'Mozilla/5.0 (Windows NT 5.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/28.0.1500.52 Safari/537.36 OPR/15.0.1147.130', 'Win32', 'alt-shift-' ], + // Chrome + [ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_5_8) AppleWebKit/534.30 (KHTML, like Gecko) Chrome/12.0.742.112 Safari/534.30', 'MacIntel', 'ctrl-option-' ], + [ 'Mozilla/5.0 (X11; Linux i686) AppleWebKit/534.30 (KHTML, like Gecko) Chrome/12.0.742.68 Safari/534.30', 'Linux i686', 'alt-shift-' ], + // Unknown! Note: These aren't necessarily *right*, this is just + // testing that we're getting the expected output based on the + // platform. + [ 'Mozilla/5.0 (Macintosh; U; PPC Mac OS X; en-US; rv:1.0.1) Gecko/20021111 Chimera/0.6', 'MacPPC', 'ctrl-' ], + [ 'Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.3a) Gecko/20021207 Phoenix/0.5', 'Linux i686', 'alt-' ] + ]; + // strings appended to title to make sure updateTooltipAccessKeys handles them correctly + updateTooltipAccessKeysTestData = [ '', ' [a]', ' [test-a]', ' [alt-b]' ]; + + function makeInput( title, accessKey ) { + // The properties aren't escaped, so make sure you don't call this function with values that need to be escaped! + return '<input title="' + title + '" ' + ( accessKey ? 'accessKey="' + accessKey + '" ' : '' ) + ' />'; + } + + QUnit.test( 'getAccessKeyPrefix', function ( assert ) { + var i; + for ( i = 0; i < getAccessKeyPrefixTestData.length; i++ ) { + assert.equal( $.fn.updateTooltipAccessKeys.getAccessKeyPrefix( { + userAgent: getAccessKeyPrefixTestData[ i ][ 0 ], + platform: getAccessKeyPrefixTestData[ i ][ 1 ] + } ), getAccessKeyPrefixTestData[ i ][ 2 ], 'Correct prefix for ' + getAccessKeyPrefixTestData[ i ][ 0 ] ); + } + } ); + + QUnit.test( 'updateTooltipAccessKeys - current browser', function ( assert ) { + var title = $( makeInput( 'Title', 'a' ) ).updateTooltipAccessKeys().prop( 'title' ), + // The new title should be something like "Title [alt-a]", but the exact label will depend on the browser. + // The "a" could be capitalized, and the prefix could be anything, e.g. a simple "^" for ctrl- + // (no browser is known using such a short prefix, though) or "Alt+Umschalt+" in German Firefox. + result = /^Title \[(.+)[aA]\]$/.exec( title ); + assert.ok( result, 'title should match expected structure.' ); + assert.notEqual( result[ 1 ], 'test-', 'Prefix used for testing shouldn\'t be used in production.' ); + } ); + + QUnit.test( 'updateTooltipAccessKeys - no access key', function ( assert ) { + var i, oldTitle, $input, newTitle; + for ( i = 0; i < updateTooltipAccessKeysTestData.length; i++ ) { + oldTitle = 'Title' + updateTooltipAccessKeysTestData[ i ]; + $input = $( makeInput( oldTitle ) ); + $( '#qunit-fixture' ).append( $input ); + newTitle = $input.updateTooltipAccessKeys().prop( 'title' ); + assert.equal( newTitle, 'Title', 'title="' + oldTitle + '"' ); + } + } ); + + QUnit.test( 'updateTooltipAccessKeys - with access key', function ( assert ) { + var i, oldTitle, $input, newTitle; + $.fn.updateTooltipAccessKeys.setTestMode( true ); + for ( i = 0; i < updateTooltipAccessKeysTestData.length; i++ ) { + oldTitle = 'Title' + updateTooltipAccessKeysTestData[ i ]; + $input = $( makeInput( oldTitle, 'a' ) ); + $( '#qunit-fixture' ).append( $input ); + newTitle = $input.updateTooltipAccessKeys().prop( 'title' ); + assert.equal( newTitle, 'Title [test-a]', 'title="' + oldTitle + '"' ); + } + $.fn.updateTooltipAccessKeys.setTestMode( false ); + } ); + + QUnit.test( 'updateTooltipAccessKeys with label element', function ( assert ) { + var html, $label, $input; + $.fn.updateTooltipAccessKeys.setTestMode( true ); + html = '<label for="testInput" title="Title">Label</label><input id="testInput" accessKey="a" />'; + $( '#qunit-fixture' ).html( html ); + $label = $( '#qunit-fixture label' ); + $input = $( '#qunit-fixture input' ); + $input.updateTooltipAccessKeys(); + assert.equal( $input.prop( 'title' ), '', 'No title attribute added to input element.' ); + assert.equal( $label.prop( 'title' ), 'Title [test-a]', 'title updated for associated label element.' ); + $.fn.updateTooltipAccessKeys.setTestMode( false ); + } ); + + QUnit.test( 'updateTooltipAccessKeys with label element as parent', function ( assert ) { + var html, $label, $input; + $.fn.updateTooltipAccessKeys.setTestMode( true ); + html = '<label title="Title">Label<input id="testInput" accessKey="a" /></label>'; + $( '#qunit-fixture' ).html( html ); + $label = $( '#qunit-fixture label' ); + $input = $( '#qunit-fixture input' ); + $input.updateTooltipAccessKeys(); + assert.equal( $input.prop( 'title' ), '', 'No title attribute added to input element.' ); + assert.equal( $label.prop( 'title' ), 'Title [test-a]', 'title updated for associated label element.' ); + $.fn.updateTooltipAccessKeys.setTestMode( false ); + } ); + +}( jQuery ) ); diff --git a/www/wiki/tests/qunit/suites/resources/jquery/jquery.color.test.js b/www/wiki/tests/qunit/suites/resources/jquery/jquery.color.test.js new file mode 100644 index 00000000..ca6a512f --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/jquery/jquery.color.test.js @@ -0,0 +1,15 @@ +( function ( $ ) { + QUnit.module( 'jquery.color', QUnit.newMwEnvironment() ); + + QUnit.test( 'animate', function ( assert ) { + var done = assert.async(), + $canvas = $( '<div>' ).css( 'background-color', '#fff' ).appendTo( '#qunit-fixture' ); + + $canvas.animate( { 'background-color': '#000' }, 3 ).promise() + .done( function () { + var endColors = $.colorUtil.getRGB( $canvas.css( 'background-color' ) ); + assert.deepEqual( endColors, [ 0, 0, 0 ], 'end state' ); + } ) + .always( done ); + } ); +}( jQuery ) ); diff --git a/www/wiki/tests/qunit/suites/resources/jquery/jquery.colorUtil.test.js b/www/wiki/tests/qunit/suites/resources/jquery/jquery.colorUtil.test.js new file mode 100644 index 00000000..d6208e91 --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/jquery/jquery.colorUtil.test.js @@ -0,0 +1,63 @@ +( function ( $ ) { + QUnit.module( 'jquery.colorUtil', QUnit.newMwEnvironment() ); + + QUnit.test( 'getRGB', function ( assert ) { + assert.strictEqual( $.colorUtil.getRGB(), undefined, 'No arguments' ); + assert.strictEqual( $.colorUtil.getRGB( '' ), undefined, 'Empty string' ); + assert.deepEqual( $.colorUtil.getRGB( [ 0, 100, 255 ] ), [ 0, 100, 255 ], 'Parse array of rgb values' ); + assert.deepEqual( $.colorUtil.getRGB( 'rgb(0,100,255)' ), [ 0, 100, 255 ], 'Parse simple rgb string' ); + assert.deepEqual( $.colorUtil.getRGB( 'rgb(0, 100, 255)' ), [ 0, 100, 255 ], 'Parse simple rgb string with spaces' ); + assert.deepEqual( $.colorUtil.getRGB( 'rgb(0%,20%,40%)' ), [ 0, 51, 102 ], 'Parse rgb string with percentages' ); + assert.deepEqual( $.colorUtil.getRGB( 'rgb(0%, 20%, 40%)' ), [ 0, 51, 102 ], 'Parse rgb string with percentages and spaces' ); + assert.deepEqual( $.colorUtil.getRGB( '#f2ddee' ), [ 242, 221, 238 ], 'Hex string: 6 char lowercase' ); + assert.deepEqual( $.colorUtil.getRGB( '#f2DDEE' ), [ 242, 221, 238 ], 'Hex string: 6 char uppercase' ); + assert.deepEqual( $.colorUtil.getRGB( '#f2DdEe' ), [ 242, 221, 238 ], 'Hex string: 6 char mixed' ); + assert.deepEqual( $.colorUtil.getRGB( '#eee' ), [ 238, 238, 238 ], 'Hex string: 3 char lowercase' ); + assert.deepEqual( $.colorUtil.getRGB( '#EEE' ), [ 238, 238, 238 ], 'Hex string: 3 char uppercase' ); + assert.deepEqual( $.colorUtil.getRGB( '#eEe' ), [ 238, 238, 238 ], 'Hex string: 3 char mixed' ); + assert.deepEqual( $.colorUtil.getRGB( 'rgba(0, 0, 0, 0)' ), [ 255, 255, 255 ], 'Zero rgba for Safari 3; Transparent (whitespace)' ); + + // Perhaps this is a bug in colorUtil, but it is the current behavior so, let's keep + // track of it, so we will know in case it would ever change. + assert.strictEqual( $.colorUtil.getRGB( 'rgba(0,0,0,0)' ), undefined, 'Zero rgba without whitespace' ); + + assert.deepEqual( $.colorUtil.getRGB( 'lightGreen' ), [ 144, 238, 144 ], 'Color names (lightGreen)' ); + assert.deepEqual( $.colorUtil.getRGB( 'transparent' ), [ 255, 255, 255 ], 'Color names (transparent)' ); + assert.strictEqual( $.colorUtil.getRGB( 'mediaWiki' ), undefined, 'Inexisting color name' ); + } ); + + QUnit.test( 'rgbToHsl', function ( assert ) { + var hsl, ret; + + // Cross-browser differences in decimals... + // Round to two decimals so they can be more reliably checked. + function dualDecimals( a ) { + return Math.round( a * 100 ) / 100; + } + + // Re-create the rgbToHsl return array items, limited to two decimals. + hsl = $.colorUtil.rgbToHsl( 144, 238, 144 ); + ret = [ dualDecimals( hsl[ 0 ] ), dualDecimals( hsl[ 1 ] ), dualDecimals( hsl[ 2 ] ) ]; + + assert.deepEqual( ret, [ 0.33, 0.73, 0.75 ], 'rgb(144, 238, 144): hsl(0.33, 0.73, 0.75)' ); + } ); + + QUnit.test( 'hslToRgb', function ( assert ) { + var rgb, ret; + rgb = $.colorUtil.hslToRgb( 0.3, 0.7, 0.8 ); + + // Re-create the hslToRgb return array items, rounded to whole numbers. + ret = [ Math.round( rgb[ 0 ] ), Math.round( rgb[ 1 ] ), Math.round( rgb[ 2 ] ) ]; + + assert.deepEqual( ret, [ 183, 240, 168 ], 'hsl(0.3, 0.7, 0.8): rgb(183, 240, 168)' ); + } ); + + QUnit.test( 'getColorBrightness', function ( assert ) { + var a, b; + a = $.colorUtil.getColorBrightness( 'red', +0.1 ); + assert.equal( a, 'rgb(255,50,50)', 'Start with named color "red", brighten 10%' ); + + b = $.colorUtil.getColorBrightness( 'rgb(200,50,50)', -0.2 ); + assert.equal( b, 'rgb(118,29,29)', 'Start with rgb string "rgb(200,50,50)", darken 20%' ); + } ); +}( jQuery ) ); diff --git a/www/wiki/tests/qunit/suites/resources/jquery/jquery.getAttrs.test.js b/www/wiki/tests/qunit/suites/resources/jquery/jquery.getAttrs.test.js new file mode 100644 index 00000000..74d85090 --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/jquery/jquery.getAttrs.test.js @@ -0,0 +1,14 @@ +( function ( $ ) { + QUnit.module( 'jquery.getAttrs', QUnit.newMwEnvironment() ); + + QUnit.test( 'getAttrs()', function ( assert ) { + var attrs = { + foo: 'bar', + class: 'lorem', + 'data-foo': 'data value' + }, + $el = $( '<div>' ).attr( attrs ); + + assert.propEqual( $el.getAttrs(), attrs, 'keys and values match' ); + } ); +}( jQuery ) ); diff --git a/www/wiki/tests/qunit/suites/resources/jquery/jquery.hidpi.test.js b/www/wiki/tests/qunit/suites/resources/jquery/jquery.hidpi.test.js new file mode 100644 index 00000000..6a265eb5 --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/jquery/jquery.hidpi.test.js @@ -0,0 +1,38 @@ +( function ( $ ) { + QUnit.module( 'jquery.hidpi', QUnit.newMwEnvironment() ); + + QUnit.test( 'devicePixelRatio', function ( assert ) { + var devicePixelRatio = $.devicePixelRatio(); + assert.equal( typeof devicePixelRatio, 'number', '$.devicePixelRatio() returns a number' ); + } ); + + QUnit.test( 'bracketedDevicePixelRatio', function ( assert ) { + var ratio = $.bracketedDevicePixelRatio(); + assert.equal( typeof ratio, 'number', '$.bracketedDevicePixelRatio() returns a number' ); + } ); + + QUnit.test( 'bracketDevicePixelRatio', function ( assert ) { + assert.equal( $.bracketDevicePixelRatio( 0.75 ), 1, '0.75 gives 1' ); + assert.equal( $.bracketDevicePixelRatio( 1 ), 1, '1 gives 1' ); + assert.equal( $.bracketDevicePixelRatio( 1.25 ), 1.5, '1.25 gives 1.5' ); + assert.equal( $.bracketDevicePixelRatio( 1.5 ), 1.5, '1.5 gives 1.5' ); + assert.equal( $.bracketDevicePixelRatio( 1.75 ), 2, '1.75 gives 2' ); + assert.equal( $.bracketDevicePixelRatio( 2 ), 2, '2 gives 2' ); + assert.equal( $.bracketDevicePixelRatio( 2.5 ), 2, '2.5 gives 2' ); + assert.equal( $.bracketDevicePixelRatio( 3 ), 2, '3 gives 2' ); + } ); + + QUnit.test( 'matchSrcSet', function ( assert ) { + var srcset = 'onefive.png 1.5x, two.png 2x'; + + // Nice exact matches + assert.equal( $.matchSrcSet( 1, srcset ), null, '1.0 gives no match' ); + assert.equal( $.matchSrcSet( 1.5, srcset ), 'onefive.png', '1.5 gives match' ); + assert.equal( $.matchSrcSet( 2, srcset ), 'two.png', '2 gives match' ); + + // Non-exact matches; should return the next-biggest specified + assert.equal( $.matchSrcSet( 1.25, srcset ), null, '1.25 gives no match' ); + assert.equal( $.matchSrcSet( 1.75, srcset ), 'onefive.png', '1.75 gives match to 1.5' ); + assert.equal( $.matchSrcSet( 2.25, srcset ), 'two.png', '2.25 gives match to 2' ); + } ); +}( jQuery ) ); diff --git a/www/wiki/tests/qunit/suites/resources/jquery/jquery.highlightText.test.js b/www/wiki/tests/qunit/suites/resources/jquery/jquery.highlightText.test.js new file mode 100644 index 00000000..277ba3f2 --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/jquery/jquery.highlightText.test.js @@ -0,0 +1,235 @@ +( function ( $ ) { + QUnit.module( 'jquery.highlightText', QUnit.newMwEnvironment() ); + + QUnit.test( 'Check', function ( assert ) { + var $fixture, + cases = [ + { + desc: 'Test 001', + text: 'Blue Öyster Cult', + highlight: 'Blue', + expected: '<span class="highlight">Blue</span> Öyster Cult' + }, + { + desc: 'Test 002', + text: 'Blue Öyster Cult', + highlight: 'Blue ', + expected: '<span class="highlight">Blue</span> Öyster Cult' + }, + { + desc: 'Test 003', + text: 'Blue Öyster Cult', + highlight: 'Blue Ö', + expected: '<span class="highlight">Blue</span> <span class="highlight">Ö</span>yster Cult' + }, + { + desc: 'Test 004', + text: 'Blue Öyster Cult', + highlight: 'Blue Öy', + expected: '<span class="highlight">Blue</span> <span class="highlight">Öy</span>ster Cult' + }, + { + desc: 'Test 005', + text: 'Blue Öyster Cult', + highlight: ' Blue', + expected: '<span class="highlight">Blue</span> Öyster Cult' + }, + { + desc: 'Test 006', + text: 'Blue Öyster Cult', + highlight: ' Blue ', + expected: '<span class="highlight">Blue</span> Öyster Cult' + }, + { + desc: 'Test 007', + text: 'Blue Öyster Cult', + highlight: ' Blue Ö', + expected: '<span class="highlight">Blue</span> <span class="highlight">Ö</span>yster Cult' + }, + { + desc: 'Test 008', + text: 'Blue Öyster Cult', + highlight: ' Blue Öy', + expected: '<span class="highlight">Blue</span> <span class="highlight">Öy</span>ster Cult' + }, + { + desc: 'Test 009: Highlighter broken on starting Umlaut?', + text: 'Österreich', + highlight: 'Österreich', + expected: '<span class="highlight">Österreich</span>' + }, + { + desc: 'Test 010: Highlighter broken on starting Umlaut?', + text: 'Österreich', + highlight: 'Ö', + expected: '<span class="highlight">Ö</span>sterreich' + }, + { + desc: 'Test 011: Highlighter broken on starting Umlaut?', + text: 'Österreich', + highlight: 'Öst', + expected: '<span class="highlight">Öst</span>erreich' + }, + { + desc: 'Test 012: Highlighter broken on starting Umlaut?', + text: 'Österreich', + highlight: 'Oe', + expected: 'Österreich' + }, + { + desc: 'Test 013: Highlighter broken on punctuation mark?', + text: 'So good. To be there', + highlight: 'good', + expected: 'So <span class="highlight">good</span>. To be there' + }, + { + desc: 'Test 014: Highlighter broken on space?', + text: 'So good. To be there', + highlight: 'be', + expected: 'So good. To <span class="highlight">be</span> there' + }, + { + desc: 'Test 015: Highlighter broken on space?', + text: 'So good. To be there', + highlight: ' be', + expected: 'So good. To <span class="highlight">be</span> there' + }, + { + desc: 'Test 016: Highlighter broken on space?', + text: 'So good. To be there', + highlight: 'be ', + expected: 'So good. To <span class="highlight">be</span> there' + }, + { + desc: 'Test 017: Highlighter broken on space?', + text: 'So good. To be there', + highlight: ' be ', + expected: 'So good. To <span class="highlight">be</span> there' + }, + { + desc: 'Test 018: en de Highlighter broken on special character at the end?', + text: 'So good. xbß', + highlight: 'xbß', + expected: 'So good. <span class="highlight">xbß</span>' + }, + { + desc: 'Test 019: en de Highlighter broken on special character at the end?', + text: 'So good. xbß.', + highlight: 'xbß.', + expected: 'So good. <span class="highlight">xbß.</span>' + }, + { + desc: 'Test 020: RTL he Hebrew', + text: 'חסיד אומות העולם', + highlight: 'חסיד אומות העולם', + expected: '<span class="highlight">חסיד</span> <span class="highlight">אומות</span> <span class="highlight">העולם</span>' + }, + { + desc: 'Test 021: RTL he Hebrew', + text: 'חסיד אומות העולם', + highlight: 'חסי', + expected: '<span class="highlight">חסי</span>ד אומות העולם' + }, + { + desc: 'Test 022: ja Japanese', + text: '諸国民の中の正義の人', + highlight: '諸国民の中の正義の人', + expected: '<span class="highlight">諸国民の中の正義の人</span>' + }, + { + desc: 'Test 023: ja Japanese', + text: '諸国民の中の正義の人', + highlight: '諸国', + expected: '<span class="highlight">諸国</span>民の中の正義の人' + }, + { + desc: 'Test 024: fr French text and « french quotes » (guillemets)', + text: '« L\'oiseau est sur l’île »', + highlight: '« L\'oiseau est sur l’île »', + expected: '<span class="highlight">«</span> <span class="highlight">L\'oiseau</span> <span class="highlight">est</span> <span class="highlight">sur</span> <span class="highlight">l’île</span> <span class="highlight">»</span>' + }, + { + desc: 'Test 025: fr French text and « french quotes » (guillemets)', + text: '« L\'oiseau est sur l’île »', + highlight: '« L\'oise', + expected: '<span class="highlight">«</span> <span class="highlight">L\'oise</span>au est sur l’île »' + }, + { + desc: 'Test 025a: fr French text and « french quotes » (guillemets) - does it match the single strings "«" and "L" separately?', + text: '« L\'oiseau est sur l’île »', + highlight: '« L', + expected: '<span class="highlight">«</span> <span class="highlight">L</span>\'oiseau est sur <span class="highlight">l</span>’île »' + }, + { + desc: 'Test 026: ru Russian', + text: 'Праведники мира', + highlight: 'Праведники мира', + expected: '<span class="highlight">Праведники</span> <span class="highlight">мира</span>' + }, + { + desc: 'Test 027: ru Russian', + text: 'Праведники мира', + highlight: 'Праве', + expected: '<span class="highlight">Праве</span>дники мира' + }, + { + desc: 'Test 028 ka Georgian', + text: 'მთავარი გვერდი', + highlight: 'მთავარი გვერდი', + expected: '<span class="highlight">მთავარი</span> <span class="highlight">გვერდი</span>' + }, + { + desc: 'Test 029 ka Georgian', + text: 'მთავარი გვერდი', + highlight: 'მთა', + expected: '<span class="highlight">მთა</span>ვარი გვერდი' + }, + { + desc: 'Test 030 hy Armenian', + text: 'Նոնա Գափրինդաշվիլի', + highlight: 'Նոնա Գափրինդաշվիլի', + expected: '<span class="highlight">Նոնա</span> <span class="highlight">Գափրինդաշվիլի</span>' + }, + { + desc: 'Test 031 hy Armenian', + text: 'Նոնա Գափրինդաշվիլի', + highlight: 'Նոն', + expected: '<span class="highlight">Նոն</span>ա Գափրինդաշվիլի' + }, + { + desc: 'Test 032: th Thai', + text: 'พอล แอร์ดิช', + highlight: 'พอล แอร์ดิช', + expected: '<span class="highlight">พอล</span> <span class="highlight">แอร์ดิช</span>' + }, + { + desc: 'Test 033: th Thai', + text: 'พอล แอร์ดิช', + highlight: 'พอ', + expected: '<span class="highlight">พอ</span>ล แอร์ดิช' + }, + { + desc: 'Test 034: RTL ar Arabic', + text: 'بول إيردوس', + highlight: 'بول إيردوس', + expected: '<span class="highlight">بول</span> <span class="highlight">إيردوس</span>' + }, + { + desc: 'Test 035: RTL ar Arabic', + text: 'بول إيردوس', + highlight: 'بو', + expected: '<span class="highlight">بو</span>ل إيردوس' + } + ]; + + cases.forEach( function ( item ) { + $fixture = $( '<p>' ).text( item.text ).highlightText( item.highlight ); + assert.equal( + $fixture.html(), + // Re-parse to normalize + $( '<p>' ).html( item.expected ).html(), + item.desc || undefined + ); + } ); + } ); +}( jQuery ) ); diff --git a/www/wiki/tests/qunit/suites/resources/jquery/jquery.lengthLimit.test.js b/www/wiki/tests/qunit/suites/resources/jquery/jquery.lengthLimit.test.js new file mode 100644 index 00000000..7117d1f4 --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/jquery/jquery.lengthLimit.test.js @@ -0,0 +1,286 @@ +( function ( $, mw ) { + var simpleSample, U_20AC, poop, mbSample; + + QUnit.module( 'jquery.lengthLimit', QUnit.newMwEnvironment() ); + + // Simple sample (20 chars, 20 bytes) + simpleSample = '12345678901234567890'; + + // 3 bytes (euro-symbol) + U_20AC = '\u20AC'; + + // Outside of the BMP (pile of poo emoji) + poop = '\uD83D\uDCA9'; // "💩" + + // Multi-byte sample (22 chars, 26 bytes) + mbSample = '1234567890' + U_20AC + '1234567890' + U_20AC; + + // Basic sendkey-implementation + function addChars( $input, charstr ) { + var c, len; + + function x( $input, i ) { + // Add character to the value + return $input.val() + charstr.charAt( i ); + } + + for ( c = 0, len = charstr.length; c < len; c += 1 ) { + $input + .val( x( $input, c ) ) + .trigger( 'change' ); + } + } + + /** + * Test factory for $.fn.byteLimit + * + * @param {Object} options + * @param {string} options.description Test name + * @param {jQuery} options.$input jQuery object in an input element + * @param {string} options.sample Sequence of characters to simulate being + * added one by one + * @param {string} options.expected Expected final value of `$input` + */ + function byteLimitTest( options ) { + var opt = $.extend( { + description: '', + $input: null, + sample: '', + expected: '' + }, options ); + + QUnit.test( opt.description, function ( assert ) { + opt.$input.appendTo( '#qunit-fixture' ); + + // Simulate pressing keys for each of the sample characters + addChars( opt.$input, opt.sample ); + + assert.equal( + opt.$input.val(), + opt.expected, + 'New value matches the expected string' + ); + } ); + } + + byteLimitTest( { + description: 'Plain text input', + $input: $( '<input>' ).attr( 'type', 'text' ), + sample: simpleSample, + expected: simpleSample + } ); + + byteLimitTest( { + description: 'Plain text input. Calling byteLimit with no parameters and no maxlength attribute (T38310)', + $input: $( '<input>' ).attr( 'type', 'text' ) + .byteLimit(), + sample: simpleSample, + expected: simpleSample + } ); + + byteLimitTest( { + description: 'Limit using the maxlength attribute', + $input: $( '<input>' ).attr( 'type', 'text' ) + .attr( 'maxlength', '10' ) + .byteLimit(), + sample: simpleSample, + expected: '1234567890' + } ); + + byteLimitTest( { + description: 'Limit using a custom value', + $input: $( '<input>' ).attr( 'type', 'text' ) + .byteLimit( 10 ), + sample: simpleSample, + expected: '1234567890' + } ); + + byteLimitTest( { + description: 'Limit using a custom value, overriding maxlength attribute', + $input: $( '<input>' ).attr( 'type', 'text' ) + .attr( 'maxlength', '10' ) + .byteLimit( 15 ), + sample: simpleSample, + expected: '123456789012345' + } ); + + byteLimitTest( { + description: 'Limit using a custom value (multibyte)', + $input: $( '<input>' ).attr( 'type', 'text' ) + .byteLimit( 14 ), + sample: mbSample, + expected: '1234567890' + U_20AC + '1' + } ); + + byteLimitTest( { + description: 'Limit using a custom value (multibyte, outside BMP)', + $input: $( '<input>' ).attr( 'type', 'text' ) + .byteLimit( 3 ), + sample: poop, + expected: '' + } ); + + byteLimitTest( { + description: 'Limit using a custom value (multibyte) overlapping a byte', + $input: $( '<input>' ).attr( 'type', 'text' ) + .byteLimit( 12 ), + sample: mbSample, + expected: '123456789012' + } ); + + byteLimitTest( { + description: 'Pass the limit and a callback as input filter', + $input: $( '<input>' ).attr( 'type', 'text' ) + .byteLimit( 6, function ( val ) { + var title = mw.Title.newFromText( String( val ) ); + // Return without namespace prefix + return title ? title.getMain() : ''; + } ), + sample: 'User:Sample', + expected: 'User:Sample' + } ); + + byteLimitTest( { + description: 'Limit using the maxlength attribute and pass a callback as input filter', + $input: $( '<input>' ).attr( 'type', 'text' ) + .attr( 'maxlength', '6' ) + .byteLimit( function ( val ) { + var title = mw.Title.newFromText( String( val ) ); + // Return without namespace prefix + return title ? title.getMain() : ''; + } ), + sample: 'User:Sample', + expected: 'User:Sample' + } ); + + byteLimitTest( { + description: 'Pass the limit and a callback as input filter', + $input: $( '<input>' ).attr( 'type', 'text' ) + .byteLimit( 6, function ( val ) { + var title = mw.Title.newFromText( String( val ) ); + // Return without namespace prefix + return title ? title.getMain() : ''; + } ), + sample: 'User:Example', + // The callback alters the value to be used to calculeate + // the length. The altered value is "Exampl" which has + // a length of 6, the "e" would exceed the limit. + expected: 'User:Exampl' + } ); + + byteLimitTest( { + description: 'Input filter that increases the length', + $input: $( '<input>' ).attr( 'type', 'text' ) + .byteLimit( 10, function ( text ) { + return 'prefix' + text; + } ), + sample: simpleSample, + // Prefix adds 6 characters, limit is reached after 4 + expected: '1234' + } ); + + // Regression tests for T43450 + byteLimitTest( { + description: 'Input filter of which the base exceeds the limit', + $input: $( '<input>' ).attr( 'type', 'text' ) + .byteLimit( 3, function ( text ) { + return 'prefix' + text; + } ), + sample: simpleSample, + expected: '' + } ); + + QUnit.test( 'Confirm properties and attributes set', function ( assert ) { + var $el; + + $el = $( '<input>' ).attr( 'type', 'text' ) + .attr( 'maxlength', '7' ) + .appendTo( '#qunit-fixture' ) + .byteLimit(); + + assert.strictEqual( $el.attr( 'maxlength' ), '7', 'maxlength attribute unchanged for simple limit' ); + + $el = $( '<input>' ).attr( 'type', 'text' ) + .attr( 'maxlength', '7' ) + .appendTo( '#qunit-fixture' ) + .byteLimit( 12 ); + + assert.strictEqual( $el.attr( 'maxlength' ), '12', 'maxlength attribute updated for custom limit' ); + + $el = $( '<input>' ).attr( 'type', 'text' ) + .attr( 'maxlength', '7' ) + .appendTo( '#qunit-fixture' ) + .byteLimit( 12, function ( val ) { + return val; + } ); + + assert.strictEqual( $el.attr( 'maxlength' ), undefined, 'maxlength attribute removed for limit with callback' ); + + $( '<input>' ).attr( 'type', 'text' ) + .addClass( 'mw-test-byteLimit-foo' ) + .attr( 'maxlength', '7' ) + .appendTo( '#qunit-fixture' ); + + $( '<input>' ).attr( 'type', 'text' ) + .addClass( 'mw-test-byteLimit-foo' ) + .attr( 'maxlength', '12' ) + .appendTo( '#qunit-fixture' ); + + $el = $( '.mw-test-byteLimit-foo' ); + + assert.strictEqual( $el.length, 2, 'Verify that there are no other elements clashing with this test suite' ); + + $el.byteLimit(); + } ); + + QUnit.test( 'Trim from insertion when limit exceeded', function ( assert ) { + var $el; + + // Use a new <input> because the bug only occurs on the first time + // the limit it reached (T42850) + $el = $( '<input>' ).attr( 'type', 'text' ) + .appendTo( '#qunit-fixture' ) + .byteLimit( 3 ) + .val( 'abc' ).trigger( 'change' ) + .val( 'zabc' ).trigger( 'change' ); + + assert.strictEqual( $el.val(), 'abc', 'Trim from the insertion point (at 0), not the end' ); + + $el = $( '<input>' ).attr( 'type', 'text' ) + .appendTo( '#qunit-fixture' ) + .byteLimit( 3 ) + .val( 'abc' ).trigger( 'change' ) + .val( 'azbc' ).trigger( 'change' ); + + assert.strictEqual( $el.val(), 'abc', 'Trim from the insertion point (at 1), not the end' ); + } ); + + QUnit.test( 'Do not cut up false matching substrings in emoji insertions', function ( assert ) { + var $el, + oldVal = '\uD83D\uDCA9\uD83D\uDCA9', // "💩💩" + newVal = '\uD83D\uDCA9\uD83D\uDCB9\uD83E\uDCA9\uD83D\uDCA9', // "💩💹🢩💩" + expected = '\uD83D\uDCA9\uD83D\uDCB9\uD83D\uDCA9'; // "💩💹💩" + + // Possible bad results: + // * With no surrogate support: + // '\uD83D\uDCA9\uD83D\uDCB9\uD83E\uDCA9' "💩💹🢩" + // * With correct trimming but bad detection of inserted text: + // '\uD83D\uDCA9\uD83D\uDCB9\uDCA9' "💩💹�" + + $el = $( '<input>' ).attr( 'type', 'text' ) + .appendTo( '#qunit-fixture' ) + .byteLimit( 12 ) + .val( oldVal ).trigger( 'change' ) + .val( newVal ).trigger( 'change' ); + + assert.strictEqual( $el.val(), expected, 'Pasted emoji correctly trimmed at the end' ); + } ); + + byteLimitTest( { + description: 'Unpaired surrogates do not crash', + $input: $( '<input>' ).attr( 'type', 'text' ).byteLimit( 4 ), + sample: '\uD800\uD800\uDFFF', + expected: '\uD800' + } ); + +}( jQuery, mediaWiki ) ); diff --git a/www/wiki/tests/qunit/suites/resources/jquery/jquery.localize.test.js b/www/wiki/tests/qunit/suites/resources/jquery/jquery.localize.test.js new file mode 100644 index 00000000..a3e46abe --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/jquery/jquery.localize.test.js @@ -0,0 +1,135 @@ +( function ( $, mw ) { + QUnit.module( 'jquery.localize', QUnit.newMwEnvironment() ); + + QUnit.test( 'Handle basic replacements', function ( assert ) { + var html, $lc; + mw.messages.set( 'basic', 'Basic stuff' ); + + // Tag: html:msg + html = '<div><span><html:msg key="basic" /></span></div>'; + $lc = $( html ).localize().find( 'span' ); + + assert.strictEqual( $lc.text(), 'Basic stuff', 'Tag: html:msg' ); + + // Attribute: title-msg + html = '<div><span title-msg="basic"></span></div>'; + $lc = $( html ).localize().find( 'span' ); + + assert.strictEqual( $lc.attr( 'title' ), 'Basic stuff', 'Attribute: title-msg' ); + + // Attribute: alt-msg + html = '<div><span alt-msg="basic"></span></div>'; + $lc = $( html ).localize().find( 'span' ); + + assert.strictEqual( $lc.attr( 'alt' ), 'Basic stuff', 'Attribute: alt-msg' ); + + // Attribute: placeholder-msg + html = '<div><input placeholder-msg="basic" /></div>'; + $lc = $( html ).localize().find( 'input' ); + + assert.strictEqual( $lc.attr( 'placeholder' ), 'Basic stuff', 'Attribute: placeholder-msg' ); + } ); + + QUnit.test( 'Proper escaping', function ( assert ) { + var html, $lc; + mw.messages.set( 'properfoo', '<proper esc="test">' ); + + // This is handled by jQuery inside $.fn.localize, just a simple sanity checked + // making sure it is actually using text() and attr() (or something with the same effect) + + // Text escaping + html = '<div><span><html:msg key="properfoo" /></span></div>'; + $lc = $( html ).localize().find( 'span' ); + + assert.strictEqual( $lc.text(), mw.msg( 'properfoo' ), 'Content is inserted as text, not as html.' ); + + // Attribute escaping + html = '<div><span title-msg="properfoo"></span></div>'; + $lc = $( html ).localize().find( 'span' ); + + assert.strictEqual( $lc.attr( 'title' ), mw.msg( 'properfoo' ), 'Attributes are not inserted raw.' ); + } ); + + QUnit.test( 'Options', function ( assert ) { + var html, $lc, x, sitename = 'Wikipedia'; + mw.messages.set( { + 'foo-lorem': 'Lorem', + 'foo-ipsum': 'Ipsum', + 'foo-bar-title': 'Read more about bars', + 'foo-bar-label': 'The Bars', + 'foo-bazz-title': 'Read more about bazz at $1 (last modified: $2)', + 'foo-bazz-label': 'The Bazz ($1)', + 'foo-welcome': 'Welcome to $1! (last visit: $2)' + } ); + + // Message key prefix + html = '<div><span title-msg="lorem"><html:msg key="ipsum" /></span></div>'; + $lc = $( html ).localize( { + prefix: 'foo-' + } ).find( 'span' ); + + assert.strictEqual( $lc.attr( 'title' ), 'Lorem', 'Message key prefix - attr' ); + assert.strictEqual( $lc.text(), 'Ipsum', 'Message key prefix - text' ); + + // Variable keys mapping + x = 'bar'; + html = '<div><span title-msg="title"><html:msg key="label" /></span></div>'; + $lc = $( html ).localize( { + keys: { + title: 'foo-' + x + '-title', + label: 'foo-' + x + '-label' + } + } ).find( 'span' ); + + assert.strictEqual( $lc.attr( 'title' ), 'Read more about bars', 'Variable keys mapping - attr' ); + assert.strictEqual( $lc.text(), 'The Bars', 'Variable keys mapping - text' ); + + // Passing parameteters to mw.msg + html = '<div><span><html:msg key="foo-welcome" /></span></div>'; + $lc = $( html ).localize( { + params: { + 'foo-welcome': [ sitename, 'yesterday' ] + } + } ).find( 'span' ); + + assert.strictEqual( $lc.text(), 'Welcome to Wikipedia! (last visit: yesterday)', 'Passing parameteters to mw.msg' ); + + // Combination of options prefix, params and keys + x = 'bazz'; + html = '<div><span title-msg="title"><html:msg key="label" /></span></div>'; + $lc = $( html ).localize( { + prefix: 'foo-', + keys: { + title: x + '-title', + label: x + '-label' + }, + params: { + title: [ sitename, '3 minutes ago' ], + label: [ sitename, '3 minutes ago' ] + + } + } ).find( 'span' ); + + assert.strictEqual( $lc.text(), 'The Bazz (Wikipedia)', 'Combination of options prefix, params and keys - text' ); + assert.strictEqual( $lc.attr( 'title' ), 'Read more about bazz at Wikipedia (last modified: 3 minutes ago)', 'Combination of options prefix, params and keys - attr' ); + } ); + + QUnit.test( 'Handle data text', function ( assert ) { + var html, $lc; + mw.messages.set( 'option-one', 'Item 1' ); + mw.messages.set( 'option-two', 'Item 2' ); + html = '<select><option data-msg-text="option-one"></option><option data-msg-text="option-two"></option></select>'; + $lc = $( html ).localize().find( 'option' ); + assert.strictEqual( $lc.eq( 0 ).text(), mw.msg( 'option-one' ), 'data-msg-text becomes text of options' ); + assert.strictEqual( $lc.eq( 1 ).text(), mw.msg( 'option-two' ), 'data-msg-text becomes text of options' ); + } ); + + QUnit.test( 'Handle data html', function ( assert ) { + var html, $lc; + mw.messages.set( 'html', 'behold... there is a <a>link</a> here!!' ); + html = '<div><div data-msg-html="html"></div></div>'; + $lc = $( html ).localize().find( 'a' ); + assert.strictEqual( $lc.length, 1, 'link is created' ); + assert.strictEqual( $lc.text(), 'link', 'the link text got added' ); + } ); +}( jQuery, mediaWiki ) ); diff --git a/www/wiki/tests/qunit/suites/resources/jquery/jquery.makeCollapsible.test.js b/www/wiki/tests/qunit/suites/resources/jquery/jquery.makeCollapsible.test.js new file mode 100644 index 00000000..d51dc373 --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/jquery/jquery.makeCollapsible.test.js @@ -0,0 +1,377 @@ +( function ( $ ) { + var loremIpsum = 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit.'; + + QUnit.module( 'jquery.makeCollapsible', QUnit.newMwEnvironment() ); + + function prepareCollapsible( html, options ) { + return $( $.parseHTML( html ) ) + .appendTo( '#qunit-fixture' ) + // options might be undefined here - this is okay + .makeCollapsible( options ); + } + + // This test is first because if it fails, then almost all of the latter tests are meaningless. + QUnit.test( 'testing hooks/triggers', function ( assert ) { + var $collapsible = prepareCollapsible( + '<div class="mw-collapsible">' + loremIpsum + '</div>' + ), + $content = $collapsible.find( '.mw-collapsible-content' ), + $toggle = $collapsible.find( '.mw-collapsible-toggle' ); + + // In one full collapse-expand cycle, each event will be fired once + + // On collapse... + $collapsible.on( 'beforeCollapse.mw-collapsible', function () { + assert.assertTrue( $content.is( ':visible' ), 'first beforeCollapseExpand: content is visible' ); + } ); + $collapsible.on( 'afterCollapse.mw-collapsible', function () { + assert.assertTrue( $content.is( ':hidden' ), 'first afterCollapseExpand: content is hidden' ); + + // On expand... + $collapsible.on( 'beforeExpand.mw-collapsible', function () { + assert.assertTrue( $content.is( ':hidden' ), 'second beforeCollapseExpand: content is hidden' ); + } ); + $collapsible.on( 'afterExpand.mw-collapsible', function () { + assert.assertTrue( $content.is( ':visible' ), 'second afterCollapseExpand: content is visible' ); + } ); + + // ...expanding happens here + $toggle.trigger( 'click' ); + } ); + + // ...collapsing happens here + $toggle.trigger( 'click' ); + } ); + + QUnit.test( 'basic operation (<div>)', function ( assert ) { + var $collapsible = prepareCollapsible( + '<div class="mw-collapsible">' + loremIpsum + '</div>' + ), + $content = $collapsible.find( '.mw-collapsible-content' ), + $toggle = $collapsible.find( '.mw-collapsible-toggle' ); + + assert.equal( $content.length, 1, 'content is present' ); + assert.equal( $content.find( $toggle ).length, 0, 'toggle is not a descendant of content' ); + + assert.assertTrue( $content.is( ':visible' ), 'content is visible' ); + + $collapsible.on( 'afterCollapse.mw-collapsible', function () { + assert.assertTrue( $content.is( ':hidden' ), 'after collapsing: content is hidden' ); + + $collapsible.on( 'afterExpand.mw-collapsible', function () { + assert.assertTrue( $content.is( ':visible' ), 'after expanding: content is visible' ); + } ); + + $toggle.trigger( 'click' ); + } ); + + $toggle.trigger( 'click' ); + } ); + + QUnit.test( 'basic operation (<table>)', function ( assert ) { + var $collapsible = prepareCollapsible( + '<table class="mw-collapsible">' + + '<tr><td>' + loremIpsum + '</td><td>' + loremIpsum + '</td></tr>' + + '<tr><td>' + loremIpsum + '</td><td>' + loremIpsum + '</td></tr>' + + '<tr><td>' + loremIpsum + '</td><td>' + loremIpsum + '</td></tr>' + + '</table>' + ), + $headerRow = $collapsible.find( 'tr:first' ), + $contentRow = $collapsible.find( 'tr:last' ), + $toggle = $headerRow.find( 'td:last .mw-collapsible-toggle' ); + + assert.equal( $toggle.length, 1, 'toggle is added to last cell of first row' ); + + assert.assertTrue( $headerRow.is( ':visible' ), 'headerRow is visible' ); + assert.assertTrue( $contentRow.is( ':visible' ), 'contentRow is visible' ); + + $collapsible.on( 'afterCollapse.mw-collapsible', function () { + assert.assertTrue( $headerRow.is( ':visible' ), 'after collapsing: headerRow is still visible' ); + assert.assertTrue( $contentRow.is( ':hidden' ), 'after collapsing: contentRow is hidden' ); + + $collapsible.on( 'afterExpand.mw-collapsible', function () { + assert.assertTrue( $headerRow.is( ':visible' ), 'after expanding: headerRow is still visible' ); + assert.assertTrue( $contentRow.is( ':visible' ), 'after expanding: contentRow is visible' ); + } ); + + $toggle.trigger( 'click' ); + } ); + + $toggle.trigger( 'click' ); + } ); + + function tableWithCaptionTest( $collapsible, test, assert ) { + var $caption = $collapsible.find( 'caption' ), + $headerRow = $collapsible.find( 'tr:first' ), + $contentRow = $collapsible.find( 'tr:last' ), + $toggle = $caption.find( '.mw-collapsible-toggle' ); + + assert.equal( $toggle.length, 1, 'toggle is added to the end of the caption' ); + + assert.assertTrue( $caption.is( ':visible' ), 'caption is visible' ); + assert.assertTrue( $headerRow.is( ':visible' ), 'headerRow is visible' ); + assert.assertTrue( $contentRow.is( ':visible' ), 'contentRow is visible' ); + + $collapsible.on( 'afterCollapse.mw-collapsible', function () { + assert.assertTrue( $caption.is( ':visible' ), 'after collapsing: caption is still visible' ); + assert.assertTrue( $headerRow.is( ':hidden' ), 'after collapsing: headerRow is hidden' ); + assert.assertTrue( $contentRow.is( ':hidden' ), 'after collapsing: contentRow is hidden' ); + + $collapsible.on( 'afterExpand.mw-collapsible', function () { + assert.assertTrue( $caption.is( ':visible' ), 'after expanding: caption is still visible' ); + assert.assertTrue( $headerRow.is( ':visible' ), 'after expanding: headerRow is visible' ); + assert.assertTrue( $contentRow.is( ':visible' ), 'after expanding: contentRow is visible' ); + } ); + + $toggle.trigger( 'click' ); + } ); + + $toggle.trigger( 'click' ); + } + + QUnit.test( 'basic operation (<table> with caption)', function ( assert ) { + tableWithCaptionTest( prepareCollapsible( + '<table class="mw-collapsible">' + + '<caption>' + loremIpsum + '</caption>' + + '<tr><th>' + loremIpsum + '</th><th>' + loremIpsum + '</th></tr>' + + '<tr><td>' + loremIpsum + '</td><td>' + loremIpsum + '</td></tr>' + + '<tr><td>' + loremIpsum + '</td><td>' + loremIpsum + '</td></tr>' + + '</table>' + ), this, assert ); + } ); + + QUnit.test( 'basic operation (<table> with caption and <thead>)', function ( assert ) { + tableWithCaptionTest( prepareCollapsible( + '<table class="mw-collapsible">' + + '<caption>' + loremIpsum + '</caption>' + + '<thead><tr><th>' + loremIpsum + '</th><th>' + loremIpsum + '</th></tr></thead>' + + '<tr><td>' + loremIpsum + '</td><td>' + loremIpsum + '</td></tr>' + + '<tr><td>' + loremIpsum + '</td><td>' + loremIpsum + '</td></tr>' + + '</table>' + ), this, assert ); + } ); + + function listTest( listType, test, assert ) { + var $collapsible = prepareCollapsible( + '<' + listType + ' class="mw-collapsible">' + + '<li>' + loremIpsum + '</li>' + + '<li>' + loremIpsum + '</li>' + + '</' + listType + '>' + ), + $toggleItem = $collapsible.find( 'li.mw-collapsible-toggle-li:first-child' ), + $contentItem = $collapsible.find( 'li:last' ), + $toggle = $toggleItem.find( '.mw-collapsible-toggle' ); + + assert.equal( $toggle.length, 1, 'toggle is present, added inside new zeroth list item' ); + + assert.assertTrue( $toggleItem.is( ':visible' ), 'toggleItem is visible' ); + assert.assertTrue( $contentItem.is( ':visible' ), 'contentItem is visible' ); + + $collapsible.on( 'afterCollapse.mw-collapsible', function () { + assert.assertTrue( $toggleItem.is( ':visible' ), 'after collapsing: toggleItem is still visible' ); + assert.assertTrue( $contentItem.is( ':hidden' ), 'after collapsing: contentItem is hidden' ); + + $collapsible.on( 'afterExpand.mw-collapsible', function () { + assert.assertTrue( $toggleItem.is( ':visible' ), 'after expanding: toggleItem is still visible' ); + assert.assertTrue( $contentItem.is( ':visible' ), 'after expanding: contentItem is visible' ); + } ); + + $toggle.trigger( 'click' ); + } ); + + $toggle.trigger( 'click' ); + } + + QUnit.test( 'basic operation (<ul>)', function ( assert ) { + listTest( 'ul', this, assert ); + } ); + + QUnit.test( 'basic operation (<ol>)', function ( assert ) { + listTest( 'ol', this, assert ); + } ); + + QUnit.test( 'basic operation when synchronous (options.instantHide)', function ( assert ) { + var $collapsible = prepareCollapsible( + '<div class="mw-collapsible">' + loremIpsum + '</div>', + { instantHide: true } + ), + $content = $collapsible.find( '.mw-collapsible-content' ); + + assert.assertTrue( $content.is( ':visible' ), 'content is visible' ); + + $collapsible.find( '.mw-collapsible-toggle' ).trigger( 'click' ); + + assert.assertTrue( $content.is( ':hidden' ), 'after collapsing: content is hidden' ); + } ); + + QUnit.test( 'mw-made-collapsible data added', function ( assert ) { + var $collapsible = prepareCollapsible( + '<div>' + loremIpsum + '</div>' + ); + + assert.equal( $collapsible.data( 'mw-made-collapsible' ), true, 'mw-made-collapsible data present' ); + } ); + + QUnit.test( 'mw-collapsible added when missing', function ( assert ) { + var $collapsible = prepareCollapsible( + '<div>' + loremIpsum + '</div>' + ); + + assert.assertTrue( $collapsible.hasClass( 'mw-collapsible' ), 'mw-collapsible class present' ); + } ); + + QUnit.test( 'mw-collapsed added when missing', function ( assert ) { + var $collapsible = prepareCollapsible( + '<div>' + loremIpsum + '</div>', + { collapsed: true } + ); + + assert.assertTrue( $collapsible.hasClass( 'mw-collapsed' ), 'mw-collapsed class present' ); + } ); + + QUnit.test( 'initial collapse (mw-collapsed class)', function ( assert ) { + var $collapsible = prepareCollapsible( + '<div class="mw-collapsible mw-collapsed">' + loremIpsum + '</div>' + ), + $content = $collapsible.find( '.mw-collapsible-content' ); + + // Synchronous - mw-collapsed should cause instantHide: true to be used on initial collapsing + assert.assertTrue( $content.is( ':hidden' ), 'content is hidden' ); + + $collapsible.on( 'afterExpand.mw-collapsible', function () { + assert.assertTrue( $content.is( ':visible' ), 'after expanding: content is visible' ); + } ); + + $collapsible.find( '.mw-collapsible-toggle' ).trigger( 'click' ); + } ); + + QUnit.test( 'initial collapse (options.collapsed)', function ( assert ) { + var $collapsible = prepareCollapsible( + '<div class="mw-collapsible">' + loremIpsum + '</div>', + { collapsed: true } + ), + $content = $collapsible.find( '.mw-collapsible-content' ); + + // Synchronous - collapsed: true should cause instantHide: true to be used on initial collapsing + assert.assertTrue( $content.is( ':hidden' ), 'content is hidden' ); + + $collapsible.on( 'afterExpand.mw-collapsible', function () { + assert.assertTrue( $content.is( ':visible' ), 'after expanding: content is visible' ); + } ); + + $collapsible.find( '.mw-collapsible-toggle' ).trigger( 'click' ); + } ); + + QUnit.test( 'clicks on links inside toggler pass through', function ( assert ) { + var $collapsible = prepareCollapsible( + '<div class="mw-collapsible">' + + '<div class="mw-collapsible-toggle">' + + 'Toggle <a href="#top">toggle</a> toggle <b>toggle</b>' + + '</div>' + + '<div class="mw-collapsible-content">' + loremIpsum + '</div>' + + '</div>', + // Can't do asynchronous because we're testing that the event *doesn't* happen + { instantHide: true } + ), + $content = $collapsible.find( '.mw-collapsible-content' ); + + $collapsible.find( '.mw-collapsible-toggle a' ).trigger( 'click' ); + assert.assertTrue( $content.is( ':visible' ), 'click event on link inside toggle passes through (content not toggled)' ); + + $collapsible.find( '.mw-collapsible-toggle b' ).trigger( 'click' ); + assert.assertTrue( $content.is( ':hidden' ), 'click event on non-link inside toggle toggles content' ); + } ); + + QUnit.test( 'click on non-link inside toggler counts as trigger', function ( assert ) { + var $collapsible = prepareCollapsible( + '<div class="mw-collapsible">' + + '<div class="mw-collapsible-toggle">' + + 'Toggle <a>toggle</a> toggle <b>toggle</b>' + + '</div>' + + '<div class="mw-collapsible-content">' + loremIpsum + '</div>' + + '</div>', + { instantHide: true } + ), + $content = $collapsible.find( '.mw-collapsible-content' ); + + $collapsible.find( '.mw-collapsible-toggle a' ).trigger( 'click' ); + assert.assertTrue( $content.is( ':hidden' ), 'click event on link (with no href) inside toggle toggles content' ); + } ); + + QUnit.test( 'collapse/expand text (data-collapsetext, data-expandtext)', function ( assert ) { + var $collapsible = prepareCollapsible( + '<div class="mw-collapsible" data-collapsetext="Collapse me!" data-expandtext="Expand me!">' + + loremIpsum + + '</div>' + ), + $toggleText = $collapsible.find( '.mw-collapsible-text' ); + + assert.equal( $toggleText.text(), 'Collapse me!', 'data-collapsetext is respected' ); + + $collapsible.on( 'afterCollapse.mw-collapsible', function () { + assert.equal( $toggleText.text(), 'Expand me!', 'data-expandtext is respected' ); + } ); + + $collapsible.find( '.mw-collapsible-toggle' ).trigger( 'click' ); + } ); + + QUnit.test( 'collapse/expand text (options.collapseText, options.expandText)', function ( assert ) { + var $collapsible = prepareCollapsible( + '<div class="mw-collapsible">' + loremIpsum + '</div>', + { collapseText: 'Collapse me!', expandText: 'Expand me!' } + ), + $toggleText = $collapsible.find( '.mw-collapsible-text' ); + + assert.equal( $toggleText.text(), 'Collapse me!', 'options.collapseText is respected' ); + + $collapsible.on( 'afterCollapse.mw-collapsible', function () { + assert.equal( $toggleText.text(), 'Expand me!', 'options.expandText is respected' ); + } ); + + $collapsible.find( '.mw-collapsible-toggle' ).trigger( 'click' ); + } ); + + QUnit.test( 'predefined toggle button and text (.mw-collapsible-toggle/.mw-collapsible-text)', function ( assert ) { + var $collapsible = prepareCollapsible( + '<div class="mw-collapsible">' + + '<div class="mw-collapsible-toggle">' + + '<span>[</span><span class="mw-collapsible-text">Toggle</span><span>]</span>' + + '</div>' + + '<div class="mw-collapsible-content">' + loremIpsum + '</div>' + + '</div>', + { collapseText: 'Hide', expandText: 'Show' } + ), + $toggleText = $collapsible.find( '.mw-collapsible-text' ); + + assert.equal( $toggleText.text(), 'Toggle', 'predefined text remains' ); + + $collapsible.on( 'afterCollapse.mw-collapsible', function () { + assert.equal( $toggleText.text(), 'Show', 'predefined text is toggled' ); + + $collapsible.on( 'afterExpand.mw-collapsible', function () { + assert.equal( $toggleText.text(), 'Hide', 'predefined text is toggled back' ); + } ); + + $collapsible.find( '.mw-collapsible-toggle' ).trigger( 'click' ); + } ); + + $collapsible.find( '.mw-collapsible-toggle' ).trigger( 'click' ); + } ); + + QUnit.test( 'cloned collapsibles can be made collapsible again', function ( assert ) { + var $collapsible = prepareCollapsible( + '<div class="mw-collapsible">' + loremIpsum + '</div>' + ), + $clone = $collapsible.clone() // clone without data and events + .appendTo( '#qunit-fixture' ).makeCollapsible(), + $content = $clone.find( '.mw-collapsible-content' ); + + assert.assertTrue( $content.is( ':visible' ), 'content is visible' ); + + $clone.on( 'afterCollapse.mw-collapsible', function () { + assert.assertTrue( $content.is( ':hidden' ), 'after collapsing: content is hidden' ); + } ); + + $clone.find( '.mw-collapsible-toggle a' ).trigger( 'click' ); + } ); +}( jQuery ) ); diff --git a/www/wiki/tests/qunit/suites/resources/jquery/jquery.tabIndex.test.js b/www/wiki/tests/qunit/suites/resources/jquery/jquery.tabIndex.test.js new file mode 100644 index 00000000..ec3539b9 --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/jquery/jquery.tabIndex.test.js @@ -0,0 +1,35 @@ +( function ( $ ) { + QUnit.module( 'jquery.tabIndex', QUnit.newMwEnvironment() ); + + QUnit.test( 'firstTabIndex', function ( assert ) { + var html, $testA, $testB; + html = '<form>' + + '<input tabindex="7" />' + + '<input tabindex="9" />' + + '<textarea tabindex="2">Foobar</textarea>' + + '<textarea tabindex="5">Foobar</textarea>' + + '</form>'; + + $testA = $( '<div>' ).html( html ).appendTo( '#qunit-fixture' ); + assert.strictEqual( $testA.firstTabIndex(), 2, 'First tabindex should be 2 within this context.' ); + + $testB = $( '<div>' ); + assert.strictEqual( $testB.firstTabIndex(), null, 'Return null if none available.' ); + } ); + + QUnit.test( 'lastTabIndex', function ( assert ) { + var html, $testA, $testB; + html = '<form>' + + '<input tabindex="7" />' + + '<input tabindex="9" />' + + '<textarea tabindex="2">Foobar</textarea>' + + '<textarea tabindex="5">Foobar</textarea>' + + '</form>'; + + $testA = $( '<div>' ).html( html ).appendTo( '#qunit-fixture' ); + assert.strictEqual( $testA.lastTabIndex(), 9, 'Last tabindex should be 9 within this context.' ); + + $testB = $( '<div>' ); + assert.strictEqual( $testB.lastTabIndex(), null, 'Return null if none available.' ); + } ); +}( jQuery ) ); diff --git a/www/wiki/tests/qunit/suites/resources/jquery/jquery.tablesorter.parsers.test.js b/www/wiki/tests/qunit/suites/resources/jquery/jquery.tablesorter.parsers.test.js new file mode 100644 index 00000000..2865cbba --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/jquery/jquery.tablesorter.parsers.test.js @@ -0,0 +1,267 @@ +( function ( $, mw ) { + /** + * This module tests the input/output capabilities of the parsers of tablesorter. + * It does not test actual sorting. + */ + + var text, ipv4, + simpleMDYDatesInMDY, simpleMDYDatesInDMY, oldMDYDates, complexMDYDates, clobberedDates, MYDates, YDates, ISODates, + currencyData, transformedCurrencyData; + + QUnit.module( 'jquery.tablesorter.parsers', QUnit.newMwEnvironment( { + setup: function () { + this.liveMonths = mw.language.months; + mw.language.months = { + keys: { + names: [ 'january', 'february', 'march', 'april', 'may_long', 'june', + 'july', 'august', 'september', 'october', 'november', 'december' ], + genitive: [ 'january-gen', 'february-gen', 'march-gen', 'april-gen', 'may-gen', 'june-gen', + 'july-gen', 'august-gen', 'september-gen', 'october-gen', 'november-gen', 'december-gen' ], + abbrev: [ 'jan', 'feb', 'mar', 'apr', 'may', 'jun', + 'jul', 'aug', 'sep', 'oct', 'nov', 'dec' ] + }, + names: [ 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December' ], + genitive: [ 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December' ], + abbrev: [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ] + }; + }, + teardown: function () { + mw.language.months = this.liveMonths; + }, + config: { + wgPageContentLanguage: 'en', + /* default date format of the content language */ + wgDefaultDateFormat: 'dmy', + /* These two are important for numeric interpretations */ + wgSeparatorTransformTable: [ '', '' ], + wgDigitTransformTable: [ '', '' ] + } + } ) ); + + /** + * For a value, check if the parser recognizes it and how it transforms it + * + * @param {string} msg text to pass on to qunit describing the test case + * @param {string[]} parserId of the parser that will be tested + * @param {string[][]} data Array of testcases. Each testcase, array of + * inputValue: The string value that we want to test the parser for + * recognized: If we expect that this value's type is detectable by the parser + * outputValue: The value the parser has converted the input to + * msg: describing the testcase + * @param {function($table)} callback something to do before we start the testcase + */ + function parserTest( msg, parserId, data, callback ) { + QUnit.test( msg, function ( assert ) { + var extractedR, extractedF, parser; + + if ( callback !== undefined ) { + callback(); + } + + parser = $.tablesorter.getParser( parserId ); + data.forEach( function ( testcase ) { + extractedR = parser.is( testcase[ 0 ] ); + extractedF = parser.format( testcase[ 0 ] ); + + assert.strictEqual( extractedR, testcase[ 1 ], 'Detect: ' + testcase[ 3 ] ); + assert.strictEqual( extractedF, testcase[ 2 ], 'Sortkey: ' + testcase[ 3 ] ); + } ); + + } ); + } + + text = [ + [ 'Mars', true, 'mars', 'Simple text' ], + [ 'Mẘas', true, 'mẘas', 'Non ascii character' ], + [ 'A sentence', true, 'a sentence', 'A sentence with space chars' ] + ]; + parserTest( 'Textual keys', 'text', text ); + + ipv4 = [ + // Some randomly generated fake IPs + [ '0.0.0.0', true, 0, 'An IP address' ], + [ '255.255.255.255', true, 255255255255, 'An IP address' ], + [ '45.238.27.109', true, 45238027109, 'An IP address' ], + [ '1.238.27.1', true, 1238027001, 'An IP address with small numbers' ], + [ '238.27.1', false, 238027001, 'A malformed IP Address' ], + [ '1', false, 1, 'A super malformed IP Address' ], + [ 'Just text', false, -Infinity, 'A line with just text' ], + [ '45.238.27.109Postfix', false, 45238027109, 'An IP address with a connected postfix' ], + [ '45.238.27.109 postfix', false, 45238027109, 'An IP address with a seperated postfix' ] + ]; + parserTest( 'IPv4', 'IPAddress', ipv4 ); + + simpleMDYDatesInMDY = [ + [ 'January 17, 2010', true, 20100117, 'Long middle endian date' ], + [ 'Jan 17, 2010', true, 20100117, 'Short middle endian date' ], + [ '1/17/2010', true, 20100117, 'Numeric middle endian date' ], + [ '01/17/2010', true, 20100117, 'Numeric middle endian date with padding on month' ], + [ '01/07/2010', true, 20100107, 'Numeric middle endian date with padding on day' ], + [ '01/07/0010', true, 20100107, 'Numeric middle endian date with padding on year' ], + [ '5.12.1990', true, 19900512, 'Numeric middle endian date with . separator' ] + ]; + parserTest( 'MDY Dates using mdy content language', 'date', simpleMDYDatesInMDY ); + + simpleMDYDatesInDMY = [ + [ 'January 17, 2010', true, 20100117, 'Long middle endian date' ], + [ 'Jan 17, 2010', true, 20100117, 'Short middle endian date' ], + [ '1/17/2010', true, 20101701, 'Numeric middle endian date' ], + [ '01/17/2010', true, 20101701, 'Numeric middle endian date with padding on month' ], + [ '01/07/2010', true, 20100701, 'Numeric middle endian date with padding on day' ], + [ '01/07/0010', true, 20100701, 'Numeric middle endian date with padding on year' ], + [ '5.12.1990', true, 19901205, 'Numeric middle endian date with . separator' ] + ]; + parserTest( 'MDY Dates using dmy content language', 'date', simpleMDYDatesInDMY, function () { + mw.config.set( { + wgDefaultDateFormat: 'dmy', + wgPageContentLanguage: 'de' + } ); + } ); + + oldMDYDates = [ + [ 'January 19, 1400 BC', false, '99999999', 'BC' ], + [ 'January 19, 1400BC', false, '99999999', 'Connected BC' ], + [ 'January, 19 1400 B.C.', false, '99999999', 'B.C.' ], + [ 'January 19, 1400 AD', false, '99999999', 'AD' ], + [ 'January, 19 10', true, 20100119, 'AD' ], + [ 'January, 19 1', false, '99999999', 'AD' ] + ]; + parserTest( 'Very old MDY dates', 'date', oldMDYDates ); + + complexMDYDates = [ + [ 'January, 19 2010', true, 20100119, 'Comma after month' ], + [ 'January 19, 2010', true, 20100119, 'Comma after day' ], + [ 'January/19/2010', true, 20100119, 'Forward slash separator' ], + [ '04 22 1991', true, 19910422, 'Month with 0 padding' ], + [ 'April 21 1991', true, 19910421, 'Space separation' ], + [ '04 22 1991', true, 19910422, 'Month with 0 padding' ], + [ 'December 12 \'10', true, 20101212, '' ], + [ 'Dec 12 \'10', true, 20101212, '' ], + [ 'Dec. 12 \'10', true, 20101212, '' ] + ]; + parserTest( 'MDY Dates', 'date', complexMDYDates ); + + clobberedDates = [ + [ 'January, 19 2010 - January, 20 2010', false, '99999999', 'Date range with hyphen' ], + [ 'January, 19 2010 — January, 20 2010', false, '99999999', 'Date range with mdash' ], + [ 'prefixJanuary, 19 2010', false, '99999999', 'Connected prefix' ], + [ 'prefix January, 19 2010', false, '99999999', 'Prefix' ], + [ 'December 12 2010postfix', false, '99999999', 'ConnectedPostfix' ], + [ 'December 12 2010 postfix', false, '99999999', 'Postfix' ], + [ 'A simple text', false, '99999999', 'Plain text in date sort' ], + [ '04l22l1991', false, '99999999', 'l char as separator' ], + [ 'January\\19\\2010', false, '99999999', 'backslash as date separator' ] + ]; + parserTest( 'Clobbered Dates', 'date', clobberedDates ); + + MYDates = [ + [ 'December 2010', false, '99999999', 'Plain month year' ], + [ 'Dec 2010', false, '99999999', 'Abreviated month year' ], + [ '12 2010', false, '99999999', 'Numeric month year' ] + ]; + parserTest( 'MY Dates', 'date', MYDates ); + + YDates = [ + [ '2010', false, '99999999', 'Plain 4-digit year' ], + [ '876', false, '99999999', '3-digit year' ], + [ '76', false, '99999999', '2-digit year' ], + [ '\'76', false, '99999999', '2-digit millenium bug year' ], + [ '2010 BC', false, '99999999', '4-digit year BC' ] + ]; + parserTest( 'Y Dates', 'date', YDates ); + + ISODates = [ + [ '', false, -Infinity, 'Not a date' ], + [ '2000', false, 946684800000, 'Plain 4-digit year' ], + [ '2000-01', true, 946684800000, 'Year with month' ], + [ '2000-01-01', true, 946684800000, 'Year with month and day' ], + [ '2000-13-01', false, 978307200000, 'Non existant month' ], + [ '2000-01-32', true, 949363200000, 'Non existant day' ], + [ '2000-01-01T12:30:30', true, 946729830000, 'Date with a time' ], + [ '2000-01-01T12:30:30Z', true, 946729830000, 'Date with a UTC+0 time' ], + [ '2000-01-01T24:30:30Z', true, 946773030000, 'Date with invalid hours' ], + [ '2000-01-01T12:60:30Z', true, 946728000000, 'Date with invalid minutes' ], + [ '2000-01-01T12:30:61Z', true, 946729800000, 'Date with invalid amount of seconds, drops seconds' ], + [ '2000-01-01T23:59:59Z', true, 946771199000, 'Edges of time' ], + [ '2000-01-01T12:30:30.111Z', true, 946729830111, 'Date with milliseconds' ], + [ '2000-01-01T12:30:30.11111Z', true, 946729830111, 'Date with too high precision' ], + [ '2000-01-01T12:30:30,111Z', true, 946729830111, 'Date with milliseconds and , separator' ], + [ '2000-01-01T12:30:30+01:00', true, 946726230000, 'Date time in UTC+1' ], + [ '2000-01-01T12:30:30+01:30', true, 946724430000, 'Date time in UTC+1:30' ], + [ '2000-01-01T12:30:30-01:00', true, 946733430000, 'Date time in UTC-1' ], + [ '2000-01-01T12:30:30-01:30', true, 946735230000, 'Date time in UTC-1:30' ], + [ '2000-01-01T12:30:30.111+01:00', true, 946726230111, 'Date time and milliseconds in UTC+1' ], + [ '2000-01-01Postfix', true, 946684800000, 'Date with appended postfix' ], + [ '2000-01-01 Postfix', true, 946684800000, 'Date with separate postfix' ], + [ '2 Postfix', false, -62104060800000, 'One digit with separate postfix' ], + [ 'ca. 2', false, -62104060800000, 'Three digit with separate prefix' ], + [ '~200', false, -55855785600000, 'Three digit with appended prefix' ], + [ 'ca. 200[1]', false, -55855785600000, 'Three digit with separate prefix and postfix' ], + [ '2000-11-31', true, 975628800000, '31 days in 30 day month' ], + [ '50-01-01', true, -60589296000000, 'Year with just two digits' ], + [ '2', false, -62104060800000, 'Year with one digit' ], + [ '02-01', true, -62104060800000, 'Year with one digit and leading zero' ], + [ ' 2-01', true, -62104060800000, 'Year with one digit and leading space' ], + [ '-2-10', true, -62206704000000, 'Year BC with month' ], + [ '-9999', false, -377705116800000, 'max. Year BC' ], + [ '+9999-12', true, 253399622400000, 'max. Date with +sign' ], + [ '2000-01-01 12:30:30Z', true, 946729830000, 'Date and time with no T marker' ], + [ '2000-01-01T12:30:60Z', true, 946729860000, 'Date with leap second' ], + [ '2000-01-01T12:30:30-23:59', true, 946816170000, 'Date time in UTC-23:59' ], + [ '2000-01-01T12:30:30+23:59', true, 946643490000, 'Date time in UTC+23:59' ], + [ '2000-01-01T123030+0100', true, 946726230000, 'Time without separators' ], + [ '20000101T123030+0100', false, 946726230000, 'All without separators' ] + ]; + parserTest( 'ISO Dates', 'isoDate', ISODates ); + + currencyData = [ + [ '1.02 $', true, 1.02, '' ], + [ '$ 3.00', true, 3, '' ], + [ '€ 2,99', true, 299, '' ], + [ '$ 1.00', true, 1, '' ], + [ '$3.50', true, 3.50, '' ], + [ '$ 1.50', true, 1.50, '' ], + [ '€ 0.99', true, 0.99, '' ], + [ '$ 299.99', true, 299.99, '' ], + [ '$ 2,299.99', true, 2299.99, '' ], + [ '$ 2,989', true, 2989, '' ], + [ '$ 2 299.99', true, 2299.99, '' ], + [ '$ 2 989', true, 2989, '' ], + [ '$ 2.989', true, 2.989, '' ] + ]; + parserTest( 'Currency', 'currency', currencyData ); + + transformedCurrencyData = [ + [ '1.02 $', true, 102, '' ], + [ '$ 3.00', true, 300, '' ], + [ '€ 2,99', true, 2.99, '' ], + [ '$ 1.00', true, 100, '' ], + [ '$3.50', true, 350, '' ], + [ '$ 1.50', true, 150, '' ], + [ '€ 0.99', true, 99, '' ], + [ '$ 299.99', true, 29999, '' ], + [ '$ 2\'299,99', true, 2299.99, '' ], + [ '$ 2,989', true, 2.989, '' ], + [ '$ 2 299.99', true, 229999, '' ], + [ '2 989 $', true, 2989, '' ], + [ '299.99 $', true, 29999, '' ], + [ '2\'299,99 $', true, 2299.99, '' ], + [ '2,989 $', true, 2.989, '' ], + [ '2 299.99 $', true, 229999, '' ], + [ '2 989 $', true, 2989, '' ] + ]; + parserTest( 'Currency with european separators', 'currency', transformedCurrencyData, function () { + mw.config.set( { + // We expect 22'234.444,22 + // Map from ascii separators => localized separators + wgSeparatorTransformTable: [ ', . ,', '\' , .' ], + wgDigitTransformTable: [ '', '' ] + } ); + } ); + + // TODO add numbers sorting tests for T10115 with a different language + +}( jQuery, mediaWiki ) ); diff --git a/www/wiki/tests/qunit/suites/resources/jquery/jquery.tablesorter.test.js b/www/wiki/tests/qunit/suites/resources/jquery/jquery.tablesorter.test.js new file mode 100644 index 00000000..23ef26f6 --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/jquery/jquery.tablesorter.test.js @@ -0,0 +1,1498 @@ +( function ( $, mw ) { + var header = [ 'Planet', 'Radius (km)' ], + + // Data set "planets" + mercury = [ 'Mercury', '2439.7' ], + venus = [ 'Venus', '6051.8' ], + earth = [ 'Earth', '6371.0' ], + mars = [ 'Mars', '3390.0' ], + jupiter = [ 'Jupiter', '69911' ], + saturn = [ 'Saturn', '58232' ], + planets = [ mercury, venus, earth, mars, jupiter, saturn ], + planetsAscName = [ earth, jupiter, mars, mercury, saturn, venus ], + planetsAscRadius = [ mercury, mars, venus, earth, saturn, jupiter ], + planetsRowspan, + planetsRowspanII, + planetsAscNameLegacy, + + // Data set "simple" + a1 = [ 'A', '1' ], + a2 = [ 'A', '2' ], + a3 = [ 'A', '3' ], + b1 = [ 'B', '1' ], + b2 = [ 'B', '2' ], + b3 = [ 'B', '3' ], + simple = [ a2, b3, a1, a3, b2, b1 ], + simpleAsc = [ a1, a2, a3, b1, b2, b3 ], + simpleDescasc = [ b1, b2, b3, a1, a2, a3 ], + + // Data set "colspan" + header4 = [ 'column1a', 'column1b', 'column1c', 'column2' ], + aaa1 = [ 'A', 'A', 'A', '1' ], + aab5 = [ 'A', 'A', 'B', '5' ], + abc3 = [ 'A', 'B', 'C', '3' ], + bbc2 = [ 'B', 'B', 'C', '2' ], + caa4 = [ 'C', 'A', 'A', '4' ], + colspanInitial = [ aab5, aaa1, abc3, bbc2, caa4 ], + + // Data set "ipv4" + ipv4 = [ + // Some randomly generated fake IPs + [ '45.238.27.109' ], + [ '44.172.9.22' ], + [ '247.240.82.209' ], + [ '204.204.132.158' ], + [ '170.38.91.162' ], + [ '197.219.164.9' ], + [ '45.68.154.72' ], + [ '182.195.149.80' ] + ], + ipv4Sorted = [ + // Sort order should go octet by octet + [ '44.172.9.22' ], + [ '45.68.154.72' ], + [ '45.238.27.109' ], + [ '170.38.91.162' ], + [ '182.195.149.80' ], + [ '197.219.164.9' ], + [ '204.204.132.158' ], + [ '247.240.82.209' ] + ], + + // Data set "umlaut" + umlautWords = [ + [ 'Günther' ], + [ 'Peter' ], + [ 'Björn' ], + [ 'Bjorn' ], + [ 'Apfel' ], + [ 'Äpfel' ], + [ 'Strasse' ], + [ 'Sträßschen' ] + ], + umlautWordsSorted = [ + [ 'Äpfel' ], + [ 'Apfel' ], + [ 'Björn' ], + [ 'Bjorn' ], + [ 'Günther' ], + [ 'Peter' ], + [ 'Sträßschen' ], + [ 'Strasse' ] + ], + + // Data set "digraph" + digraphWords = [ + [ 'London' ], + [ 'Ljubljana' ], + [ 'Luxembourg' ], + [ 'Njivice' ], + [ 'Norwich' ], + [ 'New York' ] + ], + digraphWordsSorted = [ + [ 'London' ], + [ 'Luxembourg' ], + [ 'Ljubljana' ], + [ 'New York' ], + [ 'Norwich' ], + [ 'Njivice' ] + ], + + complexMDYDates = [ + [ 'January, 19 2010' ], + [ 'April 21 1991' ], + [ '04 22 1991' ], + [ '5.12.1990' ], + [ 'December 12 \'10' ] + ], + complexMDYSorted = [ + [ '5.12.1990' ], + [ 'April 21 1991' ], + [ '04 22 1991' ], + [ 'January, 19 2010' ], + [ 'December 12 \'10' ] + ], + + currencyUnsorted = [ + [ '1.02 $' ], + [ '$ 3.00' ], + [ '€ 2,99' ], + [ '$ 1.00' ], + [ '$3.50' ], + [ '$ 1.50' ], + [ '€ 0.99' ] + ], + currencySorted = [ + [ '€ 0.99' ], + [ '$ 1.00' ], + [ '1.02 $' ], + [ '$ 1.50' ], + [ '$ 3.00' ], + [ '$3.50' ], + // Commas sort after dots + // Not intentional but test to detect changes + [ '€ 2,99' ] + ], + + numbers = [ + [ '12' ], + [ '7' ], + [ '13,000' ], + [ '9' ], + [ '14' ], + [ '8.0' ] + ], + numbersAsc = [ + [ '7' ], + [ '8.0' ], + [ '9' ], + [ '12' ], + [ '14' ], + [ '13,000' ] + ], + + correctDateSorting1 = [ + [ '01 January 2010' ], + [ '05 February 2010' ], + [ '16 January 2010' ] + ], + correctDateSortingSorted1 = [ + [ '01 January 2010' ], + [ '16 January 2010' ], + [ '05 February 2010' ] + ], + + correctDateSorting2 = [ + [ 'January 01 2010' ], + [ 'February 05 2010' ], + [ 'January 16 2010' ] + ], + correctDateSortingSorted2 = [ + [ 'January 01 2010' ], + [ 'January 16 2010' ], + [ 'February 05 2010' ] + ], + isoDateSorting = [ + [ '2010-02-01' ], + [ '2009-12-25T12:30:45.001Z' ], + [ '2010-01-31' ], + [ '2009' ], + [ '2009-12-25T12:30:45' ], + [ '2009-12-25T12:30:45.111' ], + [ '2009-12-25T12:30:45+01:00' ] + ], + isoDateSortingSorted = [ + [ '2009' ], + [ '2009-12-25T12:30:45+01:00' ], + [ '2009-12-25T12:30:45' ], + [ '2009-12-25T12:30:45.001Z' ], + [ '2009-12-25T12:30:45.111' ], + [ '2010-01-31' ], + [ '2010-02-01' ] + ]; + + QUnit.module( 'jquery.tablesorter', QUnit.newMwEnvironment( { + setup: function () { + this.liveMonths = mw.language.months; + mw.language.months = { + keys: { + names: [ 'january', 'february', 'march', 'april', 'may_long', 'june', + 'july', 'august', 'september', 'october', 'november', 'december' ], + genitive: [ 'january-gen', 'february-gen', 'march-gen', 'april-gen', 'may-gen', 'june-gen', + 'july-gen', 'august-gen', 'september-gen', 'october-gen', 'november-gen', 'december-gen' ], + abbrev: [ 'jan', 'feb', 'mar', 'apr', 'may', 'jun', + 'jul', 'aug', 'sep', 'oct', 'nov', 'dec' ] + }, + names: [ 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December' ], + genitive: [ 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December' ], + abbrev: [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ] + }; + }, + teardown: function () { + mw.language.months = this.liveMonths; + }, + config: { + wgDefaultDateFormat: 'dmy', + wgSeparatorTransformTable: [ '', '' ], + wgDigitTransformTable: [ '', '' ], + wgPageContentLanguage: 'en' + } + } ) ); + + /** + * Create an HTML table from an array of row arrays containing text strings. + * First row will be header row. No fancy rowspan/colspan stuff. + * + * @param {string[]} header + * @param {string[][]} data + * @return {jQuery} + */ + function tableCreate( header, data ) { + var i, + $table = $( '<table class="sortable"><thead></thead><tbody></tbody></table>' ), + $thead = $table.find( 'thead' ), + $tbody = $table.find( 'tbody' ), + $tr = $( '<tr>' ); + + header.forEach( function ( str ) { + var $th = $( '<th>' ); + $th.text( str ).appendTo( $tr ); + } ); + $tr.appendTo( $thead ); + + for ( i = 0; i < data.length; i++ ) { + $tr = $( '<tr>' ); + // eslint-disable-next-line no-loop-func + data[ i ].forEach( function ( str ) { + var $td = $( '<td>' ); + $td.text( str ).appendTo( $tr ); + } ); + $tr.appendTo( $tbody ); + } + return $table; + } + + /** + * Extract text from table. + * + * @param {jQuery} $table + * @return {string[][]} + */ + function tableExtract( $table ) { + var data = []; + + $table.find( 'tbody' ).find( 'tr' ).each( function ( i, tr ) { + var row = []; + $( tr ).find( 'td,th' ).each( function ( i, td ) { + row.push( $( td ).text() ); + } ); + data.push( row ); + } ); + return data; + } + + /** + * Run a table test by building a table with the given data, + * running some callback on it, then checking the results. + * + * @param {string} msg text to pass on to qunit for the comparison + * @param {string[]} header cols to make the table + * @param {string[][]} data rows/cols to make the table + * @param {string[][]} expected rows/cols to compare against at end + * @param {function($table)} callback something to do with the table before we compare + */ + function tableTest( msg, header, data, expected, callback ) { + QUnit.test( msg, function ( assert ) { + var extracted, + $table = tableCreate( header, data ); + + // Give caller a chance to set up sorting and manipulate the table. + callback( $table ); + + // Table sorting is done synchronously; if it ever needs to change back + // to asynchronous, we'll need a timeout or a callback here. + extracted = tableExtract( $table ); + assert.deepEqual( extracted, expected, msg ); + } ); + } + + /** + * Run a table test by building a table with the given HTML, + * running some callback on it, then checking the results. + * + * @param {string} msg text to pass on to qunit for the comparison + * @param {string} html HTML to make the table + * @param {string[][]} expected Rows/cols to compare against at end + * @param {function($table)} callback Something to do with the table before we compare + */ + function tableTestHTML( msg, html, expected, callback ) { + QUnit.test( msg, function ( assert ) { + var extracted, + $table = $( html ); + + // Give caller a chance to set up sorting and manipulate the table. + if ( callback ) { + callback( $table ); + } else { + $table.tablesorter(); + $table.find( '#sortme' ).click(); + } + + // Table sorting is done synchronously; if it ever needs to change back + // to asynchronous, we'll need a timeout or a callback here. + extracted = tableExtract( $table ); + assert.deepEqual( extracted, expected, msg ); + } ); + } + + function reversed( arr ) { + // Clone array + var arr2 = arr.slice( 0 ); + + arr2.reverse(); + + return arr2; + } + + // Sample data set using planets named and their radius + + tableTest( + 'Basic planet table: sorting initially - ascending by name', + header, + planets, + planetsAscName, + function ( $table ) { + $table.tablesorter( { sortList: [ + { 0: 'asc' } + ] } ); + } + ); + tableTest( + 'Basic planet table: sorting initially - descending by radius', + header, + planets, + reversed( planetsAscRadius ), + function ( $table ) { + $table.tablesorter( { sortList: [ + { 1: 'desc' } + ] } ); + } + ); + tableTest( + 'Basic planet table: ascending by name', + header, + planets, + planetsAscName, + function ( $table ) { + $table.tablesorter(); + $table.find( '.headerSort:eq(0)' ).click(); + } + ); + tableTest( + 'Basic planet table: ascending by name a second time', + header, + planets, + planetsAscName, + function ( $table ) { + $table.tablesorter(); + $table.find( '.headerSort:eq(0)' ).click(); + } + ); + tableTest( + 'Basic planet table: ascending by name (multiple clicks)', + header, + planets, + planetsAscName, + function ( $table ) { + $table.tablesorter(); + $table.find( '.headerSort:eq(0)' ).click(); + $table.find( '.headerSort:eq(1)' ).click(); + $table.find( '.headerSort:eq(0)' ).click(); + } + ); + tableTest( + 'Basic planet table: descending by name', + header, + planets, + reversed( planetsAscName ), + function ( $table ) { + $table.tablesorter(); + $table.find( '.headerSort:eq(0)' ).click().click(); + } + ); + tableTest( + 'Basic planet table: ascending radius', + header, + planets, + planetsAscRadius, + function ( $table ) { + $table.tablesorter(); + $table.find( '.headerSort:eq(1)' ).click(); + } + ); + tableTest( + 'Basic planet table: descending radius', + header, + planets, + reversed( planetsAscRadius ), + function ( $table ) { + $table.tablesorter(); + $table.find( '.headerSort:eq(1)' ).click().click(); + } + ); + tableTest( + 'Sorting multiple columns by passing sort list', + header, + simple, + simpleAsc, + function ( $table ) { + $table.tablesorter( + { sortList: [ + { 0: 'asc' }, + { 1: 'asc' } + ] } + ); + } + ); + tableTest( + 'Sorting multiple columns by programmatically triggering sort()', + header, + simple, + simpleDescasc, + function ( $table ) { + $table.tablesorter(); + $table.data( 'tablesorter' ).sort( + [ + { 0: 'desc' }, + { 1: 'asc' } + ] + ); + } + ); + tableTest( + 'Reset to initial sorting by triggering sort() without any parameters', + header, + simple, + simpleAsc, + function ( $table ) { + $table.tablesorter( + { sortList: [ + { 0: 'asc' }, + { 1: 'asc' } + ] } + ); + $table.data( 'tablesorter' ).sort( + [ + { 0: 'desc' }, + { 1: 'asc' } + ] + ); + $table.data( 'tablesorter' ).sort(); + } + ); + tableTest( + 'Sort via click event after having initialized the tablesorter with initial sorting', + header, + simple, + simpleDescasc, + function ( $table ) { + $table.tablesorter( + { sortList: [ { 0: 'asc' }, { 1: 'asc' } ] } + ); + $table.find( '.headerSort:eq(0)' ).click(); + } + ); + tableTest( + 'Multi-sort via click event after having initialized the tablesorter with initial sorting', + header, + simple, + simpleAsc, + function ( $table ) { + var event; + $table.tablesorter( + { sortList: [ { 0: 'desc' }, { 1: 'desc' } ] } + ); + $table.find( '.headerSort:eq(0)' ).click(); + + // Pretend to click while pressing the multi-sort key + event = $.Event( 'click' ); + event[ $table.data( 'tablesorter' ).config.sortMultiSortKey ] = true; + $table.find( '.headerSort:eq(1)' ).trigger( event ); + } + ); + QUnit.test( 'Reset sorting making table appear unsorted', function ( assert ) { + var $table = tableCreate( header, simple ); + $table.tablesorter( + { sortList: [ + { 0: 'desc' }, + { 1: 'asc' } + ] } + ); + $table.data( 'tablesorter' ).sort( [] ); + + assert.equal( + $table.find( 'th.headerSortUp' ).length + $table.find( 'th.headerSortDown' ).length, + 0, + 'No sort specific sort classes addign to header cells' + ); + + assert.equal( + $table.find( 'th' ).first().attr( 'title' ), + mw.msg( 'sort-ascending' ), + 'First header cell has default title' + ); + + assert.equal( + $table.find( 'th' ).first().attr( 'title' ), + $table.find( 'th' ).last().attr( 'title' ), + 'Both header cells\' titles match' + ); + } ); + + // Sorting with colspans + + tableTest( 'Sorting with colspanned headers: spanned column', + header4, + colspanInitial, + [ aaa1, aab5, abc3, bbc2, caa4 ], + function ( $table ) { + // Make colspanned header for test + $table.find( 'tr:eq(0) th:eq(1), tr:eq(0) th:eq(2)' ).remove(); + $table.find( 'tr:eq(0) th:eq(0)' ).attr( 'colspan', '3' ); + + $table.tablesorter(); + $table.find( '.headerSort:eq(0)' ).click(); + } + ); + tableTest( 'Sorting with colspanned headers: sort spanned column twice', + header4, + colspanInitial, + [ caa4, bbc2, abc3, aab5, aaa1 ], + function ( $table ) { + // Make colspanned header for test + $table.find( 'tr:eq(0) th:eq(1), tr:eq(0) th:eq(2)' ).remove(); + $table.find( 'tr:eq(0) th:eq(0)' ).attr( 'colspan', '3' ); + + $table.tablesorter(); + $table.find( '.headerSort:eq(0)' ).click(); + $table.find( '.headerSort:eq(0)' ).click(); + } + ); + tableTest( 'Sorting with colspanned headers: subsequent column', + header4, + colspanInitial, + [ aaa1, bbc2, abc3, caa4, aab5 ], + function ( $table ) { + // Make colspanned header for test + $table.find( 'tr:eq(0) th:eq(1), tr:eq(0) th:eq(2)' ).remove(); + $table.find( 'tr:eq(0) th:eq(0)' ).attr( 'colspan', '3' ); + + $table.tablesorter(); + $table.find( '.headerSort:eq(1)' ).click(); + } + ); + tableTest( 'Sorting with colspanned headers: sort subsequent column twice', + header4, + colspanInitial, + [ aab5, caa4, abc3, bbc2, aaa1 ], + function ( $table ) { + // Make colspanned header for test + $table.find( 'tr:eq(0) th:eq(1), tr:eq(0) th:eq(2)' ).remove(); + $table.find( 'tr:eq(0) th:eq(0)' ).attr( 'colspan', '3' ); + + $table.tablesorter(); + $table.find( '.headerSort:eq(1)' ).click(); + $table.find( '.headerSort:eq(1)' ).click(); + } + ); + + QUnit.test( 'Basic planet table: one unsortable column', function ( assert ) { + var $table = tableCreate( header, planets ), + $cell; + $table.find( 'tr:eq(0) > th:eq(0)' ).addClass( 'unsortable' ); + + $table.tablesorter(); + $table.find( 'tr:eq(0) > th:eq(0)' ).click(); + + assert.deepEqual( + tableExtract( $table ), + planets, + 'table not sorted' + ); + + $cell = $table.find( 'tr:eq(0) > th:eq(0)' ); + $table.find( 'tr:eq(0) > th:eq(1)' ).click(); + + assert.equal( + $cell.hasClass( 'headerSortUp' ) || $cell.hasClass( 'headerSortDown' ), + false, + 'after sort: no class headerSortUp or headerSortDown' + ); + + assert.equal( + $cell.attr( 'title' ), + undefined, + 'after sort: no title tag added' + ); + + } ); + + // Regression tests! + tableTest( + 'T30775: German-style (dmy) short numeric dates', + [ 'Date' ], + [ + // German-style dates are day-month-year + [ '11.11.2011' ], + [ '01.11.2011' ], + [ '02.10.2011' ], + [ '03.08.2011' ], + [ '09.11.2011' ] + ], + [ + // Sorted by ascending date + [ '03.08.2011' ], + [ '02.10.2011' ], + [ '01.11.2011' ], + [ '09.11.2011' ], + [ '11.11.2011' ] + ], + function ( $table ) { + mw.config.set( 'wgDefaultDateFormat', 'dmy' ); + mw.config.set( 'wgPageContentLanguage', 'de' ); + + $table.tablesorter(); + $table.find( '.headerSort:eq(0)' ).click(); + } + ); + + tableTest( + 'T30775: American-style (mdy) short numeric dates', + [ 'Date' ], + [ + // American-style dates are month-day-year + [ '11.11.2011' ], + [ '01.11.2011' ], + [ '02.10.2011' ], + [ '03.08.2011' ], + [ '09.11.2011' ] + ], + [ + // Sorted by ascending date + [ '01.11.2011' ], + [ '02.10.2011' ], + [ '03.08.2011' ], + [ '09.11.2011' ], + [ '11.11.2011' ] + ], + function ( $table ) { + mw.config.set( 'wgDefaultDateFormat', 'mdy' ); + + $table.tablesorter(); + $table.find( '.headerSort:eq(0)' ).click(); + } + ); + + tableTest( + 'T19141: IPv4 address sorting', + [ 'IP' ], + ipv4, + ipv4Sorted, + function ( $table ) { + $table.tablesorter(); + $table.find( '.headerSort:eq(0)' ).click(); + } + ); + tableTest( + 'T19141: IPv4 address sorting (reverse)', + [ 'IP' ], + ipv4, + reversed( ipv4Sorted ), + function ( $table ) { + $table.tablesorter(); + $table.find( '.headerSort:eq(0)' ).click().click(); + } + ); + + tableTest( + 'Accented Characters with custom collation', + [ 'Name' ], + umlautWords, + umlautWordsSorted, + function ( $table ) { + mw.config.set( 'tableSorterCollation', { + ä: 'ae', + ö: 'oe', + ß: 'ss', + ü: 'ue' + } ); + + $table.tablesorter(); + $table.find( '.headerSort:eq(0)' ).click(); + } + ); + + tableTest( + 'Digraphs with custom collation', + [ 'City' ], + digraphWords, + digraphWordsSorted, + function ( $table ) { + mw.config.set( 'tableSorterCollation', { + lj: 'lzzzz', + nj: 'nzzzz' + } ); + + $table.tablesorter(); + $table.find( '.headerSort:eq(0)' ).click(); + } + ); + + QUnit.test( 'Rowspan not exploded on init', function ( assert ) { + var $table = tableCreate( header, planets ); + + // Modify the table to have a multiple-row-spanning cell: + // - Remove 2nd cell of 4th row, and, 2nd cell or 5th row. + $table.find( 'tr:eq(3) td:eq(1), tr:eq(4) td:eq(1)' ).remove(); + // - Set rowspan for 2nd cell of 3rd row to 3. + // This covers the removed cell in the 4th and 5th row. + $table.find( 'tr:eq(2) td:eq(1)' ).attr( 'rowspan', '3' ); + + $table.tablesorter(); + + assert.equal( + $table.find( 'tr:eq(2) td:eq(1)' ).prop( 'rowSpan' ), + 3, + 'Rowspan not exploded' + ); + } ); + + planetsRowspan = [ + [ 'Earth', '6051.8' ], + jupiter, + [ 'Mars', '6051.8' ], + mercury, + saturn, + venus + ]; + planetsRowspanII = [ jupiter, mercury, saturn, venus, [ 'Venus', '6371.0' ], [ 'Venus', '3390.0' ] ]; + + tableTest( + 'Basic planet table: same value for multiple rows via rowspan', + header, + planets, + planetsRowspan, + function ( $table ) { + // Modify the table to have a multiple-row-spanning cell: + // - Remove 2nd cell of 4th row, and, 2nd cell or 5th row. + $table.find( 'tr:eq(3) td:eq(1), tr:eq(4) td:eq(1)' ).remove(); + // - Set rowspan for 2nd cell of 3rd row to 3. + // This covers the removed cell in the 4th and 5th row. + $table.find( 'tr:eq(2) td:eq(1)' ).attr( 'rowspan', '3' ); + + $table.tablesorter(); + $table.find( '.headerSort:eq(0)' ).click(); + } + ); + tableTest( + 'Basic planet table: same value for multiple rows via rowspan (sorting initially)', + header, + planets, + planetsRowspan, + function ( $table ) { + // Modify the table to have a multiple-row-spanning cell: + // - Remove 2nd cell of 4th row, and, 2nd cell or 5th row. + $table.find( 'tr:eq(3) td:eq(1), tr:eq(4) td:eq(1)' ).remove(); + // - Set rowspan for 2nd cell of 3rd row to 3. + // This covers the removed cell in the 4th and 5th row. + $table.find( 'tr:eq(2) td:eq(1)' ).attr( 'rowspan', '3' ); + + $table.tablesorter( { sortList: [ + { 0: 'asc' } + ] } ); + } + ); + tableTest( + 'Basic planet table: Same value for multiple rows via rowspan II', + header, + planets, + planetsRowspanII, + function ( $table ) { + // Modify the table to have a multiple-row-spanning cell: + // - Remove 1st cell of 4th row, and, 1st cell or 5th row. + $table.find( 'tr:eq(3) td:eq(0), tr:eq(4) td:eq(0)' ).remove(); + // - Set rowspan for 1st cell of 3rd row to 3. + // This covers the removed cell in the 4th and 5th row. + $table.find( 'tr:eq(2) td:eq(0)' ).attr( 'rowspan', '3' ); + + $table.tablesorter(); + $table.find( '.headerSort:eq(0)' ).click(); + } + ); + + tableTest( + 'Complex date parsing I', + [ 'date' ], + complexMDYDates, + complexMDYSorted, + function ( $table ) { + mw.config.set( 'wgDefaultDateFormat', 'mdy' ); + + $table.tablesorter(); + $table.find( '.headerSort:eq(0)' ).click(); + } + ); + + tableTest( + 'Currency parsing I', + [ 'currency' ], + currencyUnsorted, + currencySorted, + function ( $table ) { + $table.tablesorter(); + $table.find( '.headerSort:eq(0)' ).click(); + } + ); + + planetsAscNameLegacy = planetsAscName.slice( 0 ); + planetsAscNameLegacy[ 4 ] = planetsAscNameLegacy[ 5 ]; + planetsAscNameLegacy.pop(); + + tableTest( + 'Legacy compat with .sortbottom', + header, + planets, + planetsAscNameLegacy, + function ( $table ) { + $table.find( 'tr:last' ).addClass( 'sortbottom' ); + $table.tablesorter(); + $table.find( '.headerSort:eq(0)' ).click(); + } + ); + + QUnit.test( 'Test detection routine', function ( assert ) { + var $table; + $table = $( + '<table class="sortable">' + + '<caption>CAPTION</caption>' + + '<tr><th>THEAD</th></tr>' + + '<tr><td>1</td></tr>' + + '<tr class="sortbottom"><td>text</td></tr>' + + '</table>' + ); + $table.tablesorter(); + $table.find( '.headerSort:eq(0)' ).click(); + + assert.equal( + $table.data( 'tablesorter' ).config.parsers[ 0 ].id, + 'number', + 'Correctly detected column content skipping sortbottom' + ); + } ); + + /** FIXME: the diff output is not very readeable. */ + QUnit.test( 'T34047 - caption must be before thead', function ( assert ) { + var $table; + $table = $( + '<table class="sortable">' + + '<caption>CAPTION</caption>' + + '<tr><th>THEAD</th></tr>' + + '<tr><td>A</td></tr>' + + '<tr><td>B</td></tr>' + + '<tr class="sortbottom"><td>TFOOT</td></tr>' + + '</table>' + ); + $table.tablesorter(); + + assert.equal( + $table.children().get( 0 ).nodeName, + 'CAPTION', + 'First element after <thead> must be <caption> (T34047)' + ); + } ); + + QUnit.test( 'data-sort-value attribute, when available, should override sorting position', function ( assert ) { + var $table, data; + + // Example 1: All cells except one cell without data-sort-value, + // which should be sorted at it's text content value. + $table = $( + '<table class="sortable"><thead><tr><th>Data</th></tr></thead>' + + '<tbody>' + + '<tr><td>Cheetah</td></tr>' + + '<tr><td data-sort-value="Apple">Bird</td></tr>' + + '<tr><td data-sort-value="Bananna">Ferret</td></tr>' + + '<tr><td data-sort-value="Drupe">Elephant</td></tr>' + + '<tr><td data-sort-value="Cherry">Dolphin</td></tr>' + + '</tbody></table>' + ); + $table.tablesorter().find( '.headerSort:eq(0)' ).click(); + + data = []; + $table.find( 'tbody > tr' ).each( function ( i, tr ) { + $( tr ).find( 'td' ).each( function ( i, td ) { + data.push( { + data: $( td ).data( 'sortValue' ), + text: $( td ).text() + } ); + } ); + } ); + + assert.deepEqual( data, [ + { + data: 'Apple', + text: 'Bird' + }, + { + data: 'Bananna', + text: 'Ferret' + }, + { + data: undefined, + text: 'Cheetah' + }, + { + data: 'Cherry', + text: 'Dolphin' + }, + { + data: 'Drupe', + text: 'Elephant' + } + ], 'Order matches expected order (based on data-sort-value attribute values)' ); + + // Example 2 + $table = $( + '<table class="sortable"><thead><tr><th>Data</th></tr></thead>' + + '<tbody>' + + '<tr><td>D</td></tr>' + + '<tr><td data-sort-value="E">A</td></tr>' + + '<tr><td>B</td></tr>' + + '<tr><td>G</td></tr>' + + '<tr><td data-sort-value="F">C</td></tr>' + + '</tbody></table>' + ); + $table.tablesorter().find( '.headerSort:eq(0)' ).click(); + + data = []; + $table.find( 'tbody > tr' ).each( function ( i, tr ) { + $( tr ).find( 'td' ).each( function ( i, td ) { + data.push( { + data: $( td ).data( 'sortValue' ), + text: $( td ).text() + } ); + } ); + } ); + + assert.deepEqual( data, [ + { + data: undefined, + text: 'B' + }, + { + data: undefined, + text: 'D' + }, + { + data: 'E', + text: 'A' + }, + { + data: 'F', + text: 'C' + }, + { + data: undefined, + text: 'G' + } + ], 'Order matches expected order (based on data-sort-value attribute values)' ); + + // Example 3: Test that live changes are used from data-sort-value, + // even if they change after the tablesorter is constructed (T40152). + $table = $( + '<table class="sortable"><thead><tr><th>Data</th></tr></thead>' + + '<tbody>' + + '<tr><td>D</td></tr>' + + '<tr><td data-sort-value="1">A</td></tr>' + + '<tr><td>B</td></tr>' + + '<tr><td data-sort-value="2">G</td></tr>' + + '<tr><td>C</td></tr>' + + '</tbody></table>' + ); + // initialize table sorter and sort once + $table + .tablesorter() + .find( '.headerSort:eq(0)' ).click(); + + // Change the sortValue data properties (T40152) + // - change data + $table.find( 'td:contains(A)' ).data( 'sortValue', 3 ); + // - add data + $table.find( 'td:contains(B)' ).data( 'sortValue', 1 ); + // - remove data, bring back attribute: 2 + $table.find( 'td:contains(G)' ).removeData( 'sortValue' ); + + // Now sort again (twice, so it is back at Ascending) + $table.find( '.headerSort:eq(0)' ).click(); + $table.find( '.headerSort:eq(0)' ).click(); + + data = []; + $table.find( 'tbody > tr' ).each( function ( i, tr ) { + $( tr ).find( 'td' ).each( function ( i, td ) { + data.push( { + data: $( td ).data( 'sortValue' ), + text: $( td ).text() + } ); + } ); + } ); + + assert.deepEqual( data, [ + { + data: 1, + text: 'B' + }, + { + data: 2, + text: 'G' + }, + { + data: 3, + text: 'A' + }, + { + data: undefined, + text: 'C' + }, + { + data: undefined, + text: 'D' + } + ], 'Order matches expected order, using the current sortValue in $.data()' ); + + } ); + + tableTest( 'T10115: sort numbers with commas (ascending)', + [ 'Numbers' ], numbers, numbersAsc, + function ( $table ) { + $table.tablesorter(); + $table.find( '.headerSort:eq(0)' ).click(); + } + ); + + tableTest( 'T10115: sort numbers with commas (descending)', + [ 'Numbers' ], numbers, reversed( numbersAsc ), + function ( $table ) { + $table.tablesorter(); + $table.find( '.headerSort:eq(0)' ).click().click(); + } + ); + // TODO add numbers sorting tests for T10115 with a different language + + QUnit.test( 'T34888 - Tables inside a tableheader cell', function ( assert ) { + var $table; + $table = $( + '<table class="sortable" id="mw-bug-32888">' + + '<tr><th>header<table id="mw-bug-32888-2">' + + '<tr><th>1</th><th>2</th></tr>' + + '</table></th></tr>' + + '<tr><td>A</td></tr>' + + '<tr><td>B</td></tr>' + + '</table>' + ); + $table.tablesorter(); + + assert.equal( + $table.find( '> thead:eq(0) > tr > th.headerSort' ).length, + 1, + 'Child tables inside a headercell should not interfere with sortable headers (T34888)' + ); + assert.equal( + $( '#mw-bug-32888-2' ).find( 'th.headerSort' ).length, + 0, + 'The headers of child tables inside a headercell should not be sortable themselves (T34888)' + ); + } ); + + tableTest( + 'Correct date sorting I', + [ 'date' ], + correctDateSorting1, + correctDateSortingSorted1, + function ( $table ) { + mw.config.set( 'wgDefaultDateFormat', 'mdy' ); + + $table.tablesorter(); + $table.find( '.headerSort:eq(0)' ).click(); + } + ); + + tableTest( + 'Correct date sorting II', + [ 'date' ], + correctDateSorting2, + correctDateSortingSorted2, + function ( $table ) { + mw.config.set( 'wgDefaultDateFormat', 'dmy' ); + + $table.tablesorter(); + $table.find( '.headerSort:eq(0)' ).click(); + } + ); + + tableTest( + 'ISO date sorting', + [ 'isoDate' ], + isoDateSorting, + isoDateSortingSorted, + function ( $table ) { + mw.config.set( 'wgDefaultDateFormat', 'dmy' ); + + $table.tablesorter(); + $table.find( '.headerSort:eq(0)' ).click(); + } + ); + + QUnit.test( 'Sorting images using alt text', function ( assert ) { + var $table = $( + '<table class="sortable">' + + '<tr><th>THEAD</th></tr>' + + '<tr><td><img alt="2"/></td></tr>' + + '<tr><td>1</td></tr>' + + '</table>' + ); + $table.tablesorter().find( '.headerSort:eq(0)' ).click(); + + assert.equal( + $table.find( 'td' ).first().text(), + '1', + 'Applied correct sorting order' + ); + } ); + + QUnit.test( 'Sorting images using alt text (complex)', function ( assert ) { + var $table = $( + '<table class="sortable">' + + '<tr><th>THEAD</th></tr>' + + '<tr><td><img alt="D" />A</td></tr>' + + '<tr><td>CC</td></tr>' + + '<tr><td><a><img alt="A" /></a>F</tr>' + + '<tr><td><img alt="A" /><strong>E</strong></tr>' + + '<tr><td><strong><img alt="A" />D</strong></tr>' + + '<tr><td><img alt="A" />C</tr>' + + '</table>' + ); + $table.tablesorter().find( '.headerSort:eq(0)' ).click(); + + assert.equal( + $table.find( 'td' ).text(), + 'CDEFCCA', + 'Applied correct sorting order' + ); + } ); + + QUnit.test( 'Sorting images using alt text (with format autodetection)', function ( assert ) { + var $table = $( + '<table class="sortable">' + + '<tr><th>THEAD</th></tr>' + + '<tr><td><img alt="1" />7</td></tr>' + + '<tr><td>1<img alt="6" /></td></tr>' + + '<tr><td>5</td></tr>' + + '<tr><td>4</td></tr>' + + '</table>' + ); + $table.tablesorter().find( '.headerSort:eq(0)' ).click(); + + assert.equal( + $table.find( 'td' ).text(), + '4517', + 'Applied correct sorting order' + ); + } ); + + QUnit.test( 'T40911 - The row with the largest amount of columns should receive the sort indicators', function ( assert ) { + var $table = $( + '<table class="sortable">' + + '<thead>' + + '<tr><th rowspan="2" id="A1">A1</th><th colspan="2">B2a</th></tr>' + + '<tr><th id="B2b">B2b</th><th id="C2b">C2b</th></tr>' + + '</thead>' + + '<tr><td>A</td><td>Aa</td><td>Ab</td></tr>' + + '<tr><td>B</td><td>Ba</td><td>Bb</td></tr>' + + '</table>' + ); + $table.tablesorter(); + + assert.equal( + $table.find( '#A1' ).attr( 'class' ), + 'headerSort', + 'The first column of the first row should be sortable' + ); + assert.equal( + $table.find( '#B2b' ).attr( 'class' ), + 'headerSort', + 'The th element of the 2nd row of the 2nd column should be sortable' + ); + assert.equal( + $table.find( '#C2b' ).attr( 'class' ), + 'headerSort', + 'The th element of the 2nd row of the 3rd column should be sortable' + ); + } ); + + QUnit.test( 'rowspans in table headers should prefer the last row when rows are equal in length', function ( assert ) { + var $table = $( + '<table class="sortable">' + + '<thead>' + + '<tr><th rowspan="2" id="A1">A1</th><th>B2a</th></tr>' + + '<tr><th id="B2b">B2b</th></tr>' + + '</thead>' + + '<tr><td>A</td><td>Aa</td></tr>' + + '<tr><td>B</td><td>Ba</td></tr>' + + '</table>' + ); + $table.tablesorter(); + + assert.equal( + $table.find( '#A1' ).attr( 'class' ), + 'headerSort', + 'The first column of the first row should be sortable' + ); + assert.equal( + $table.find( '#B2b' ).attr( 'class' ), + 'headerSort', + 'The th element of the 2nd row of the 2nd column should be sortable' + ); + } ); + + QUnit.test( 'holes in the table headers should not throw JS errors', function ( assert ) { + var $table = $( + '<table class="sortable">' + + '<thead>' + + '<tr><th id="A1">A1</th><th>B1</th><th id="C1" rowspan="2">C1</th></tr>' + + '<tr><th id="A2">A2</th></tr>' + + '</thead>' + + '<tr><td>A</td><td>Aa</td><td>Aaa</td></tr>' + + '<tr><td>B</td><td>Ba</td><td>Bbb</td></tr>' + + '</table>' + ); + $table.tablesorter(); + assert.equal( $table.find( '#A2' ).data( 'headerIndex' ), + undefined, + 'A2 should not be a sort header' + ); + assert.equal( $table.find( '#C1' ).data( 'headerIndex' ), + 2, + 'C1 should be a sort header' + ); + } ); + + // T55527 + QUnit.test( 'td cells in thead should not be taken into account for longest row calculation', function ( assert ) { + var $table = $( + '<table class="sortable">' + + '<thead>' + + '<tr><th id="A1">A1</th><th>B1</th><td id="C1">C1</td></tr>' + + '<tr><th id="A2">A2</th><th>B2</th><th id="C2">C2</th></tr>' + + '</thead>' + + '</table>' + ); + $table.tablesorter(); + assert.equal( $table.find( '#C2' ).data( 'headerIndex' ), + 2, + 'C2 should be a sort header' + ); + assert.equal( $table.find( '#C1' ).data( 'headerIndex' ), + undefined, + 'C1 should not be a sort header' + ); + } ); + + // T43889 - exploding rowspans in more complex cases + tableTestHTML( + 'Rowspan exploding with row headers', + '<table class="sortable">' + + '<thead><tr><th id="sortme">n</th><th>foo</th><th>bar</th><th>baz</th></tr></thead>' + + '<tbody>' + + '<tr><td>1</td><th rowspan="2">foo</th><td rowspan="2">bar</td><td>baz</td></tr>' + + '<tr><td>2</td><td>baz</td></tr>' + + '</tbody></table>', + [ + [ '1', 'foo', 'bar', 'baz' ], + [ '2', 'foo', 'bar', 'baz' ] + ] + ); + + // T55211 - exploding rowspans in more complex cases + QUnit.test( + 'Rowspan exploding with row headers and colspans', function ( assert ) { + var $table = $( '<table class="sortable">' + + '<thead><tr><th rowspan="2">n</th><th colspan="2">foo</th><th rowspan="2">baz</th></tr>' + + '<tr><th>foo</th><th>bar</th></tr></thead>' + + '<tbody>' + + '<tr><td>1</td><td>foo</td><td>bar</td><td>baz</td></tr>' + + '<tr><td>2</td><td>foo</td><td>bar</td><td>baz</td></tr>' + + '</tbody></table>' ); + + $table.tablesorter(); + assert.equal( $table.find( 'tr:eq(1) th:eq(1)' ).data( 'headerIndex' ), + 2, + 'Incorrect index of sort header' + ); + } + ); + + tableTestHTML( + 'Rowspan exploding with colspanned cells', + '<table class="sortable">' + + '<thead><tr><th id="sortme">n</th><th>foo</th><th>bar</th><th>baz</th></tr></thead>' + + '<tbody>' + + '<tr><td>1</td><td>foo</td><td>bar</td><td rowspan="2">baz</td></tr>' + + '<tr><td>2</td><td colspan="2">foobar</td></tr>' + + '</tbody></table>', + [ + [ '1', 'foo', 'bar', 'baz' ], + [ '2', 'foobar', 'baz' ] + ] + ); + + tableTestHTML( + 'Rowspan exploding with colspanned cells (2)', + '<table class="sortable">' + + '<thead><tr><th>n</th><th>foo</th><th>bar</th><th>baz</th><th id="sortme">n2</th></tr></thead>' + + '<tbody>' + + '<tr><td>1</td><td>foo</td><td>bar</td><td rowspan="2">baz</td><td>2</td></tr>' + + '<tr><td>2</td><td colspan="2">foobar</td><td>1</td></tr>' + + '</tbody></table>', + [ + [ '2', 'foobar', 'baz', '1' ], + [ '1', 'foo', 'bar', 'baz', '2' ] + ] + ); + + tableTestHTML( + 'Rowspan exploding with rightmost rows spanning most', + '<table class="sortable">' + + '<thead><tr><th id="sortme">n</th><th>foo</th><th>bar</th></tr></thead>' + + '<tbody>' + + '<tr><td>1</td><td rowspan="2">foo</td><td rowspan="4">bar</td></tr>' + + '<tr><td>2</td></tr>' + + '<tr><td>3</td><td rowspan="2">foo</td></tr>' + + '<tr><td>4</td></tr>' + + '</tbody></table>', + [ + [ '1', 'foo', 'bar' ], + [ '2', 'foo', 'bar' ], + [ '3', 'foo', 'bar' ], + [ '4', 'foo', 'bar' ] + ] + ); + + tableTestHTML( + 'Rowspan exploding with rightmost rows spanning most (2)', + '<table class="sortable">' + + '<thead><tr><th id="sortme">n</th><th>foo</th><th>bar</th><th>baz</th></tr></thead>' + + '<tbody>' + + '<tr><td>1</td><td rowspan="2">foo</td><td rowspan="4">bar</td><td>baz</td></tr>' + + '<tr><td>2</td><td>baz</td></tr>' + + '<tr><td>3</td><td rowspan="2">foo</td><td>baz</td></tr>' + + '<tr><td>4</td><td>baz</td></tr>' + + '</tbody></table>', + [ + [ '1', 'foo', 'bar', 'baz' ], + [ '2', 'foo', 'bar', 'baz' ], + [ '3', 'foo', 'bar', 'baz' ], + [ '4', 'foo', 'bar', 'baz' ] + ] + ); + + tableTestHTML( + 'Rowspan exploding with row-and-colspanned cells', + '<table class="sortable">' + + '<thead><tr><th id="sortme">n</th><th>foo1</th><th>foo2</th><th>bar</th><th>baz</th></tr></thead>' + + '<tbody>' + + '<tr><td>1</td><td rowspan="2">foo1</td><td rowspan="2">foo2</td><td rowspan="4">bar</td><td>baz</td></tr>' + + '<tr><td>2</td><td>baz</td></tr>' + + '<tr><td>3</td><td colspan="2" rowspan="2">foo</td><td>baz</td></tr>' + + '<tr><td>4</td><td>baz</td></tr>' + + '</tbody></table>', + [ + [ '1', 'foo1', 'foo2', 'bar', 'baz' ], + [ '2', 'foo1', 'foo2', 'bar', 'baz' ], + [ '3', 'foo', 'bar', 'baz' ], + [ '4', 'foo', 'bar', 'baz' ] + ] + ); + + tableTestHTML( + 'Rowspan exploding with uneven rowspan layout', + '<table class="sortable">' + + '<thead><tr><th id="sortme">n</th><th>foo1</th><th>foo2</th><th>foo3</th><th>bar</th><th>baz</th></tr></thead>' + + '<tbody>' + + '<tr><td>1</td><td rowspan="2">foo1</td><td rowspan="2">foo2</td><td rowspan="2">foo3</td><td>bar</td><td>baz</td></tr>' + + '<tr><td>2</td><td rowspan="3">bar</td><td>baz</td></tr>' + + '<tr><td>3</td><td rowspan="2">foo1</td><td rowspan="2">foo2</td><td rowspan="2">foo3</td><td>baz</td></tr>' + + '<tr><td>4</td><td>baz</td></tr>' + + '</tbody></table>', + [ + [ '1', 'foo1', 'foo2', 'foo3', 'bar', 'baz' ], + [ '2', 'foo1', 'foo2', 'foo3', 'bar', 'baz' ], + [ '3', 'foo1', 'foo2', 'foo3', 'bar', 'baz' ], + [ '4', 'foo1', 'foo2', 'foo3', 'bar', 'baz' ] + ] + ); + + QUnit.test( 'T105731 - incomplete rows in table body', function ( assert ) { + var $table, parsers; + $table = $( + '<table class="sortable">' + + '<tr><th>A</th><th>B</th></tr>' + + '<tr><td>3</td></tr>' + + '<tr><td>1</td><td>2</td></tr>' + + '</table>' + ); + $table.tablesorter(); + $table.find( '.headerSort:eq(0)' ).click(); + // now the first row have 2 columns + $table.find( '.headerSort:eq(1)' ).click(); + + parsers = $table.data( 'tablesorter' ).config.parsers; + + assert.equal( + parsers.length, + 2, + 'detectParserForColumn() detect 2 parsers' + ); + + assert.equal( + parsers[ 1 ].id, + 'number', + 'detectParserForColumn() detect parser.id "number" for second column' + ); + + assert.equal( + parsers[ 1 ].format( $table.find( 'tbody > tr > td:eq(1)' ).text() ), + -Infinity, + 'empty cell is sorted as number -Infinity' + ); + } ); + + QUnit.test( 'bug T114721 - use of expand-child class', function ( assert ) { + var $table, parsers; + $table = $( + '<table class="sortable">' + + '<tr><th>A</th><th>B</th></tr>' + + '<tr><td>b</td><td>4</td></tr>' + + '<tr class="expand-child"><td colspan="2">some text follow b</td></tr>' + + '<tr><td>a</td><td>2</td></tr>' + + '<tr class="expand-child"><td colspan="2">some text follow a</td></tr>' + + '<tr class="expand-child"><td colspan="2">more text</td></tr>' + + '</table>' + ); + $table.tablesorter(); + $table.find( '.headerSort:eq(0)' ).click(); + + assert.deepEqual( + tableExtract( $table ), + [ + [ 'a', '2' ], + [ 'some text follow a' ], + [ 'more text' ], + [ 'b', '4' ], + [ 'some text follow b' ] + ], + 'row with expand-child class follow above row' + ); + + parsers = $table.data( 'tablesorter' ).config.parsers; + assert.equal( + parsers[ 1 ].id, + 'number', + 'detectParserForColumn() detect parser.id "number" for second column' + ); + } ); + +}( jQuery, mediaWiki ) ); diff --git a/www/wiki/tests/qunit/suites/resources/jquery/jquery.textSelection.test.js b/www/wiki/tests/qunit/suites/resources/jquery/jquery.textSelection.test.js new file mode 100644 index 00000000..32cda7eb --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/jquery/jquery.textSelection.test.js @@ -0,0 +1,266 @@ +( function ( $ ) { + var caretSample, + sig = { + pre: '--~~~~' + }, + bold = { + pre: '\'\'\'', + peri: 'Bold text', + post: '\'\'\'' + }, + h2 = { + pre: '== ', + peri: 'Heading 2', + post: ' ==', + regex: /^(\s*)(={1,6})(.*?)\2(\s*)$/, + regexReplace: '$1==$3==$4', + ownline: true + }, + ulist = { + pre: '* ', + peri: 'Bulleted list item', + post: '', + ownline: true, + splitlines: true + }; + + QUnit.module( 'jquery.textSelection', QUnit.newMwEnvironment() ); + + /** + * Test factory for $.fn.textSelection( 'encapsulateText' ) + * + * @param {Object} options Associative configuration array + * @param {string} options.description Description + * @param {string} options.input Input + * @param {string} options.output Output + * @param {int} options.start Starting char for selection + * @param {int} options.end Ending char for selection + * @param {Object} options.params Additional parameters for $().textSelection( 'encapsulateText' ) + */ + function encapsulateTest( options ) { + var opt = $.extend( { + description: '', + before: {}, + after: {}, + replace: {} + }, options ); + + opt.before = $.extend( { + text: '', + start: 0, + end: 0 + }, opt.before ); + opt.after = $.extend( { + text: '', + selected: null + }, opt.after ); + + QUnit.test( opt.description, function ( assert ) { + var $textarea, start, end, options, text, selected; + + $textarea = $( '<textarea>' ); + + $( '#qunit-fixture' ).append( $textarea ); + + $textarea.textSelection( 'setContents', opt.before.text ); + + start = opt.before.start; + end = opt.before.end; + + // Clone opt.replace + options = $.extend( {}, opt.replace ); + options.selectionStart = start; + options.selectionEnd = end; + $textarea.textSelection( 'encapsulateSelection', options ); + + text = $textarea.textSelection( 'getContents' ).replace( /\r\n/g, '\n' ); + + assert.equal( text, opt.after.text, 'Checking full text after encapsulation' ); + + if ( opt.after.selected !== null ) { + selected = $textarea.textSelection( 'getSelection' ); + assert.equal( selected, opt.after.selected, 'Checking selected text after encapsulation.' ); + } + + } ); + } + + encapsulateTest( { + description: 'Adding sig to end of text', + before: { + text: 'Wikilove dude! ', + start: 15, + end: 15 + }, + after: { + text: 'Wikilove dude! --~~~~', + selected: '' + }, + replace: sig + } ); + + encapsulateTest( { + description: 'Adding bold to empty', + before: { + text: '', + start: 0, + end: 0 + }, + after: { + text: '\'\'\'Bold text\'\'\'', + selected: 'Bold text' // selected because it's the default + }, + replace: bold + } ); + + encapsulateTest( { + description: 'Adding bold to existing text', + before: { + text: 'Now is the time for all good men to come to the aid of their country', + start: 20, + end: 32 + }, + after: { + text: 'Now is the time for \'\'\'all good men\'\'\' to come to the aid of their country', + selected: '' // empty because it's not the default' + }, + replace: bold + } ); + + encapsulateTest( { + description: 'ownline option: adding new h2', + before: { + text: 'Before\nAfter', + start: 7, + end: 7 + }, + after: { + text: 'Before\n== Heading 2 ==\nAfter', + selected: 'Heading 2' + }, + replace: h2 + } ); + + encapsulateTest( { + description: 'ownline option: turn a whole line into new h2', + before: { + text: 'Before\nMy heading\nAfter', + start: 7, + end: 17 + }, + after: { + text: 'Before\n== My heading ==\nAfter', + selected: '' + }, + replace: h2 + } ); + + encapsulateTest( { + description: 'ownline option: turn a partial line into new h2', + before: { + text: 'BeforeMy headingAfter', + start: 6, + end: 16 + }, + after: { + text: 'Before\n== My heading ==\nAfter', + selected: '' + }, + replace: h2 + } ); + + encapsulateTest( { + description: 'splitlines option: no selection, insert new list item', + before: { + text: 'Before\nAfter', + start: 7, + end: 7 + }, + after: { + text: 'Before\n* Bulleted list item\nAfter' + }, + replace: ulist + } ); + + encapsulateTest( { + description: 'splitlines option: single partial line selection, insert new list item', + before: { + text: 'BeforeMy List ItemAfter', + start: 6, + end: 18 + }, + after: { + text: 'Before\n* My List Item\nAfter' + }, + replace: ulist + } ); + + encapsulateTest( { + description: 'splitlines option: multiple lines', + before: { + text: 'Before\nFirst\nSecond\nThird\nAfter', + start: 7, + end: 25 + }, + after: { + text: 'Before\n* First\n* Second\n* Third\nAfter' + }, + replace: ulist + } ); + + function caretTest( options ) { + QUnit.test( options.description, function ( assert ) { + var pos, + $textarea = $( '<textarea>' ).text( options.text ); + + $( '#qunit-fixture' ).append( $textarea ); + + if ( options.mode === 'set' ) { + $textarea.textSelection( 'setSelection', { + start: options.start, + end: options.end + } ); + } + + function among( actual, expected, message ) { + if ( Array.isArray( expected ) ) { + assert.ok( expected.indexOf( actual ) !== -1, message + ' (got ' + actual + '; expected one of ' + expected.join( ', ' ) + ')' ); + } else { + assert.equal( actual, expected, message ); + } + } + + pos = $textarea.textSelection( 'getCaretPosition', { startAndEnd: true } ); + among( pos[ 0 ], options.start, 'Caret start should be where we set it.' ); + among( pos[ 1 ], options.end, 'Caret end should be where we set it.' ); + } ); + } + + caretSample = 'Some big text that we like to work with. Nothing fancy... you know what I mean?'; + + /* @broken: Disabled per T36820 + caretTest({ + description: 'getCaretPosition with original/empty selection - T33847 with IE 6/7/8', + text: caretSample, + start: [0, caretSample.length], // Opera and Firefox (prior to FF 6.0) default caret to the end of the box (caretSample.length) + end: [0, caretSample.length], // Other browsers default it to the beginning (0), so check both. + mode: 'get' + }); + */ + + caretTest( { + description: 'set/getCaretPosition with forced empty selection', + text: caretSample, + start: 7, + end: 7, + mode: 'set' + } ); + + caretTest( { + description: 'set/getCaretPosition with small selection', + text: caretSample, + start: 6, + end: 11, + mode: 'set' + } ); +}( jQuery ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.ForeignApi.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.ForeignApi.test.js new file mode 100644 index 00000000..69ab7975 --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.ForeignApi.test.js @@ -0,0 +1,32 @@ +( function ( mw ) { + QUnit.module( 'mediawiki.ForeignApi', QUnit.newMwEnvironment( { + setup: function () { + this.server = this.sandbox.useFakeServer(); + this.server.respondImmediately = true; + } + } ) ); + + QUnit.test( 'origin is included in GET requests', function ( assert ) { + var api = new mw.ForeignApi( '//localhost:4242/w/api.php' ); + + this.server.respond( function ( request ) { + assert.ok( request.url.match( /origin=/ ), 'origin is included in GET requests' ); + request.respond( 200, { 'Content-Type': 'application/json' }, '[]' ); + } ); + + return api.get( {} ); + } ); + + QUnit.test( 'origin is included in POST requests', function ( assert ) { + var api = new mw.ForeignApi( '//localhost:4242/w/api.php' ); + + this.server.respond( function ( request ) { + assert.ok( request.requestBody.match( /origin=/ ), 'origin is included in POST request body' ); + assert.ok( request.url.match( /origin=/ ), 'origin is included in POST request URL, too' ); + request.respond( 200, { 'Content-Type': 'application/json' }, '[]' ); + } ); + + return api.post( {} ); + } ); + +}( mediaWiki ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.category.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.category.test.js new file mode 100644 index 00000000..50fa6d15 --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.category.test.js @@ -0,0 +1,114 @@ +( function ( mw ) { + QUnit.module( 'mediawiki.api.category', QUnit.newMwEnvironment( { + setup: function () { + this.server = this.sandbox.useFakeServer(); + this.server.respondImmediately = true; + } + } ) ); + + QUnit.test( '.getCategoriesByPrefix()', function ( assert ) { + this.server.respondWith( [ 200, { 'Content-Type': 'application/json' }, + '{ "query": { "allpages": [ ' + + '{ "title": "Category:Food" },' + + '{ "title": "Category:Fool Supermarine S.6" },' + + '{ "title": "Category:Fools" }' + + '] } }' + ] ); + + return new mw.Api().getCategoriesByPrefix( 'Foo' ).then( function ( matches ) { + assert.deepEqual( + matches, + [ 'Food', 'Fool Supermarine S.6', 'Fools' ] + ); + } ); + } ); + + QUnit.test( '.isCategory("")', function ( assert ) { + this.server.respondWith( /titles=$/, [ + 200, + { 'Content-Type': 'application/json' }, + '{"batchcomplete":true}' + ] ); + return new mw.Api().isCategory( '' ).then( function ( response ) { + assert.equal( response, false ); + } ); + } ); + + QUnit.test( '.isCategory("#")', function ( assert ) { + this.server.respondWith( /titles=%23$/, [ + 200, + { 'Content-Type': 'application/json' }, + '{"batchcomplete":true,"query":{"normalized":[{"fromencoded":false,"from":"#","to":""}]}}' + ] ); + return new mw.Api().isCategory( '#' ).then( function ( response ) { + assert.equal( response, false ); + } ); + } ); + + QUnit.test( '.isCategory("mw:")', function ( assert ) { + this.server.respondWith( /titles=mw%3A$/, [ + 200, + { 'Content-Type': 'application/json' }, + '{"batchcomplete":true,"query":{"interwiki":[{"title":"mw:","iw":"mw"}]}}' + ] ); + return new mw.Api().isCategory( 'mw:' ).then( function ( response ) { + assert.equal( response, false ); + } ); + } ); + + QUnit.test( '.isCategory("|")', function ( assert ) { + this.server.respondWith( /titles=%1F%7C$/, [ + 200, + { 'Content-Type': 'application/json' }, + '{"batchcomplete":true,"query":{"pages":[{"title":"|","invalidreason":"The requested page title contains invalid characters: \\"|\\".","invalid":true}]}}' + ] ); + return new mw.Api().isCategory( '|' ).then( function ( response ) { + assert.equal( response, false ); + } ); + } ); + + QUnit.test( '.getCategories("")', function ( assert ) { + this.server.respondWith( /titles=$/, [ + 200, + { 'Content-Type': 'application/json' }, + '{"batchcomplete":true}' + ] ); + return new mw.Api().getCategories( '' ).then( function ( response ) { + assert.equal( response, false ); + } ); + } ); + + QUnit.test( '.getCategories("#")', function ( assert ) { + this.server.respondWith( /titles=%23$/, [ + 200, + { 'Content-Type': 'application/json' }, + '{"batchcomplete":true,"query":{"normalized":[{"fromencoded":false,"from":"#","to":""}]}}' + ] ); + return new mw.Api().getCategories( '#' ).then( function ( response ) { + assert.equal( response, false ); + } ); + } ); + + QUnit.test( '.getCategories("mw:")', function ( assert ) { + this.server.respondWith( /titles=mw%3A$/, [ + 200, + { 'Content-Type': 'application/json' }, + '{"batchcomplete":true,"query":{"interwiki":[{"title":"mw:","iw":"mw"}]}}' + ] ); + return new mw.Api().getCategories( 'mw:' ).then( function ( response ) { + assert.equal( response, false ); + } ); + } ); + + QUnit.test( '.getCategories("|")', function ( assert ) { + this.server.respondWith( /titles=%1F%7C$/, [ + 200, + { 'Content-Type': 'application/json' }, + '{"batchcomplete":true,"query":{"pages":[{"title":"|","invalidreason":"The requested page title contains invalid characters: \\"|\\".","invalid":true}]}}' + ] ); + return new mw.Api().getCategories( '|' ).then( function ( response ) { + assert.equal( response, false ); + } ); + } ); + +}( mediaWiki ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.edit.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.edit.test.js new file mode 100644 index 00000000..4ce7c5db --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.edit.test.js @@ -0,0 +1,220 @@ +( function ( mw, $ ) { + QUnit.module( 'mediawiki.api.edit', QUnit.newMwEnvironment( { + setup: function () { + this.server = this.sandbox.useFakeServer(); + this.server.respondImmediately = true; + } + } ) ); + + QUnit.test( 'edit( title, transform String )', function ( assert ) { + this.server.respond( function ( req ) { + if ( /query.+titles=Sandbox/.test( req.url ) ) { + req.respond( 200, { 'Content-Type': 'application/json' }, JSON.stringify( { + curtimestamp: '2016-01-02T12:00:00Z', + query: { + pages: [ { + pageid: 1, + ns: 0, + title: 'Sandbox', + revisions: [ { + timestamp: '2016-01-01T12:00:00Z', + contentformat: 'text/x-wiki', + contentmodel: 'wikitext', + content: 'Sand.' + } ] + } ] + } + } ) ); + } + if ( /edit.+basetimestamp=2016-01-01.+starttimestamp=2016-01-02.+text=Box%2E/.test( req.requestBody ) ) { + req.respond( 200, { 'Content-Type': 'application/json' }, JSON.stringify( { + edit: { + result: 'Success', + oldrevid: 11, + newrevid: 13, + newtimestamp: '2016-01-03T12:00:00Z' + } + } ) ); + } + } ); + + return new mw.Api() + .edit( 'Sandbox', function ( revision ) { + return revision.content.replace( 'Sand', 'Box' ); + } ) + .then( function ( edit ) { + assert.equal( edit.newrevid, 13 ); + } ); + } ); + + QUnit.test( 'edit( mw.Title, transform String )', function ( assert ) { + this.server.respond( function ( req ) { + if ( /query.+titles=Sandbox/.test( req.url ) ) { + req.respond( 200, { 'Content-Type': 'application/json' }, JSON.stringify( { + curtimestamp: '2016-01-02T12:00:00Z', + query: { + pages: [ { + pageid: 1, + ns: 0, + title: 'Sandbox', + revisions: [ { + timestamp: '2016-01-01T12:00:00Z', + contentformat: 'text/x-wiki', + contentmodel: 'wikitext', + content: 'Sand.' + } ] + } ] + } + } ) ); + } + if ( /edit.+basetimestamp=2016-01-01.+starttimestamp=2016-01-02.+text=Box%2E/.test( req.requestBody ) ) { + req.respond( 200, { 'Content-Type': 'application/json' }, JSON.stringify( { + edit: { + result: 'Success', + oldrevid: 11, + newrevid: 13, + newtimestamp: '2016-01-03T12:00:00Z' + } + } ) ); + } + } ); + + return new mw.Api() + .edit( new mw.Title( 'Sandbox' ), function ( revision ) { + return revision.content.replace( 'Sand', 'Box' ); + } ) + .then( function ( edit ) { + assert.equal( edit.newrevid, 13 ); + } ); + } ); + + QUnit.test( 'edit( title, transform Promise )', function ( assert ) { + this.server.respond( function ( req ) { + if ( /query.+titles=Async/.test( req.url ) ) { + req.respond( 200, { 'Content-Type': 'application/json' }, JSON.stringify( { + curtimestamp: '2016-02-02T12:00:00Z', + query: { + pages: [ { + pageid: 4, + ns: 0, + title: 'Async', + revisions: [ { + timestamp: '2016-02-01T12:00:00Z', + contentformat: 'text/x-wiki', + contentmodel: 'wikitext', + content: 'Async.' + } ] + } ] + } + } ) ); + } + if ( /edit.+basetimestamp=2016-02-01.+starttimestamp=2016-02-02.+text=Promise%2E/.test( req.requestBody ) ) { + req.respond( 200, { 'Content-Type': 'application/json' }, JSON.stringify( { + edit: { + result: 'Success', + oldrevid: 21, + newrevid: 23, + newtimestamp: '2016-02-03T12:00:00Z' + } + } ) ); + } + } ); + + return new mw.Api() + .edit( 'Async', function ( revision ) { + return $.Deferred().resolve( revision.content.replace( 'Async', 'Promise' ) ); + } ) + .then( function ( edit ) { + assert.equal( edit.newrevid, 23 ); + } ); + } ); + + QUnit.test( 'edit( title, transform Object )', function ( assert ) { + this.server.respond( function ( req ) { + if ( /query.+titles=Param/.test( req.url ) ) { + req.respond( 200, { 'Content-Type': 'application/json' }, JSON.stringify( { + curtimestamp: '2016-03-02T12:00:00Z', + query: { + pages: [ { + pageid: 3, + ns: 0, + title: 'Param', + revisions: [ { + timestamp: '2016-03-01T12:00:00Z', + contentformat: 'text/x-wiki', + contentmodel: 'wikitext', + content: '...' + } ] + } ] + } + } ) ); + } + if ( /edit.+basetimestamp=2016-03-01.+starttimestamp=2016-03-02.+text=Content&summary=Sum/.test( req.requestBody ) ) { + req.respond( 200, { 'Content-Type': 'application/json' }, JSON.stringify( { + edit: { + result: 'Success', + oldrevid: 31, + newrevid: 33, + newtimestamp: '2016-03-03T12:00:00Z' + } + } ) ); + } + } ); + + return new mw.Api() + .edit( 'Param', function () { + return { text: 'Content', summary: 'Sum' }; + } ) + .then( function ( edit ) { + assert.equal( edit.newrevid, 33 ); + } ); + } ); + + QUnit.test( 'edit( invalid-title, transform String )', function ( assert ) { + this.server.respond( function ( req ) { + if ( /query.+titles=%1F%7C/.test( req.url ) ) { + req.respond( 200, { 'Content-Type': 'application/json' }, JSON.stringify( { + query: { + pages: [ { + title: '|', + invalidreason: 'The requested page title contains invalid characters: "|".', + invalid: true + } ] + } + } ) ); + } + } ); + + return new mw.Api() + .edit( '|', function ( revision ) { + return revision.content.replace( 'Sand', 'Box' ); + } ) + .then( function () { + return $.Deferred().reject( 'Unexpected success' ); + }, function ( reason ) { + assert.equal( reason, 'invalidtitle' ); + } ); + } ); + + QUnit.test( 'create( title, content )', function ( assert ) { + this.server.respond( function ( req ) { + if ( /edit.+text=Sand/.test( req.requestBody ) ) { + req.respond( 200, { 'Content-Type': 'application/json' }, JSON.stringify( { + edit: { + new: true, + result: 'Success', + newrevid: 41, + newtimestamp: '2016-04-01T12:00:00Z' + } + } ) ); + } + } ); + + return new mw.Api() + .create( 'Sandbox', { summary: 'Load sand particles.' }, 'Sand.' ) + .then( function ( page ) { + assert.equal( page.newrevid, 41 ); + } ); + } ); + +}( mediaWiki, jQuery ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.messages.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.messages.test.js new file mode 100644 index 00000000..7282b3fb --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.messages.test.js @@ -0,0 +1,29 @@ +( function ( mw ) { + QUnit.module( 'mediawiki.api.messages', QUnit.newMwEnvironment( { + setup: function () { + this.server = this.sandbox.useFakeServer(); + this.server.respondImmediately = true; + } + } ) ); + + QUnit.test( '.getMessages()', function ( assert ) { + this.server.respondWith( /ammessages=foo%7Cbaz/, [ + 200, + { 'Content-Type': 'application/json' }, + '{ "query": { "allmessages": [' + + '{ "name": "foo", "content": "Foo bar" },' + + '{ "name": "baz", "content": "Baz Quux" }' + + '] } }' + ] ); + + return new mw.Api().getMessages( [ 'foo', 'baz' ] ).then( function ( messages ) { + assert.deepEqual( + messages, + { + foo: 'Foo bar', + baz: 'Baz Quux' + } + ); + } ); + } ); +}( mediaWiki ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.options.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.options.test.js new file mode 100644 index 00000000..997a42c8 --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.options.test.js @@ -0,0 +1,141 @@ +( function ( mw ) { + QUnit.module( 'mediawiki.api.options', QUnit.newMwEnvironment( { + setup: function () { + this.server = this.sandbox.useFakeServer(); + this.server.respondImmediately = true; + } + } ) ); + + QUnit.test( 'saveOption', function ( assert ) { + var api = new mw.Api(), + stub = this.sandbox.stub( mw.Api.prototype, 'saveOptions' ); + + api.saveOption( 'foo', 'bar' ); + + assert.ok( stub.calledOnce, '#saveOptions called once' ); + assert.deepEqual( stub.getCall( 0 ).args, [ { foo: 'bar' } ], '#saveOptions called correctly' ); + } ); + + QUnit.test( 'saveOptions without Unit Separator', function ( assert ) { + var api = new mw.Api( { useUS: false } ); + + // We need to respond to the request for token first, otherwise the other requests won't be sent + // until after the server.respond call, which confuses sinon terribly. This sucks a lot. + api.getToken( 'options' ); + this.server.respond( + /meta=tokens&type=csrf/, + [ 200, { 'Content-Type': 'application/json' }, + '{ "query": { "tokens": { "csrftoken": "+\\\\" } } }' ] + ); + + // Requests are POST, match requestBody instead of url + this.server.respond( function ( request ) { + if ( [ + // simple + 'action=options&format=json&formatversion=2&change=foo%3Dbar&token=%2B%5C', + // two options + 'action=options&format=json&formatversion=2&change=foo%3Dbar%7Cbaz%3Dquux&token=%2B%5C', + // not bundleable + 'action=options&format=json&formatversion=2&optionname=foo&optionvalue=bar%7Cquux&token=%2B%5C', + 'action=options&format=json&formatversion=2&optionname=bar&optionvalue=a%7Cb%7Cc&token=%2B%5C', + 'action=options&format=json&formatversion=2&change=baz%3Dquux&token=%2B%5C', + // reset an option + 'action=options&format=json&formatversion=2&change=foo&token=%2B%5C', + // reset an option, not bundleable + 'action=options&format=json&formatversion=2&optionname=foo%7Cbar%3Dquux&token=%2B%5C' + ].indexOf( request.requestBody ) !== -1 ) { + assert.ok( true, 'Repond to ' + request.requestBody ); + request.respond( 200, { 'Content-Type': 'application/json' }, + '{ "options": "success" }' ); + } else { + assert.ok( false, 'Unexpected request: ' + request.requestBody ); + } + } ); + + return QUnit.whenPromisesComplete( + api.saveOptions( {} ).then( function () { + assert.ok( true, 'Request completed: empty case' ); + } ), + api.saveOptions( { foo: 'bar' } ).then( function () { + assert.ok( true, 'Request completed: simple' ); + } ), + api.saveOptions( { foo: 'bar', baz: 'quux' } ).then( function () { + assert.ok( true, 'Request completed: two options' ); + } ), + api.saveOptions( { foo: 'bar|quux', bar: 'a|b|c', baz: 'quux' } ).then( function () { + assert.ok( true, 'Request completed: not bundleable' ); + } ), + api.saveOptions( { foo: null } ).then( function () { + assert.ok( true, 'Request completed: reset an option' ); + } ), + api.saveOptions( { 'foo|bar=quux': null } ).then( function () { + assert.ok( true, 'Request completed: reset an option, not bundleable' ); + } ) + ); + } ); + + QUnit.test( 'saveOptions with Unit Separator', function ( assert ) { + var api = new mw.Api( { useUS: true } ); + + // We need to respond to the request for token first, otherwise the other requests won't be sent + // until after the server.respond call, which confuses sinon terribly. This sucks a lot. + api.getToken( 'options' ); + this.server.respond( + /meta=tokens&type=csrf/, + [ 200, { 'Content-Type': 'application/json' }, + '{ "query": { "tokens": { "csrftoken": "+\\\\" } } }' ] + ); + + // Requests are POST, match requestBody instead of url + this.server.respond( function ( request ) { + if ( [ + // simple + 'action=options&format=json&formatversion=2&change=foo%3Dbar&token=%2B%5C', + // two options + 'action=options&format=json&formatversion=2&change=foo%3Dbar%7Cbaz%3Dquux&token=%2B%5C', + // bundleable with unit separator + 'action=options&format=json&formatversion=2&change=%1Ffoo%3Dbar%7Cquux%1Fbar%3Da%7Cb%7Cc%1Fbaz%3Dquux&token=%2B%5C', + // not bundleable with unit separator + 'action=options&format=json&formatversion=2&optionname=baz%3Dbaz&optionvalue=quux&token=%2B%5C', + 'action=options&format=json&formatversion=2&change=%1Ffoo%3Dbar%7Cquux%1Fbar%3Da%7Cb%7Cc&token=%2B%5C', + // reset an option + 'action=options&format=json&formatversion=2&change=foo&token=%2B%5C', + // reset an option, not bundleable + 'action=options&format=json&formatversion=2&optionname=foo%7Cbar%3Dquux&token=%2B%5C' + ].indexOf( request.requestBody ) !== -1 ) { + assert.ok( true, 'Repond to ' + request.requestBody ); + request.respond( + 200, + { 'Content-Type': 'application/json' }, + '{ "options": "success" }' + ); + } else { + assert.ok( false, 'Unexpected request: ' + request.requestBody ); + } + } ); + + return QUnit.whenPromisesComplete( + api.saveOptions( {} ).done( function () { + assert.ok( true, 'Request completed: empty case' ); + } ), + api.saveOptions( { foo: 'bar' } ).done( function () { + assert.ok( true, 'Request completed: simple' ); + } ), + api.saveOptions( { foo: 'bar', baz: 'quux' } ).done( function () { + assert.ok( true, 'Request completed: two options' ); + } ), + api.saveOptions( { foo: 'bar|quux', bar: 'a|b|c', baz: 'quux' } ).done( function () { + assert.ok( true, 'Request completed: bundleable with unit separator' ); + } ), + api.saveOptions( { foo: 'bar|quux', bar: 'a|b|c', 'baz=baz': 'quux' } ).done( function () { + assert.ok( true, 'Request completed: not bundleable with unit separator' ); + } ), + api.saveOptions( { foo: null } ).done( function () { + assert.ok( true, 'Request completed: reset an option' ); + } ), + api.saveOptions( { 'foo|bar=quux': null } ).done( function () { + assert.ok( true, 'Request completed: reset an option, not bundleable' ); + } ) + ); + } ); +}( mediaWiki ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.parse.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.parse.test.js new file mode 100644 index 00000000..74da0098 --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.parse.test.js @@ -0,0 +1,45 @@ +( function ( mw ) { + QUnit.module( 'mediawiki.api.parse', QUnit.newMwEnvironment( { + setup: function () { + this.server = this.sandbox.useFakeServer(); + this.server.respondImmediately = true; + } + } ) ); + + QUnit.test( '.parse( string )', function ( assert ) { + this.server.respondWith( /action=parse.*&text='''Hello(\+|%20)world'''/, [ 200, + { 'Content-Type': 'application/json' }, + '{ "parse": { "text": "<p><b>Hello world</b></p>" } }' + ] ); + + return new mw.Api().parse( '\'\'\'Hello world\'\'\'' ).done( function ( html ) { + assert.equal( html, '<p><b>Hello world</b></p>', 'Parse wikitext by string' ); + } ); + } ); + + QUnit.test( '.parse( Object.toString )', function ( assert ) { + this.server.respondWith( /action=parse.*&text='''Hello(\+|%20)world'''/, [ 200, + { 'Content-Type': 'application/json' }, + '{ "parse": { "text": "<p><b>Hello world</b></p>" } }' + ] ); + + return new mw.Api().parse( { + toString: function () { + return '\'\'\'Hello world\'\'\''; + } + } ).done( function ( html ) { + assert.equal( html, '<p><b>Hello world</b></p>', 'Parse wikitext by toString object' ); + } ); + } ); + + QUnit.test( '.parse( mw.Title )', function ( assert ) { + this.server.respondWith( /action=parse.*&page=Earth/, [ 200, + { 'Content-Type': 'application/json' }, + '{ "parse": { "text": "<p><b>Earth</b> is a planet.</p>" } }' + ] ); + + return new mw.Api().parse( new mw.Title( 'Earth' ) ).done( function ( html ) { + assert.equal( html, '<p><b>Earth</b> is a planet.</p>', 'Parse page by Title object' ); + } ); + } ); +}( mediaWiki ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.test.js new file mode 100644 index 00000000..417ad3d8 --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.test.js @@ -0,0 +1,456 @@ +( function ( mw, $ ) { + QUnit.module( 'mediawiki.api', QUnit.newMwEnvironment( { + setup: function () { + this.server = this.sandbox.useFakeServer(); + this.server.respondImmediately = true; + } + } ) ); + + function sequence( responses ) { + var i = 0; + return function ( request ) { + var response = responses[ i ]; + if ( response ) { + i++; + request.respond.apply( request, response ); + } + }; + } + + function sequenceBodies( status, headers, bodies ) { + bodies.forEach( function ( body, i ) { + bodies[ i ] = [ status, headers, body ]; + } ); + return sequence( bodies ); + } + + // Utility to make inline use with an assert easier + function match( text, pattern ) { + var m = text.match( pattern ); + return m && m[ 1 ] || null; + } + + QUnit.test( 'get()', function ( assert ) { + var api = new mw.Api(); + + this.server.respond( [ 200, { 'Content-Type': 'application/json' }, '[]' ] ); + + return api.get( {} ).then( function ( data ) { + assert.deepEqual( data, [], 'If request succeeds without errors, resolve deferred' ); + } ); + } ); + + QUnit.test( 'post()', function ( assert ) { + var api = new mw.Api(); + + this.server.respond( [ 200, { 'Content-Type': 'application/json' }, '[]' ] ); + + return api.post( {} ).then( function ( data ) { + assert.deepEqual( data, [], 'Simple POST request' ); + } ); + } ); + + QUnit.test( 'API error errorformat=bc', function ( assert ) { + var api = new mw.Api(); + + this.server.respond( [ 200, { 'Content-Type': 'application/json' }, + '{ "error": { "code": "unknown_action" } }' + ] ); + + api.get( { action: 'doesntexist' } ) + .fail( function ( errorCode ) { + assert.equal( errorCode, 'unknown_action', 'API error should reject the deferred' ); + } ) + .always( assert.async() ); + } ); + + QUnit.test( 'API error errorformat!=bc', function ( assert ) { + var api = new mw.Api(); + + this.server.respond( [ 200, { 'Content-Type': 'application/json' }, + '{ "errors": [ { "code": "unknown_action", "key": "unknown-error", "params": [] } ] }' + ] ); + + api.get( { action: 'doesntexist' } ) + .fail( function ( errorCode ) { + assert.equal( errorCode, 'unknown_action', 'API error should reject the deferred' ); + } ) + .always( assert.async() ); + } ); + + QUnit.test( 'FormData support', function ( assert ) { + var api = new mw.Api(); + + this.server.respond( function ( request ) { + if ( window.FormData ) { + assert.ok( !request.url.match( /action=/ ), 'Request has no query string' ); + assert.ok( request.requestBody instanceof FormData, 'Request uses FormData body' ); + } else { + assert.ok( !request.url.match( /action=test/ ), 'Request has no query string' ); + assert.equal( request.requestBody, 'action=test&format=json', 'Request uses query string body' ); + } + request.respond( 200, { 'Content-Type': 'application/json' }, '[]' ); + } ); + + return api.post( { action: 'test' }, { contentType: 'multipart/form-data' } ); + } ); + + QUnit.test( 'Converting arrays to pipe-separated (string)', function ( assert ) { + var api = new mw.Api(); + + this.server.respond( function ( request ) { + assert.equal( match( request.url, /test=([^&]+)/ ), 'foo%7Cbar%7Cbaz', 'Pipe-separated value was submitted' ); + request.respond( 200, { 'Content-Type': 'application/json' }, '[]' ); + } ); + + return api.get( { test: [ 'foo', 'bar', 'baz' ] } ); + } ); + + QUnit.test( 'Converting arrays to pipe-separated (mw.Title)', function ( assert ) { + var api = new mw.Api(); + + this.server.respond( function ( request ) { + assert.equal( match( request.url, /test=([^&]+)/ ), 'Foo%7CBar', 'Pipe-separated value was submitted' ); + request.respond( 200, { 'Content-Type': 'application/json' }, '[]' ); + } ); + + return api.get( { test: [ new mw.Title( 'Foo' ), new mw.Title( 'Bar' ) ] } ); + } ); + + QUnit.test( 'Converting arrays to pipe-separated (misc primitives)', function ( assert ) { + var api = new mw.Api(); + + this.server.respond( function ( request ) { + assert.equal( match( request.url, /test=([^&]+)/ ), 'true%7Cfalse%7C%7C%7C0%7C1%2E2', 'Pipe-separated value was submitted' ); + request.respond( 200, { 'Content-Type': 'application/json' }, '[]' ); + } ); + + // undefined/null will become empty string + return api.get( { test: [ true, false, undefined, null, 0, 1.2 ] } ); + } ); + + QUnit.test( 'Omitting false booleans', function ( assert ) { + var api = new mw.Api(); + + this.server.respond( function ( request ) { + assert.ok( !request.url.match( /foo/ ), 'foo query parameter is not present' ); + assert.ok( request.url.match( /bar=true/ ), 'bar query parameter is present with value true' ); + request.respond( 200, { 'Content-Type': 'application/json' }, '[]' ); + } ); + + return api.get( { foo: false, bar: true } ); + } ); + + QUnit.test( 'getToken() - cached', function ( assert ) { + var api = new mw.Api(), + test = this; + + // Get csrfToken for local wiki, this should not make + // a request as it should be retrieved from mw.user.tokens. + return api.getToken( 'csrf' ) + .then( function ( token ) { + assert.ok( token.length, 'Got a token' ); + }, function ( err ) { + assert.equal( '', err, 'API error' ); + } ) + .then( function () { + assert.equal( test.server.requests.length, 0, 'Requests made' ); + } ); + } ); + + QUnit.test( 'getToken() - uncached', function ( assert ) { + var api = new mw.Api(), + firstDone = assert.async(), + secondDone = assert.async(); + + this.server.respondWith( /type=testuncached/, [ 200, { 'Content-Type': 'application/json' }, + '{ "query": { "tokens": { "testuncachedtoken": "good" } } }' + ] ); + + // Get a token of a type that isn't prepopulated by user.tokens. + // Could use "block" or "delete" here, but those could in theory + // be added to user.tokens, use a fake one instead. + api.getToken( 'testuncached' ) + .done( function ( token ) { + assert.equal( token, 'good', 'The token' ); + } ) + .fail( function ( err ) { + assert.equal( err, '', 'API error' ); + } ) + .always( firstDone ); + + api.getToken( 'testuncached' ) + .done( function ( token ) { + assert.equal( token, 'good', 'The cached token' ); + } ) + .fail( function ( err ) { + assert.equal( err, '', 'API error' ); + } ) + .always( secondDone ); + + assert.equal( this.server.requests.length, 1, 'Requests made' ); + } ); + + QUnit.test( 'getToken() - error', function ( assert ) { + var api = new mw.Api(); + + this.server.respondWith( /type=testerror/, sequenceBodies( 200, { 'Content-Type': 'application/json' }, + [ + '{ "error": { "code": "bite-me", "info": "Smite me, O Mighty Smiter" } }', + '{ "query": { "tokens": { "testerrortoken": "good" } } }' + ] + ) ); + + // Don't cache error (T67268) + return api.getToken( 'testerror' ) + .catch( function ( err ) { + assert.equal( err, 'bite-me', 'Expected error' ); + + return api.getToken( 'testerror' ); + } ) + .then( function ( token ) { + assert.equal( token, 'good', 'The token' ); + } ); + } ); + + QUnit.test( 'getToken() - deprecated', function ( assert ) { + // Cache API endpoint from default to avoid cachehit in mw.user.tokens + var api = new mw.Api( { ajax: { url: '/postWithToken/api.php' } } ), + test = this; + + this.server.respondWith( /type=csrf/, [ 200, { 'Content-Type': 'application/json' }, + '{ "query": { "tokens": { "csrftoken": "csrfgood" } } }' + ] ); + + // Get a token of a type that is in the legacy map. + return api.getToken( 'email' ) + .done( function ( token ) { + assert.equal( token, 'csrfgood', 'Token' ); + } ) + .fail( function ( err ) { + assert.equal( err, '', 'API error' ); + } ) + .always( function () { + assert.equal( test.server.requests.length, 1, 'Requests made' ); + } ); + } ); + + QUnit.test( 'badToken()', function ( assert ) { + var api = new mw.Api(), + test = this; + + this.server.respondWith( /type=testbad/, sequenceBodies( 200, { 'Content-Type': 'application/json' }, + [ + '{ "query": { "tokens": { "testbadtoken": "bad" } } }', + '{ "query": { "tokens": { "testbadtoken": "good" } } }' + ] + ) ); + + return api.getToken( 'testbad' ) + .then( function () { + api.badToken( 'testbad' ); + return api.getToken( 'testbad' ); + } ) + .then( function ( token ) { + assert.equal( token, 'good', 'The token' ); + assert.equal( test.server.requests.length, 2, 'Requests made' ); + } ); + + } ); + + QUnit.test( 'badToken( legacy )', function ( assert ) { + var api = new mw.Api( { ajax: { url: '/badTokenLegacy/api.php' } } ), + test = this; + + this.server.respondWith( /type=csrf/, sequenceBodies( 200, { 'Content-Type': 'application/json' }, + [ + '{ "query": { "tokens": { "csrftoken": "badlegacy" } } }', + '{ "query": { "tokens": { "csrftoken": "goodlegacy" } } }' + ] + ) ); + + return api.getToken( 'options' ) + .then( function () { + api.badToken( 'options' ); + return api.getToken( 'options' ); + } ) + .then( function ( token ) { + assert.equal( token, 'goodlegacy', 'The token' ); + assert.equal( test.server.requests.length, 2, 'Request made' ); + } ); + + } ); + + QUnit.test( 'postWithToken( tokenType, params )', function ( assert ) { + var api = new mw.Api( { ajax: { url: '/postWithToken/api.php' } } ); + + this.server.respondWith( 'GET', /type=testpost/, [ 200, { 'Content-Type': 'application/json' }, + '{ "query": { "tokens": { "testposttoken": "good" } } }' + ] ); + this.server.respondWith( 'POST', /api/, function ( request ) { + if ( request.requestBody.match( /token=good/ ) ) { + request.respond( 200, { 'Content-Type': 'application/json' }, + '{ "example": { "foo": "quux" } }' + ); + } + } ); + + return api.postWithToken( 'testpost', { action: 'example', key: 'foo' } ) + .then( function ( data ) { + assert.deepEqual( data, { example: { foo: 'quux' } } ); + } ); + } ); + + QUnit.test( 'postWithToken( tokenType, params with assert )', function ( assert ) { + var api = new mw.Api( { ajax: { url: '/postWithToken/api.php' } } ), + test = this; + + this.server.respondWith( /assert=user/, [ 200, { 'Content-Type': 'application/json' }, + '{ "error": { "code": "assertuserfailed", "info": "Assertion failed" } }' + ] ); + + return api.postWithToken( 'testassertpost', { action: 'example', key: 'foo', assert: 'user' } ) + // Cast error to success and vice versa + .then( function () { + return $.Deferred().reject( 'Unexpected success' ); + }, function ( errorCode ) { + assert.equal( errorCode, 'assertuserfailed', 'getToken fails assert' ); + return $.Deferred().resolve(); + } ) + .then( function () { + assert.equal( test.server.requests.length, 1, 'Requests made' ); + } ); + } ); + + QUnit.test( 'postWithToken( tokenType, params, ajaxOptions )', function ( assert ) { + var api = new mw.Api(), + test = this; + + this.server.respond( [ 200, { 'Content-Type': 'application/json' }, '{ "example": "quux" }' ] ); + + return api.postWithToken( 'csrf', + { action: 'example' }, + { + headers: { + 'X-Foo': 'Bar' + } + } + ).then( function () { + assert.equal( test.server.requests[ 0 ].requestHeaders[ 'X-Foo' ], 'Bar', 'Header sent' ); + + return api.postWithToken( 'csrf', + { action: 'example' }, + function () { + assert.ok( false, 'This parameter cannot be a callback' ); + } + ); + } ).then( function ( data ) { + assert.equal( data.example, 'quux' ); + + assert.equal( test.server.requests.length, 2, 'Request made' ); + } ); + } ); + + QUnit.test( 'postWithToken() - badtoken', function ( assert ) { + var api = new mw.Api(); + + this.server.respondWith( /type=testbadtoken/, sequenceBodies( 200, { 'Content-Type': 'application/json' }, + [ + '{ "query": { "tokens": { "testbadtokentoken": "bad" } } }', + '{ "query": { "tokens": { "testbadtokentoken": "good" } } }' + ] + ) ); + this.server.respondWith( 'POST', /api/, function ( request ) { + if ( request.requestBody.match( /token=bad/ ) ) { + request.respond( 200, { 'Content-Type': 'application/json' }, + '{ "error": { "code": "badtoken" } }' + ); + } + if ( request.requestBody.match( /token=good/ ) ) { + request.respond( 200, { 'Content-Type': 'application/json' }, + '{ "example": { "foo": "quux" } }' + ); + } + } ); + + // - Request: new token -> bad + // - Request: action=example -> badtoken error + // - Request: new token -> good + // - Request: action=example -> success + return api.postWithToken( 'testbadtoken', { action: 'example', key: 'foo' } ) + .then( function ( data ) { + assert.deepEqual( data, { example: { foo: 'quux' } } ); + } ); + } ); + + QUnit.test( 'postWithToken() - badtoken-cached', function ( assert ) { + var sequenceA, + api = new mw.Api(); + + this.server.respondWith( /type=testonce/, sequenceBodies( 200, { 'Content-Type': 'application/json' }, + [ + '{ "query": { "tokens": { "testoncetoken": "good-A" } } }', + '{ "query": { "tokens": { "testoncetoken": "good-B" } } }' + ] + ) ); + sequenceA = sequenceBodies( 200, { 'Content-Type': 'application/json' }, + [ + '{ "example": { "value": "A" } }', + '{ "error": { "code": "badtoken" } }' + ] + ); + this.server.respondWith( 'POST', /api/, function ( request ) { + if ( request.requestBody.match( /token=good-A/ ) ) { + sequenceA( request ); + } else if ( request.requestBody.match( /token=good-B/ ) ) { + request.respond( 200, { 'Content-Type': 'application/json' }, + '{ "example": { "value": "B" } }' + ); + } + } ); + + // - Request: new token -> A + // - Request: action=example + return api.postWithToken( 'testonce', { action: 'example', key: 'foo' } ) + .then( function ( data ) { + assert.deepEqual( data, { example: { value: 'A' } } ); + + // - Request: action=example w/ token A -> badtoken error + // - Request: new token -> B + // - Request: action=example w/ token B -> success + return api.postWithToken( 'testonce', { action: 'example', key: 'bar' } ); + } ) + .then( function ( data ) { + assert.deepEqual( data, { example: { value: 'B' } } ); + } ); + } ); + + QUnit.module( 'mediawiki.api (2)', { + setup: function () { + var self = this, + requests = this.requests = []; + this.api = new mw.Api(); + this.sandbox.stub( jQuery, 'ajax', function () { + var request = $.extend( { + abort: self.sandbox.spy() + }, $.Deferred() ); + requests.push( request ); + return request; + } ); + } + } ); + + QUnit.test( '#abort', function ( assert ) { + this.api.get( { + a: 1 + } ); + this.api.post( { + b: 2 + } ); + this.api.abort(); + assert.ok( this.requests.length === 2, 'Check both requests triggered' ); + this.requests.forEach( function ( request, i ) { + assert.ok( request.abort.calledOnce, 'abort request number ' + i ); + } ); + } ); +}( mediaWiki, jQuery ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.upload.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.upload.test.js new file mode 100644 index 00000000..788a427e --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.upload.test.js @@ -0,0 +1,33 @@ +( function ( mw, $ ) { + QUnit.module( 'mediawiki.api.upload', QUnit.newMwEnvironment( {} ) ); + + QUnit.test( 'Basic functionality', function ( assert ) { + var api = new mw.Api(); + assert.ok( api.upload ); + assert.throws( function () { + api.upload(); + } ); + } ); + + QUnit.test( 'Set up iframe upload', function ( assert ) { + var $iframe, $form, $input, + api = new mw.Api(); + + this.sandbox.stub( api, 'getEditToken', function () { + return $.Deferred().promise(); + } ); + + api.uploadWithIframe( $( '<input>' )[ 0 ], { filename: 'Testing API upload.jpg' } ); + + $iframe = $( 'iframe:last-child' ); + $form = $( 'form.mw-api-upload-form' ); + $input = $form.find( 'input[name=filename]' ); + + assert.ok( $form.length > 0, 'form' ); + assert.ok( $input.length > 0, 'input' ); + assert.ok( $iframe.length > 0, 'frame' ); + assert.strictEqual( $form.prop( 'target' ), $iframe.prop( 'id' ), 'form.target and frame.id ' ); + assert.strictEqual( $input.val(), 'Testing API upload.jpg', 'input value' ); + } ); + +}( mediaWiki, jQuery ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.watch.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.watch.test.js new file mode 100644 index 00000000..86414691 --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.watch.test.js @@ -0,0 +1,60 @@ +( function ( mw ) { + QUnit.module( 'mediawiki.api.watch', QUnit.newMwEnvironment( { + setup: function () { + this.server = this.sandbox.useFakeServer(); + this.server.respondImmediately = true; + } + } ) ); + + QUnit.test( '.watch( string )', function ( assert ) { + this.server.respond( function ( req ) { + // Match POST requestBody + if ( /action=watch.*&titles=Foo(&|$)/.test( req.requestBody ) ) { + req.respond( 200, { 'Content-Type': 'application/json' }, + '{ "watch": [ { "title": "Foo", "watched": true, "message": "<b>Added</b>" } ] }' + ); + } + } ); + + return new mw.Api().watch( 'Foo' ).done( function ( item ) { + assert.equal( item.title, 'Foo' ); + } ); + } ); + + // Ensure we don't mistake a single item array for a single item and vice versa. + // The query parameter in request is the same either way (separated by pipe). + QUnit.test( '.watch( Array ) - single', function ( assert ) { + this.server.respond( function ( req ) { + // Match POST requestBody + if ( /action=watch.*&titles=Foo(&|$)/.test( req.requestBody ) ) { + req.respond( 200, { 'Content-Type': 'application/json' }, + '{ "watch": [ { "title": "Foo", "watched": true, "message": "<b>Added</b>" } ] }' + ); + } + } ); + + return new mw.Api().watch( [ 'Foo' ] ).done( function ( items ) { + assert.equal( items[ 0 ].title, 'Foo' ); + } ); + } ); + + QUnit.test( '.watch( Array ) - multi', function ( assert ) { + this.server.respond( function ( req ) { + // Match POST requestBody + if ( /action=watch.*&titles=Foo%7CBar/.test( req.requestBody ) ) { + req.respond( 200, { 'Content-Type': 'application/json' }, + '{ "watch": [ ' + + '{ "title": "Foo", "watched": true, "message": "<b>Added</b>" },' + + '{ "title": "Bar", "watched": true, "message": "<b>Added</b>" }' + + '] }' + ); + } + } ); + + return new mw.Api().watch( [ 'Foo', 'Bar' ] ).done( function ( items ) { + assert.equal( items[ 0 ].title, 'Foo' ); + assert.equal( items[ 1 ].title, 'Bar' ); + } ); + } ); + +}( mediaWiki ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/UriProcessor.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/UriProcessor.test.js new file mode 100644 index 00000000..872f4ddf --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/UriProcessor.test.js @@ -0,0 +1,354 @@ +/* eslint-disable camelcase */ +/* eslint no-underscore-dangle: "off" */ +( function ( mw, $ ) { + var mockFilterStructure = [ { + name: 'group1', + title: 'Group 1', + type: 'send_unselected_if_any', + filters: [ + { name: 'filter1', cssClass: 'filter1class', default: true }, + { name: 'filter2', cssClass: 'filter2class' } + ] + }, { + name: 'group2', + title: 'Group 2', + type: 'send_unselected_if_any', + filters: [ + { name: 'filter3', cssClass: 'filter3class' }, + { name: 'filter4', cssClass: 'filter4class', default: true } + ] + }, { + name: 'group3', + title: 'Group 3', + type: 'string_options', + filters: [ + { name: 'filter5', cssClass: 'filter5class' }, + { name: 'filter6' } // Not supporting highlights + ] + }, { + name: 'group4', + title: 'Group 4', + type: 'boolean', + sticky: true, + filters: [ + { name: 'stickyFilter7', cssClass: 'filter7class' }, + { name: 'stickyFilter8', cssClass: 'filter8class' } + ] + } ], + minimalDefaultParams = { + filter1: '1', + filter4: '1' + }; + + QUnit.module( 'mediawiki.rcfilters - UriProcessor' ); + + QUnit.test( 'getVersion', function ( assert ) { + var uriProcessor = new mw.rcfilters.UriProcessor( new mw.rcfilters.dm.FiltersViewModel() ); + + assert.ok( + uriProcessor.getVersion( { param1: 'foo', urlversion: '2' } ), + 2, + 'Retrieving the version from the URI query' + ); + + assert.ok( + uriProcessor.getVersion( { param1: 'foo' } ), + 1, + 'Getting version 1 if no version is specified' + ); + } ); + + QUnit.test( 'getUpdatedUri', function ( assert ) { + var uriProcessor, + filtersModel = new mw.rcfilters.dm.FiltersViewModel(), + makeUri = function ( queryParams ) { + var uri = new mw.Uri( 'http://server/wiki/Special:RC' ); + uri.query = queryParams; + return uri; + }; + + filtersModel.initializeFilters( mockFilterStructure ); + uriProcessor = new mw.rcfilters.UriProcessor( filtersModel ); + + assert.deepEqual( + ( uriProcessor.getUpdatedUri( makeUri( {} ) ) ).query, + { urlversion: '2' }, + 'Empty model state with empty uri state, assumes the given uri is already normalized, and adds urlversion=2' + ); + + assert.deepEqual( + ( uriProcessor.getUpdatedUri( makeUri( { foo: 'bar' } ) ) ).query, + { urlversion: '2', foo: 'bar' }, + 'Empty model state with unrecognized params retains unrecognized params' + ); + + // Update the model + filtersModel.toggleFiltersSelected( { + group1__filter1: true, // Param: filter2: '1' + group3__filter5: true // Param: group3: 'filter5' + } ); + + assert.deepEqual( + ( uriProcessor.getUpdatedUri( makeUri( {} ) ) ).query, + { urlversion: '2', filter2: '1', group3: 'filter5' }, + 'Model state is reflected in the updated URI' + ); + + assert.deepEqual( + ( uriProcessor.getUpdatedUri( makeUri( { foo: 'bar' } ) ) ).query, + { urlversion: '2', filter2: '1', group3: 'filter5', foo: 'bar' }, + 'Model state is reflected in the updated URI with existing uri params' + ); + } ); + + QUnit.test( 'updateModelBasedOnQuery', function ( assert ) { + var uriProcessor, + filtersModel = new mw.rcfilters.dm.FiltersViewModel(); + + filtersModel.initializeFilters( mockFilterStructure ); + uriProcessor = new mw.rcfilters.UriProcessor( filtersModel ); + + uriProcessor.updateModelBasedOnQuery( {} ); + assert.deepEqual( + filtersModel.getCurrentParameterState(), + minimalDefaultParams, + 'Version 1: Empty url query sets model to defaults' + ); + + uriProcessor.updateModelBasedOnQuery( { urlversion: '2' } ); + assert.deepEqual( + filtersModel.getCurrentParameterState(), + {}, + 'Version 2: Empty url query sets model to all-false' + ); + + uriProcessor.updateModelBasedOnQuery( { filter1: '1', urlversion: '2' } ); + assert.deepEqual( + filtersModel.getCurrentParameterState(), + $.extend( true, {}, { filter1: '1' } ), + 'Parameters in Uri query set parameter value in the model' + ); + } ); + + QUnit.test( 'isNewState', function ( assert ) { + var uriProcessor, + filtersModel = new mw.rcfilters.dm.FiltersViewModel(), + cases = [ + { + states: { + curr: {}, + new: {} + }, + result: false, + message: 'Empty objects are not new state.' + }, + { + states: { + curr: { filter1: '1' }, + new: { filter1: '0' } + }, + result: true, + message: 'Nulified parameter is a new state' + }, + { + states: { + curr: { filter1: '1' }, + new: { filter1: '1', filter2: '1' } + }, + result: true, + message: 'Added parameters are a new state' + }, + { + states: { + curr: { filter1: '1' }, + new: { filter1: '1', filter2: '0' } + }, + result: false, + message: 'Added null parameters are not a new state (normalizing equals old state)' + }, + { + states: { + curr: { filter1: '1' }, + new: { filter1: '1', foo: 'bar' } + }, + result: true, + message: 'Added unrecognized parameters are a new state' + }, + { + states: { + curr: { filter1: '1', foo: 'bar' }, + new: { filter1: '1', foo: 'baz' } + }, + result: true, + message: 'Changed unrecognized parameters are a new state' + } + ]; + + filtersModel.initializeFilters( mockFilterStructure ); + uriProcessor = new mw.rcfilters.UriProcessor( filtersModel ); + + cases.forEach( function ( testCase ) { + assert.equal( + uriProcessor.isNewState( testCase.states.curr, testCase.states.new ), + testCase.result, + testCase.message + ); + } ); + } ); + + QUnit.test( 'doesQueryContainRecognizedParams', function ( assert ) { + var uriProcessor, + filtersModel = new mw.rcfilters.dm.FiltersViewModel(), + cases = [ + { + query: {}, + result: false, + message: 'Empty query is not valid for load.' + }, + { + query: { highlight: '1' }, + result: false, + message: 'Highlight state alone is not valid for load' + }, + { + query: { urlversion: '2' }, + result: true, + message: 'urlversion=2 state alone is valid for load as an empty state' + }, + { + query: { filter1: '1', foo: 'bar' }, + result: true, + message: 'Existence of recognized parameters makes the query valid for load' + }, + { + query: { foo: 'bar', debug: true }, + result: false, + message: 'Only unrecognized parameters makes the query invalid for load' + } + ]; + + filtersModel.initializeFilters( mockFilterStructure ); + uriProcessor = new mw.rcfilters.UriProcessor( filtersModel ); + + cases.forEach( function ( testCase ) { + assert.equal( + uriProcessor.doesQueryContainRecognizedParams( testCase.query ), + testCase.result, + testCase.message + ); + } ); + } ); + + QUnit.test( '_getNormalizedQueryParams', function ( assert ) { + var uriProcessor, + filtersModel = new mw.rcfilters.dm.FiltersViewModel(), + cases = [ + { + query: {}, + result: $.extend( true, { urlversion: '2' }, minimalDefaultParams ), + message: 'Empty query returns defaults (urlversion 1).' + }, + { + query: { urlversion: '2' }, + result: { urlversion: '2' }, + message: 'Empty query returns empty (urlversion 2)' + }, + { + query: { filter1: '0' }, + result: { urlversion: '2', filter4: '1' }, + message: 'urlversion 1 returns query that overrides defaults' + }, + { + query: { filter3: '1' }, + result: { urlversion: '2', filter1: '1', filter4: '1', filter3: '1' }, + message: 'urlversion 1 with an extra param value returns query that is joined with defaults' + } + ]; + + filtersModel.initializeFilters( mockFilterStructure ); + uriProcessor = new mw.rcfilters.UriProcessor( filtersModel ); + + cases.forEach( function ( testCase ) { + assert.deepEqual( + uriProcessor._getNormalizedQueryParams( testCase.query ), + testCase.result, + testCase.message + ); + } ); + } ); + + QUnit.test( '_normalizeTargetInUri', function ( assert ) { + var cases = [ + { + input: 'http://host/wiki/Special:RecentChangesLinked/Moai', + output: 'http://host/wiki/Special:RecentChangesLinked?target=Moai', + message: 'Target as subpage in path' + }, + { + input: 'http://host/wiki/Special:RecentChangesLinked/Château', + output: 'http://host/wiki/Special:RecentChangesLinked?target=Château', + message: 'Target as subpage in path with special characters' + }, + { + input: 'http://host/wiki/Special:RecentChangesLinked/Moai/Sub1', + output: 'http://host/wiki/Special:RecentChangesLinked?target=Moai/Sub1', + message: 'Target as subpage also has a subpage' + }, + { + input: 'http://host/wiki/Special:RecentChangesLinked/Category:Foo', + output: 'http://host/wiki/Special:RecentChangesLinked?target=Category:Foo', + message: 'Target as subpage in path (with namespace)' + }, + { + input: 'http://host/wiki/Special:RecentChangesLinked/Category:Foo/Bar', + output: 'http://host/wiki/Special:RecentChangesLinked?target=Category:Foo/Bar', + message: 'Target as subpage in path also has a subpage (with namespace)' + }, + { + input: 'http://host/w/index.php?title=Special:RecentChangesLinked/Moai', + output: 'http://host/w/index.php?title=Special:RecentChangesLinked&target=Moai', + message: 'Target as subpage in title param' + }, + { + input: 'http://host/w/index.php?title=Special:RecentChangesLinked/Moai/Sub1', + output: 'http://host/w/index.php?title=Special:RecentChangesLinked&target=Moai/Sub1', + message: 'Target as subpage in title param also has a subpage' + }, + { + input: 'http://host/w/index.php?title=Special:RecentChangesLinked/Category:Foo/Bar', + output: 'http://host/w/index.php?title=Special:RecentChangesLinked&target=Category:Foo/Bar', + message: 'Target as subpage in title param also has a subpage (with namespace)' + }, + { + input: 'http://host/wiki/Special:Watchlist', + output: 'http://host/wiki/Special:Watchlist', + message: 'No target specified' + }, + { + normalizeTarget: false, + input: 'http://host/wiki/Special:RecentChanges/Foo', + output: 'http://host/wiki/Special:RecentChanges/Foo', + message: 'Do not normalize if "normalizeTarget" is false.' + } + ]; + + cases.forEach( function ( testCase ) { + var uriProcessor = new mw.rcfilters.UriProcessor( + null, + { + normalizeTarget: testCase.normalizeTarget === undefined ? + true : testCase.normalizeTarget + } + ); + + assert.equal( + uriProcessor._normalizeTargetInUri( + new mw.Uri( testCase.input ) + ).toString(), + new mw.Uri( testCase.output ).toString(), + testCase.message + ); + } ); + } ); + +}( mediaWiki, jQuery ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FilterItem.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FilterItem.test.js new file mode 100644 index 00000000..18a2c9ce --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FilterItem.test.js @@ -0,0 +1,205 @@ +/* eslint-disable camelcase */ +( function ( mw ) { + QUnit.module( 'mediawiki.rcfilters - FilterItem' ); + + QUnit.test( 'Initializing filter item', function ( assert ) { + var item, + group1 = new mw.rcfilters.dm.FilterGroup( 'group1' ), + group2 = new mw.rcfilters.dm.FilterGroup( 'group2' ); + + item = new mw.rcfilters.dm.FilterItem( 'filter1', group1 ); + assert.equal( + item.getName(), + 'group1__filter1', + 'Filter name is retained.' + ); + assert.equal( + item.getGroupName(), + 'group1', + 'Group name is retained.' + ); + + item = new mw.rcfilters.dm.FilterItem( + 'filter1', + group1, + { + label: 'test label', + description: 'test description' + } + ); + assert.equal( + item.getLabel(), + 'test label', + 'Label information is retained.' + ); + assert.equal( + item.getLabel(), + 'test label', + 'Description information is retained.' + ); + + item = new mw.rcfilters.dm.FilterItem( + 'filter1', + group1, + { + selected: true + } + ); + assert.equal( + item.isSelected(), + true, + 'Item can be selected in the config.' + ); + item.toggleSelected( true ); + assert.equal( + item.isSelected(), + true, + 'Item can toggle its selected state.' + ); + + // Subsets + item = new mw.rcfilters.dm.FilterItem( + 'filter1', + group1, + { + subset: [ 'sub1', 'sub2', 'sub3' ] + } + ); + assert.deepEqual( + item.getSubset(), + [ 'sub1', 'sub2', 'sub3' ], + 'Subset information is retained.' + ); + assert.equal( + item.existsInSubset( 'sub1' ), + true, + 'Specific item exists in subset.' + ); + assert.equal( + item.existsInSubset( 'sub10' ), + false, + 'Specific item does not exists in subset.' + ); + assert.equal( + item.isIncluded(), + false, + 'Initial state of "included" is false.' + ); + + item.toggleIncluded( true ); + assert.equal( + item.isIncluded(), + true, + 'Item toggles its included state.' + ); + + // Conflicts + item = new mw.rcfilters.dm.FilterItem( + 'filter1', + group1, + { + conflicts: { + group2__conflict1: { group: 'group2', filter: 'group2__conflict1' }, + group2__conflict2: { group: 'group2', filter: 'group2__conflict2' }, + group2__conflict3: { group: 'group2', filter: 'group2__conflict3' } + } + } + ); + assert.deepEqual( + item.getConflicts(), + { + group2__conflict1: { group: 'group2', filter: 'group2__conflict1' }, + group2__conflict2: { group: 'group2', filter: 'group2__conflict2' }, + group2__conflict3: { group: 'group2', filter: 'group2__conflict3' } + }, + 'Conflict information is retained.' + ); + assert.equal( + item.existsInConflicts( new mw.rcfilters.dm.FilterItem( 'conflict1', group2 ) ), + true, + 'Specific item exists in conflicts.' + ); + assert.equal( + item.existsInConflicts( new mw.rcfilters.dm.FilterItem( 'conflict10', group1 ) ), + false, + 'Specific item does not exists in conflicts.' + ); + assert.equal( + item.isConflicted(), + false, + 'Initial state of "conflicted" is false.' + ); + + item.toggleConflicted( true ); + assert.equal( + item.isConflicted(), + true, + 'Item toggles its conflicted state.' + ); + + // Fully covered + item = new mw.rcfilters.dm.FilterItem( 'filter1', group1 ); + assert.equal( + item.isFullyCovered(), + false, + 'Initial state of "full coverage" is false.' + ); + item.toggleFullyCovered( true ); + assert.equal( + item.isFullyCovered(), + true, + 'Item toggles its fully coverage state.' + ); + + } ); + + QUnit.test( 'Emitting events', function ( assert ) { + var group1 = new mw.rcfilters.dm.FilterGroup( 'group1' ), + item = new mw.rcfilters.dm.FilterItem( 'filter1', group1 ), + events = []; + + // Listen to update events + item.on( 'update', function () { + events.push( item.getState() ); + } ); + + // Do stuff + item.toggleSelected( true ); // { selected: true, included: false, conflicted: false, fullyCovered: false } + item.toggleSelected( true ); // No event (duplicate state) + item.toggleIncluded( true ); // { selected: true, included: true, conflicted: false, fullyCovered: false } + item.toggleConflicted( true ); // { selected: true, included: true, conflicted: true, fullyCovered: false } + item.toggleFullyCovered( true ); // { selected: true, included: true, conflicted: true, fullyCovered: true } + item.toggleSelected(); // { selected: false, included: true, conflicted: true, fullyCovered: true } + + // Check emitted events + assert.deepEqual( + events, + [ + { selected: true, included: false, conflicted: false, fullyCovered: false }, + { selected: true, included: true, conflicted: false, fullyCovered: false }, + { selected: true, included: true, conflicted: true, fullyCovered: false }, + { selected: true, included: true, conflicted: true, fullyCovered: true }, + { selected: false, included: true, conflicted: true, fullyCovered: true } + ], + 'Events emitted successfully.' + ); + } ); + + QUnit.test( 'get/set boolean value', function ( assert ) { + var group = new mw.rcfilters.dm.FilterGroup( 'group1', { type: 'boolean' } ), + item = new mw.rcfilters.dm.FilterItem( 'filter1', group ); + + item.setValue( '1' ); + + assert.equal( item.getValue(), true, 'Value is coerced to boolean' ); + } ); + + QUnit.test( 'get/set any value', function ( assert ) { + var group = new mw.rcfilters.dm.FilterGroup( 'group1', { type: 'any_value' } ), + item = new mw.rcfilters.dm.FilterItem( 'filter1', group ); + + item.setValue( '1' ); + + assert.equal( item.getValue(), '1', 'Value is kept as-is' ); + } ); +}( mediaWiki ) ); 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 ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/dm.SavedQueriesModel.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/dm.SavedQueriesModel.test.js new file mode 100644 index 00000000..ed054bd7 --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/dm.SavedQueriesModel.test.js @@ -0,0 +1,520 @@ +/* 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', cssClass: 'filter1class' }, + { name: 'filter2', cssClass: 'filter2class', default: true }, + { name: 'filter3', cssClass: 'filter3class' } + ] + }, { + name: 'group2', + type: 'string_options', + separator: ',', + filters: [ + { name: 'filter4', cssClass: 'filter4class' }, + { name: 'filter5' }, // NOTE: Not supporting highlights! + { name: 'filter6', cssClass: 'filter6class' } + ] + }, { + name: 'group3', + type: 'boolean', + sticky: true, + filters: [ + { name: 'group3option1', cssClass: 'filter1class' }, + { name: 'group3option2', cssClass: 'filter1class' }, + { name: 'group3option3', cssClass: 'filter1class' } + ] + }, { + // Copy of the way the controller defines invert + // to check whether the conversion works + name: 'invertGroup', + type: 'boolean', + hidden: true, + filters: [ { + name: 'invert', + default: '0' + } ] + } ], + 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, + group1__filter1: 'c5', + group3__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 + }, + highlights: { + group1__filter1_color: 'c5', + group3__group3option1_color: 'c1' + } + } + } + } + }, + removeHighlights = function ( data ) { + var copy = $.extend( true, {}, data ); + copy.queries[ 1234 ].data.highlights = {}; + return copy; + }; + + 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: { + group1__filter3_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 + input: $.extend( true, {}, queriesFilterRepresentation, { queries: { 1234: { data: { + filters: { + // Entire group true: normalize params + filter1: true, + filter2: true, + filter3: true + }, + highlights: { + filter3: null // Get rid of empty highlight + } + } } } } ), + finalState: $.extend( true, {}, queriesParamRepresentation ), + msg: 'Conversion from filter representation to parameters normalizes params and highlights.' + }, + { + // 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.' + }, + { + // Converting from old structure and cleaning up highlights + input: $.extend( true, queriesFilterRepresentation, { queries: { 1234: { data: { highlights: { highlight: false } } } } } ), + finalState: removeHighlights( queriesParamRepresentation ), + msg: 'Conversion from filter representation to parameters and highlight cleanup' + }, + { + // New structure + input: $.extend( true, {}, queriesParamRepresentation ), + finalState: $.extend( true, {}, queriesParamRepresentation ), + msg: 'Parameter representation retains its queries structure' + }, + { + // Do not touch invalid color parameters from the initialization routine + // (Normalization, or "fixing" the query should only happen when we add new query or actively convert queries) + input: $.extend( true, { queries: { 1234: { data: { highlights: { group2__filter5_color: 'c2' } } } } }, exampleQueryStructure ), + finalState: $.extend( true, { queries: { 1234: { data: { highlights: { group2__filter5_color: 'c2' } } } } }, exampleQueryStructure ), + msg: 'Structure that contains invalid highlights remains the same in initialization' + }, + { + // Trim colors when highlight=false is stored + input: $.extend( true, { queries: { 1234: { data: { params: { highlight: '0' } } } } }, queriesParamRepresentation ), + finalState: removeHighlights( queriesParamRepresentation ), + msg: 'Colors are removed when highlight=false' + }, + { + // Remove highlight when it is true but no colors are specified + input: $.extend( true, { queries: { 1234: { data: { params: { highlight: '1' } } } } }, removeHighlights( queriesParamRepresentation ) ), + finalState: removeHighlights( queriesParamRepresentation ), + msg: 'remove highlight when it is true but there is no colors' + } + ]; + + filtersModel.initializeFilters( filterDefinition ); + + cases.forEach( function ( testCase ) { + queriesModel.initialize( testCase.input ); + assert.deepEqual( + queriesModel.getState(), + testCase.finalState, + testCase.msg + ); + } ); + } ); + + QUnit.test( 'Adding new queries', function ( assert ) { + var filtersModel = new mw.rcfilters.dm.FiltersViewModel(), + queriesModel = new mw.rcfilters.dm.SavedQueriesModel( filtersModel ), + cases = [ + { + methodParams: [ + 'label1', // Label + { // Data + filter1: '1', + filter2: '2', + group1__filter1_color: 'c2', + group1__filter3_color: 'c5' + }, + true, // isDefault + '1234' // ID + ], + result: { + itemState: { + label: 'label1', + data: { + params: { + filter1: '1', + filter2: '2' + }, + highlights: { + group1__filter1_color: 'c2', + group1__filter3_color: 'c5' + } + } + }, + isDefault: true, + id: '1234' + }, + msg: 'Given valid data is preserved.' + }, + { + methodParams: [ + 'label2', + { + filter1: '1', + invert: '1', + filter15: '1', // Invalid filter - removed + filter2: '0', // Falsey value - removed + group1__filter1_color: 'c3', + foobar: 'w00t' // Unrecognized parameter - removed + } + ], + result: { + itemState: { + label: 'label2', + data: { + params: { + filter1: '1' // Invert will be dropped because there are no namespaces + }, + highlights: { + group1__filter1_color: 'c3' + } + } + }, + isDefault: false + }, + msg: 'Given data with invalid filters and highlights is normalized' + } + ]; + + filtersModel.initializeFilters( filterDefinition ); + + // Start with an empty saved queries model + queriesModel.initialize( {} ); + + cases.forEach( function ( testCase ) { + var itemID = queriesModel.addNewQuery.apply( queriesModel, testCase.methodParams ), + item = queriesModel.getItemByID( itemID ); + + assert.deepEqual( + item.getState(), + testCase.result.itemState, + testCase.msg + ' (itemState)' + ); + + assert.equal( + item.isDefault(), + testCase.result.isDefault, + testCase.msg + ' (isDefault)' + ); + + if ( testCase.result.id !== undefined ) { + assert.equal( + item.getID(), + testCase.result.id, + testCase.msg + ' (item ID)' + ); + } + } ); + } ); + + 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', + { + group2: 'filter5', + group1__filter1_color: 'c5', + group3__group3option1_color: 'c1' + } + ); + id2 = queriesModel.addNewQuery( + 'New query 2', + { + filter1: '1', + filter2: '1', + invert: '1' + } + ); + 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' + }, + highlights: { + group1__filter1_color: 'c5', + group3__group3option1_color: 'c1' + } + } + }; + queriesStructure[ id2 ] = { + label: 'New query 2', + data: { + params: { + filter1: '1', + filter2: '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( + { + group2: 'filter5', + group1__filter1_color: 'c5', + group3__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( + { + group2: 'filter5', + filter1: '0', + filter2: '0', + group1__filter1_color: 'c5', + group3__group3option1_color: 'c1' + } + ); + assert.deepEqual( + matchingItem.getID(), + id1, + 'Finding matching item by "dirty" state with 0-base values' + ); + } ); + + QUnit.test( 'Testing invert property', function ( assert ) { + var itemID, item, + filtersModel = new mw.rcfilters.dm.FiltersViewModel(), + queriesModel = new mw.rcfilters.dm.SavedQueriesModel( filtersModel ), + viewsDefinition = { + namespace: { + 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' } + ] + } ] + } + }; + + filtersModel.initializeFilters( filterDefinition, viewsDefinition ); + + // Start with an empty saved queries model + queriesModel.initialize( {} ); + + filtersModel.toggleFiltersSelected( { + group1__filter3: true, + invertGroup__invert: true + } ); + itemID = queriesModel.addNewQuery( + 'label1', // Label + filtersModel.getMinimizedParamRepresentation(), + true, // isDefault + '2345' // ID + ); + item = queriesModel.getItemByID( itemID ); + + assert.deepEqual( + item.getState(), + { + label: 'label1', + data: { + params: { + filter1: '1', + filter2: '1' + }, + highlights: {} + } + }, + 'Invert parameter is not saved if there are no namespaces.' + ); + + // Reset + filtersModel.initializeFilters( filterDefinition, viewsDefinition ); + filtersModel.toggleFiltersSelected( { + group1__filter3: true, + invertGroup__invert: true, + namespace__1: true + } ); + itemID = queriesModel.addNewQuery( + 'label1', // Label + filtersModel.getMinimizedParamRepresentation(), + true, // isDefault + '1234' // ID + ); + item = queriesModel.getItemByID( itemID ); + + assert.deepEqual( + item.getState(), + { + label: 'label1', + data: { + params: { + filter1: '1', + filter2: '1', + invert: '1', + namespace: '1' + }, + highlights: {} + } + }, + 'Invert parameter saved if there are namespaces.' + ); + } ); +}( mediaWiki ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/dm.SavedQueryItemModel.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/dm.SavedQueryItemModel.test.js new file mode 100644 index 00000000..181e9925 --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki.rcfilters/dm.SavedQueryItemModel.test.js @@ -0,0 +1,89 @@ +/* eslint-disable camelcase */ +( function ( mw ) { + var itemData = { + params: { + param1: '1', + param2: 'foo|bar', + 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 ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki.special/mediawiki.special.recentchanges.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki.special/mediawiki.special.recentchanges.test.js new file mode 100644 index 00000000..14c2bb4c --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki.special/mediawiki.special.recentchanges.test.js @@ -0,0 +1,64 @@ +( function ( $ ) { + QUnit.module( 'mediawiki.special.recentchanges', QUnit.newMwEnvironment() ); + + // TODO: verify checkboxes == [ 'nsassociated', 'nsinvert' ] + + QUnit.test( '"all" namespace disable checkboxes', function ( assert ) { + var selectHtml, $env, $options, + rc = require( 'mediawiki.special.recentchanges' ); + + // from Special:Recentchanges + selectHtml = '<select id="namespace" name="namespace" class="namespaceselector">' + + '<option value="" selected="selected">all</option>' + + '<option value="0">(Main)</option>' + + '<option value="1">Talk</option>' + + '<option value="2">User</option>' + + '<option value="3">User talk</option>' + + '<option value="4">ProjectName</option>' + + '<option value="5">ProjectName talk</option>' + + '</select>' + + '<input name="invert" type="checkbox" value="1" id="nsinvert" title="no title" />' + + '<label for="nsinvert" title="no title">Invert selection</label>' + + '<input name="associated" type="checkbox" value="1" id="nsassociated" title="no title" />' + + '<label for="nsassociated" title="no title">Associated namespace</label>' + + '<input type="submit" value="Go" />' + + '<input type="hidden" value="Special:RecentChanges" name="title" />'; + + $env = $( '<div>' ).html( selectHtml ).appendTo( 'body' ); + + // TODO abstract the double strictEquals + + // At first checkboxes are enabled + assert.strictEqual( $( '#nsinvert' ).prop( 'disabled' ), false ); + assert.strictEqual( $( '#nsassociated' ).prop( 'disabled' ), false ); + + // Initiate the recentchanges module + rc.init(); + + // By default + assert.strictEqual( $( '#nsinvert' ).prop( 'disabled' ), true ); + assert.strictEqual( $( '#nsassociated' ).prop( 'disabled' ), true ); + + // select second option... + $options = $( '#namespace' ).find( 'option' ); + $options.eq( 0 ).removeProp( 'selected' ); + $options.eq( 1 ).prop( 'selected', true ); + $( '#namespace' ).change(); + + // ... and checkboxes should be enabled again + assert.strictEqual( $( '#nsinvert' ).prop( 'disabled' ), false ); + assert.strictEqual( $( '#nsassociated' ).prop( 'disabled' ), false ); + + // select first option ( 'all' namespace)... + $options.eq( 1 ).removeProp( 'selected' ); + $options.eq( 0 ).prop( 'selected', true ); + $( '#namespace' ).change(); + + // ... and checkboxes should now be disabled + assert.strictEqual( $( '#nsinvert' ).prop( 'disabled' ), true ); + assert.strictEqual( $( '#nsassociated' ).prop( 'disabled' ), true ); + + // DOM cleanup + $env.remove(); + } ); +}( jQuery ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.RegExp.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.RegExp.test.js new file mode 100644 index 00000000..4e15cf01 --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.RegExp.test.js @@ -0,0 +1,38 @@ +( function ( mw ) { + QUnit.module( 'mediawiki.RegExp' ); + + QUnit.test( 'escape', function ( assert ) { + var specials, normal; + + specials = [ + '\\', + '{', + '}', + '(', + ')', + '[', + ']', + '|', + '.', + '?', + '*', + '+', + '-', + '^', + '$' + ]; + + normal = [ + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + 'abcdefghijklmnopqrstuvwxyz', + '0123456789' + ].join( '' ); + + specials.forEach( function ( str ) { + assert.propEqual( str.match( new RegExp( mw.RegExp.escape( str ) ) ), [ str ], 'Match ' + str ); + } ); + + assert.equal( mw.RegExp.escape( normal ), normal, 'Alphanumerals are left alone' ); + } ); + +}( mediaWiki ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.String.byteLength.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.String.byteLength.test.js new file mode 100644 index 00000000..ae3ebbf7 --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.String.byteLength.test.js @@ -0,0 +1,39 @@ +( function () { + var byteLength = require( 'mediawiki.String' ).byteLength; + + QUnit.module( 'mediawiki.String.byteLength', QUnit.newMwEnvironment() ); + + QUnit.test( 'Simple text', function ( assert ) { + var azLc = 'abcdefghijklmnopqrstuvwxyz', + azUc = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + num = '0123456789', + x = '*', + space = ' '; + + assert.equal( byteLength( azLc ), 26, 'Lowercase a-z' ); + assert.equal( byteLength( azUc ), 26, 'Uppercase A-Z' ); + assert.equal( byteLength( num ), 10, 'Numbers 0-9' ); + assert.equal( byteLength( x ), 1, 'An asterisk' ); + assert.equal( byteLength( space ), 3, '3 spaces' ); + + } ); + + QUnit.test( 'Special text', function ( assert ) { + // https://en.wikipedia.org/wiki/UTF-8 + var u0024 = '$', + // Cent symbol + u00A2 = '\u00A2', + // Euro symbol + u20AC = '\u20AC', + // Character \U00024B62 (Han script) can't be represented in javascript as a single + // code point, instead it is composed as a surrogate pair of two separate code units. + // http://codepoints.net/U+24B62 + // http://www.fileformat.info/info/unicode/char/24B62/index.htm + u024B62 = '\uD852\uDF62'; + + assert.strictEqual( byteLength( u0024 ), 1, 'U+0024' ); + assert.strictEqual( byteLength( u00A2 ), 2, 'U+00A2' ); + assert.strictEqual( byteLength( u20AC ), 3, 'U+20AC' ); + assert.strictEqual( byteLength( u024B62 ), 4, 'U+024B62 (surrogate pair: \\uD852\\uDF62)' ); + } ); +}() ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.String.trimByteLength.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.String.trimByteLength.test.js new file mode 100644 index 00000000..e2eea94e --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.String.trimByteLength.test.js @@ -0,0 +1,150 @@ +( function ( $, mw ) { + var simpleSample, U_20AC, poop, mbSample, + trimByteLength = require( 'mediawiki.String' ).trimByteLength; + + QUnit.module( 'mediawiki.String.trimByteLength', QUnit.newMwEnvironment() ); + + // Simple sample (20 chars, 20 bytes) + simpleSample = '12345678901234567890'; + + // 3 bytes (euro-symbol) + U_20AC = '\u20AC'; + + // Outside of the BMP (pile of poo emoji) + poop = '\uD83D\uDCA9'; // "💩" + + // Multi-byte sample (22 chars, 26 bytes) + mbSample = '1234567890' + U_20AC + '1234567890' + U_20AC; + + /** + * Test factory for mw.String#trimByteLength + * + * @param {Object} options + * @param {string} options.description Test name + * @param {string} options.sample Sequence of characters to trim + * @param {string} [options.initial] Previous value of the sequence of characters, if any + * @param {Number} options.limit Length to trim to + * @param {Function} [options.fn] Filter function + * @param {string} options.expected Expected final value + */ + function byteLimitTest( options ) { + var opt = $.extend( { + description: '', + sample: '', + initial: '', + limit: 0, + fn: function ( a ) { return a; }, + expected: '' + }, options ); + + QUnit.test( opt.description, function ( assert ) { + var res = trimByteLength( opt.initial, opt.sample, opt.limit, opt.fn ); + + assert.equal( + res.newVal, + opt.expected, + 'New value matches the expected string' + ); + } ); + } + + byteLimitTest( { + description: 'Limit using the maxlength attribute', + limit: 10, + sample: simpleSample, + expected: '1234567890' + } ); + + byteLimitTest( { + description: 'Limit using a custom value (multibyte)', + limit: 14, + sample: mbSample, + expected: '1234567890' + U_20AC + '1' + } ); + + byteLimitTest( { + description: 'Limit using a custom value (multibyte, outside BMP)', + limit: 3, + sample: poop, + expected: '' + } ); + + byteLimitTest( { + description: 'Limit using a custom value (multibyte) overlapping a byte', + limit: 12, + sample: mbSample, + expected: '1234567890' + } ); + + byteLimitTest( { + description: 'Pass the limit and a callback as input filter', + limit: 6, + fn: function ( val ) { + var title = mw.Title.newFromText( String( val ) ); + // Return without namespace prefix + return title ? title.getMain() : ''; + }, + sample: 'User:Sample', + expected: 'User:Sample' + } ); + + byteLimitTest( { + description: 'Pass the limit and a callback as input filter', + limit: 6, + fn: function ( val ) { + var title = mw.Title.newFromText( String( val ) ); + // Return without namespace prefix + return title ? title.getMain() : ''; + }, + sample: 'User:Example', + // The callback alters the value to be used to calculeate + // the length. The altered value is "Exampl" which has + // a length of 6, the "e" would exceed the limit. + expected: 'User:Exampl' + } ); + + byteLimitTest( { + description: 'Input filter that increases the length', + limit: 10, + fn: function ( text ) { + return 'prefix' + text; + }, + sample: simpleSample, + // Prefix adds 6 characters, limit is reached after 4 + expected: '1234' + } ); + + byteLimitTest( { + description: 'Trim from insertion when limit exceeded', + limit: 3, + initial: 'abc', + sample: 'zabc', + // Trim from the insertion point (at 0), not the end + expected: 'abc' + } ); + + byteLimitTest( { + description: 'Trim from insertion when limit exceeded', + limit: 3, + initial: 'abc', + sample: 'azbc', + // Trim from the insertion point (at 1), not the end + expected: 'abc' + } ); + + byteLimitTest( { + description: 'Do not cut up false matching substrings in emoji insertions', + limit: 12, + initial: '\uD83D\uDCA9\uD83D\uDCA9', // "💩💩" + sample: '\uD83D\uDCA9\uD83D\uDCB9\uD83E\uDCA9\uD83D\uDCA9', // "💩💹🢩💩" + expected: '\uD83D\uDCA9\uD83D\uDCB9\uD83D\uDCA9' // "💩💹💩" + } ); + + byteLimitTest( { + description: 'Unpaired surrogates do not crash', + limit: 4, + sample: '\uD800\uD800\uDFFF', + expected: '\uD800' + } ); + +}( jQuery, mediaWiki ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.Title.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.Title.test.js new file mode 100644 index 00000000..d6fe744f --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.Title.test.js @@ -0,0 +1,738 @@ +( function ( mw, $ ) { + /* eslint-disable camelcase */ + var repeat = function ( input, multiplier ) { + return new Array( multiplier + 1 ).join( input ); + }, + // See also TitleTest.php#testSecureAndSplit + cases = { + valid: [ + 'Sandbox', + 'A "B"', + 'A \'B\'', + '.com', + '~', + '"', + '\'', + 'Talk:Sandbox', + 'Talk:Foo:Sandbox', + 'File:Example.svg', + 'File_talk:Example.svg', + 'Foo/.../Sandbox', + 'Sandbox/...', + 'A~~', + ':A', + // Length is 256 total, but only title part matters + 'Category:' + repeat( 'x', 248 ), + repeat( 'x', 252 ) + ], + invalid: [ + '', + ':', + '__ __', + ' __ ', + // Bad characters forbidden regardless of wgLegalTitleChars + 'A [ B', + 'A ] B', + 'A { B', + 'A } B', + 'A < B', + 'A > B', + 'A | B', + 'A \t B', + 'A \n B', + // URL encoding + 'A%20B', + 'A%23B', + 'A%2523B', + // XML/HTML character entity references + // Note: The ones with # are commented out as those are interpreted as fragment and + // as such end up being valid. + 'A é B', + // 'A é B', + // 'A é B', + // Subject of NS_TALK does not roundtrip to NS_MAIN + 'Talk:File:Example.svg', + // Directory navigation + '.', + '..', + './Sandbox', + '../Sandbox', + 'Foo/./Sandbox', + 'Foo/../Sandbox', + 'Sandbox/.', + 'Sandbox/..', + // Tilde + 'A ~~~ Name', + 'A ~~~~ Signature', + 'A ~~~~~ Timestamp', + repeat( 'x', 256 ), + // Extension separation is a js invention, for length + // purposes it is part of the title + repeat( 'x', 252 ) + '.json', + // Namespace prefix without actual title + 'Talk:', + 'Category: ', + 'Category: #bar' + ] + }; + + QUnit.module( 'mediawiki.Title', QUnit.newMwEnvironment( { + // mw.Title relies on these three config vars + // Restore them after each test run + config: { + wgFormattedNamespaces: { + '-2': 'Media', + '-1': 'Special', + 0: '', + 1: 'Talk', + 2: 'User', + 3: 'User talk', + 4: 'Wikipedia', + 5: 'Wikipedia talk', + 6: 'File', + 7: 'File talk', + 8: 'MediaWiki', + 9: 'MediaWiki talk', + 10: 'Template', + 11: 'Template talk', + 12: 'Help', + 13: 'Help talk', + 14: 'Category', + 15: 'Category talk', + // testing custom / localized namespace + 100: 'Penguins' + }, + wgNamespaceIds: { + media: -2, + special: -1, + '': 0, + talk: 1, + user: 2, + user_talk: 3, + wikipedia: 4, + wikipedia_talk: 5, + file: 6, + file_talk: 7, + mediawiki: 8, + mediawiki_talk: 9, + template: 10, + template_talk: 11, + help: 12, + help_talk: 13, + category: 14, + category_talk: 15, + image: 6, + image_talk: 7, + project: 4, + project_talk: 5, + // Testing custom namespaces and aliases + penguins: 100, + antarctic_waterfowl: 100 + }, + wgCaseSensitiveNamespaces: [] + } + } ) ); + + QUnit.test( 'constructor', function ( assert ) { + var i, title; + for ( i = 0; i < cases.valid.length; i++ ) { + title = new mw.Title( cases.valid[ i ] ); + } + for ( i = 0; i < cases.invalid.length; i++ ) { + title = cases.invalid[ i ]; + // eslint-disable-next-line no-loop-func + assert.throws( function () { + return new mw.Title( title ); + }, cases.invalid[ i ] ); + } + } ); + + QUnit.test( 'newFromText', function ( assert ) { + var i; + for ( i = 0; i < cases.valid.length; i++ ) { + assert.equal( + $.type( mw.Title.newFromText( cases.valid[ i ] ) ), + 'object', + cases.valid[ i ] + ); + } + for ( i = 0; i < cases.invalid.length; i++ ) { + assert.equal( + $.type( mw.Title.newFromText( cases.invalid[ i ] ) ), + 'null', + cases.invalid[ i ] + ); + } + } ); + + QUnit.test( 'makeTitle', function ( assert ) { + var cases, i, title, expected, + NS_MAIN = 0, + NS_TALK = 1, + NS_TEMPLATE = 10; + + cases = [ + [ NS_TEMPLATE, 'Foo', 'Template:Foo' ], + [ NS_TEMPLATE, 'Category:Foo', 'Template:Category:Foo' ], + [ NS_TEMPLATE, 'Template:Foo', 'Template:Template:Foo' ], + [ NS_TALK, 'Help:Foo', null ], + [ NS_TEMPLATE, '<', null ], + [ NS_MAIN, 'Help:Foo', 'Help:Foo' ] + ]; + + for ( i = 0; i < cases.length; i++ ) { + title = mw.Title.makeTitle( cases[ i ][ 0 ], cases[ i ][ 1 ] ); + expected = cases[ i ][ 2 ]; + if ( expected === null ) { + assert.strictEqual( title, expected ); + } else { + assert.strictEqual( title.getPrefixedText(), expected ); + } + } + } ); + + QUnit.test( 'Basic parsing', function ( assert ) { + var title; + title = new mw.Title( 'File:Foo_bar.JPG' ); + + assert.equal( title.getNamespaceId(), 6 ); + assert.equal( title.getNamespacePrefix(), 'File:' ); + assert.equal( title.getName(), 'Foo_bar' ); + assert.equal( title.getNameText(), 'Foo bar' ); + assert.equal( title.getExtension(), 'JPG' ); + assert.equal( title.getDotExtension(), '.JPG' ); + assert.equal( title.getMain(), 'Foo_bar.JPG' ); + assert.equal( title.getMainText(), 'Foo bar.JPG' ); + assert.equal( title.getPrefixedDb(), 'File:Foo_bar.JPG' ); + assert.equal( title.getPrefixedText(), 'File:Foo bar.JPG' ); + + title = new mw.Title( 'Foo#bar' ); + assert.equal( title.getPrefixedText(), 'Foo' ); + assert.equal( title.getFragment(), 'bar' ); + + title = new mw.Title( '.foo' ); + assert.equal( title.getPrefixedText(), '.foo' ); + assert.equal( title.getName(), '' ); + assert.equal( title.getNameText(), '' ); + assert.equal( title.getExtension(), 'foo' ); + assert.equal( title.getDotExtension(), '.foo' ); + assert.equal( title.getMain(), '.foo' ); + assert.equal( title.getMainText(), '.foo' ); + assert.equal( title.getPrefixedDb(), '.foo' ); + assert.equal( title.getPrefixedText(), '.foo' ); + } ); + + QUnit.test( 'Transformation', function ( assert ) { + var title; + + title = new mw.Title( 'File:quux pif.jpg' ); + assert.equal( title.getNameText(), 'Quux pif', 'First character of title' ); + + title = new mw.Title( 'File:Glarg_foo_glang.jpg' ); + assert.equal( title.getNameText(), 'Glarg foo glang', 'Underscores' ); + + title = new mw.Title( 'User:ABC.DEF' ); + assert.equal( title.toText(), 'User:ABC.DEF', 'Round trip text' ); + assert.equal( title.getNamespaceId(), 2, 'Parse canonical namespace prefix' ); + + title = new mw.Title( 'Image:quux pix.jpg' ); + assert.equal( title.getNamespacePrefix(), 'File:', 'Transform alias to canonical namespace' ); + + title = new mw.Title( 'uSEr:hAshAr' ); + assert.equal( title.toText(), 'User:HAshAr' ); + assert.equal( title.getNamespaceId(), 2, 'Case-insensitive namespace prefix' ); + + title = new mw.Title( 'Foo \u00A0\u1680\u180E\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u2028\u2029\u202F\u205F\u3000 bar' ); + assert.equal( title.getMain(), 'Foo_bar', 'Merge multiple types of whitespace/underscores into a single underscore' ); + + title = new mw.Title( 'Foo\u200E\u200F\u202A\u202B\u202C\u202D\u202Ebar' ); + assert.equal( title.getMain(), 'Foobar', 'Strip Unicode bidi override characters' ); + + // Regression test: Previously it would only detect an extension if there is no space after it + title = new mw.Title( 'Example.js ' ); + assert.equal( title.getExtension(), 'js', 'Space after an extension is stripped' ); + + title = new mw.Title( 'Example#foo' ); + assert.equal( title.getFragment(), 'foo', 'Fragment' ); + + title = new mw.Title( 'Example#_foo_bar baz_' ); + assert.equal( title.getFragment(), ' foo bar baz', 'Fragment' ); + } ); + + QUnit.test( 'Namespace detection and conversion', function ( assert ) { + var title; + + title = new mw.Title( 'File:User:Example' ); + assert.equal( title.getNamespaceId(), 6, 'Titles can contain namespace prefixes, which are otherwise ignored' ); + + title = new mw.Title( 'Example', 6 ); + assert.equal( title.getNamespaceId(), 6, 'Default namespace passed is used' ); + + title = new mw.Title( 'User:Example', 6 ); + assert.equal( title.getNamespaceId(), 2, 'Included namespace prefix overrides the given default' ); + + title = new mw.Title( ':Example', 6 ); + assert.equal( title.getNamespaceId(), 0, 'Colon forces main namespace' ); + + title = new mw.Title( 'something.PDF', 6 ); + assert.equal( title.toString(), 'File:Something.PDF' ); + + title = new mw.Title( 'NeilK', 3 ); + assert.equal( title.toString(), 'User_talk:NeilK' ); + assert.equal( title.toText(), 'User talk:NeilK' ); + + title = new mw.Title( 'Frobisher', 100 ); + assert.equal( title.toString(), 'Penguins:Frobisher' ); + + title = new mw.Title( 'antarctic_waterfowl:flightless_yet_cute.jpg' ); + assert.equal( title.toString(), 'Penguins:Flightless_yet_cute.jpg' ); + + title = new mw.Title( 'Penguins:flightless_yet_cute.jpg' ); + assert.equal( title.toString(), 'Penguins:Flightless_yet_cute.jpg' ); + } ); + + QUnit.test( 'Throw error on invalid title', function ( assert ) { + assert.throws( function () { + return new mw.Title( '' ); + }, 'Throw error on empty string' ); + } ); + + QUnit.test( 'Case-sensivity', function ( assert ) { + var title; + + // Default config + mw.config.set( 'wgCaseSensitiveNamespaces', [] ); + + title = new mw.Title( 'article' ); + assert.equal( title.toString(), 'Article', 'Default config: No sensitive namespaces by default. First-letter becomes uppercase' ); + + title = new mw.Title( 'ß' ); + assert.equal( title.toString(), 'ß', 'Uppercasing matches PHP behaviour (ß -> ß, not SS)' ); + + title = new mw.Title( 'dž (digraph)' ); + assert.equal( title.toString(), 'Dž_(digraph)', 'Uppercasing matches PHP behaviour (dž -> Dž, not DŽ)' ); + + // $wgCapitalLinks = false; + mw.config.set( 'wgCaseSensitiveNamespaces', [ 0, -2, 1, 4, 5, 6, 7, 10, 11, 12, 13, 14, 15 ] ); + + title = new mw.Title( 'article' ); + assert.equal( title.toString(), 'article', '$wgCapitalLinks=false: Article namespace is sensitive, first-letter case stays lowercase' ); + + title = new mw.Title( 'john', 2 ); + assert.equal( title.toString(), 'User:John', '$wgCapitalLinks=false: User namespace is insensitive, first-letter becomes uppercase' ); + } ); + + QUnit.test( 'toString / toText', function ( assert ) { + var title = new mw.Title( 'Some random page' ); + + assert.equal( title.toString(), title.getPrefixedDb() ); + assert.equal( title.toText(), title.getPrefixedText() ); + } ); + + QUnit.test( 'getExtension', function ( assert ) { + function extTest( pagename, ext, description ) { + var title = new mw.Title( pagename ); + assert.equal( title.getExtension(), ext, description || pagename ); + } + + extTest( 'MediaWiki:Vector.js', 'js' ); + extTest( 'User:Example/common.css', 'css' ); + extTest( 'File:Example.longextension', 'longextension', 'Extension parsing not limited (T38151)' ); + extTest( 'Example/information.json', 'json', 'Extension parsing not restricted from any namespace' ); + extTest( 'Foo.', null, 'Trailing dot is not an extension' ); + extTest( 'Foo..', null, 'Trailing dots are not an extension' ); + extTest( 'Foo.a.', null, 'Page name with dots and ending in a dot does not have an extension' ); + + // @broken: Throws an exception + // extTest( '.NET', null, 'Leading dot is (or is not?) an extension' ); + } ); + + QUnit.test( 'exists', function ( assert ) { + var title; + + // Empty registry, checks default to null + + title = new mw.Title( 'Some random page', 4 ); + assert.strictEqual( title.exists(), null, 'Return null with empty existance registry' ); + + // Basic registry, checks default to boolean + mw.Title.exist.set( [ 'Does_exist', 'User_talk:NeilK', 'Wikipedia:Sandbox_rules' ], true ); + mw.Title.exist.set( [ 'Does_not_exist', 'User:John', 'Foobar' ], false ); + + title = new mw.Title( 'Project:Sandbox rules' ); + assert.assertTrue( title.exists(), 'Return true for page titles marked as existing' ); + title = new mw.Title( 'Foobar' ); + assert.assertFalse( title.exists(), 'Return false for page titles marked as nonexistent' ); + + } ); + + QUnit.test( 'getUrl', function ( assert ) { + var title; + mw.config.set( { + wgScript: '/w/index.php', + wgArticlePath: '/wiki/$1' + } ); + + title = new mw.Title( 'Foobar' ); + assert.equal( title.getUrl(), '/wiki/Foobar', 'Basic functionality, getUrl uses mw.util.getUrl' ); + assert.equal( title.getUrl( { action: 'edit' } ), '/w/index.php?title=Foobar&action=edit', 'Basic functionality, \'params\' parameter' ); + + title = new mw.Title( 'John Doe', 3 ); + assert.equal( title.getUrl(), '/wiki/User_talk:John_Doe', 'Escaping in title and namespace for urls' ); + + title = new mw.Title( 'John Cena#And_His_Name_Is', 3 ); + assert.equal( title.getUrl( { meme: true } ), '/w/index.php?title=User_talk:John_Cena&meme=true#And_His_Name_Is', 'title with fragment and query parameter' ); + } ); + + QUnit.test( 'newFromImg', function ( assert ) { + var title, i, thisCase, prefix, + cases = [ + { + url: '//upload.wikimedia.org/wikipedia/commons/thumb/b/bf/Princess_Alexandra_of_Denmark_%28later_Queen_Alexandra%2C_wife_of_Edward_VII%29_with_her_two_eldest_sons%2C_Prince_Albert_Victor_%28Eddy%29_and_George_Frederick_Ernest_Albert_%28later_George_V%29.jpg/939px-thumbnail.jpg', + typeOfUrl: 'Hashed thumb with shortened path', + nameText: 'Princess Alexandra of Denmark (later Queen Alexandra, wife of Edward VII) with her two eldest sons, Prince Albert Victor (Eddy) and George Frederick Ernest Albert (later George V)', + prefixedText: 'File:Princess Alexandra of Denmark (later Queen Alexandra, wife of Edward VII) with her two eldest sons, Prince Albert Victor (Eddy) and George Frederick Ernest Albert (later George V).jpg' + }, + + { + url: '//upload.wikimedia.org/wikipedia/commons/thumb/b/bf/Princess_Alexandra_of_Denmark_%28later_Queen_Alexandra%2C_wife_of_Edward_VII%29_with_her_two_eldest_sons%2C_Prince_Albert_Victor_%28Eddy%29_and_George_Frederick_Ernest_Albert_%28later_George_V%29.jpg/939px-ki708pr1r6g2dl5lbhvwdqxenhait13.jpg', + typeOfUrl: 'Hashed thumb with sha1-ed path', + nameText: 'Princess Alexandra of Denmark (later Queen Alexandra, wife of Edward VII) with her two eldest sons, Prince Albert Victor (Eddy) and George Frederick Ernest Albert (later George V)', + prefixedText: 'File:Princess Alexandra of Denmark (later Queen Alexandra, wife of Edward VII) with her two eldest sons, Prince Albert Victor (Eddy) and George Frederick Ernest Albert (later George V).jpg' + }, + + { + url: '/wiki/images/thumb/9/91/Anticlockwise_heliotrope%27s.jpg/99px-Anticlockwise_heliotrope%27s.jpg', + typeOfUrl: 'Normal hashed directory thumbnail', + nameText: 'Anticlockwise heliotrope\'s', + prefixedText: 'File:Anticlockwise heliotrope\'s.jpg' + }, + + { + url: '/wiki/images/thumb/8/80/Wikipedia-logo-v2.svg/langde-150px-Wikipedia-logo-v2.svg.png', + typeOfUrl: 'Normal hashed directory thumbnail with complex thumbnail parameters', + nameText: 'Wikipedia-logo-v2', + prefixedText: 'File:Wikipedia-logo-v2.svg' + }, + + { + url: '//upload.wikimedia.org/wikipedia/commons/thumb/8/80/Wikipedia-logo-v2.svg/150px-Wikipedia-logo-v2.svg.png', + typeOfUrl: 'Commons thumbnail', + nameText: 'Wikipedia-logo-v2', + prefixedText: 'File:Wikipedia-logo-v2.svg' + }, + + { + url: '/wiki/images/9/91/Anticlockwise_heliotrope%27s.jpg', + typeOfUrl: 'Full image', + nameText: 'Anticlockwise heliotrope\'s', + prefixedText: 'File:Anticlockwise heliotrope\'s.jpg' + }, + + { + url: 'http://localhost/thumb.php?f=Stuffless_Figaro%27s.jpg&width=180', + typeOfUrl: 'thumb.php-based thumbnail', + nameText: 'Stuffless Figaro\'s', + prefixedText: 'File:Stuffless Figaro\'s.jpg' + }, + + { + url: '/wikipedia/commons/thumb/Wikipedia-logo-v2.svg/150px-Wikipedia-logo-v2.svg.png', + typeOfUrl: 'Commons unhashed thumbnail', + nameText: 'Wikipedia-logo-v2', + prefixedText: 'File:Wikipedia-logo-v2.svg' + }, + + { + url: '/wikipedia/commons/thumb/Wikipedia-logo-v2.svg/langde-150px-Wikipedia-logo-v2.svg.png', + typeOfUrl: 'Commons unhashed thumbnail with complex thumbnail parameters', + nameText: 'Wikipedia-logo-v2', + prefixedText: 'File:Wikipedia-logo-v2.svg' + }, + + { + url: '/wiki/images/Anticlockwise_heliotrope%27s.jpg', + typeOfUrl: 'Unhashed local file', + nameText: 'Anticlockwise heliotrope\'s', + prefixedText: 'File:Anticlockwise heliotrope\'s.jpg' + }, + + { + url: '', + typeOfUrl: 'Empty string' + }, + + { + url: 'foo', + typeOfUrl: 'String with only alphabet characters' + }, + + { + url: 'foobar.foobar', + typeOfUrl: 'Not a file path' + }, + + { + url: '/a/a0/blah blah blah', + typeOfUrl: 'Space characters' + } + ]; + + for ( i = 0; i < cases.length; i++ ) { + thisCase = cases[ i ]; + title = mw.Title.newFromImg( { src: thisCase.url } ); + + if ( thisCase.nameText !== undefined ) { + prefix = '[' + thisCase.typeOfUrl + ' URL] '; + + assert.notStrictEqual( title, null, prefix + 'Parses successfully' ); + assert.equal( title.getNameText(), thisCase.nameText, prefix + 'Filename matches original' ); + assert.equal( title.getPrefixedText(), thisCase.prefixedText, prefix + 'File page title matches original' ); + assert.equal( title.getNamespaceId(), 6, prefix + 'Namespace ID matches File namespace' ); + } else { + assert.strictEqual( title, null, thisCase.typeOfUrl + ', should not produce an mw.Title object' ); + } + } + } ); + + QUnit.test( 'getRelativeText', function ( assert ) { + var i, thisCase, title, + cases = [ + { + text: 'asd', + relativeTo: 123, + expectedResult: ':Asd' + }, + { + text: 'dfg', + relativeTo: 0, + expectedResult: 'Dfg' + }, + { + text: 'Template:Ghj', + relativeTo: 0, + expectedResult: 'Template:Ghj' + }, + { + text: 'Template:1', + relativeTo: 10, + expectedResult: '1' + }, + { + text: 'User:Hi', + relativeTo: 10, + expectedResult: 'User:Hi' + } + ]; + + for ( i = 0; i < cases.length; i++ ) { + thisCase = cases[ i ]; + + title = mw.Title.newFromText( thisCase.text ); + assert.equal( title.getRelativeText( thisCase.relativeTo ), thisCase.expectedResult ); + } + } ); + + QUnit.test( 'normalizeExtension', function ( assert ) { + var extension, i, thisCase, prefix, + cases = [ + { + extension: 'png', + expected: 'png', + description: 'Extension already in canonical form' + }, + { + extension: 'PNG', + expected: 'png', + description: 'Extension lowercased in canonical form' + }, + { + extension: 'jpeg', + expected: 'jpg', + description: 'Extension changed in canonical form' + }, + { + extension: 'JPEG', + expected: 'jpg', + description: 'Extension lowercased and changed in canonical form' + }, + { + extension: '~~~', + expected: '', + description: 'Extension invalid and discarded' + } + ]; + + for ( i = 0; i < cases.length; i++ ) { + thisCase = cases[ i ]; + extension = mw.Title.normalizeExtension( thisCase.extension ); + + prefix = '[' + thisCase.description + '] '; + assert.equal( extension, thisCase.expected, prefix + 'Extension as expected' ); + } + } ); + + QUnit.test( 'newFromUserInput', function ( assert ) { + var title, i, thisCase, prefix, + cases = [ + { + title: 'DCS0001557854455.JPG', + expected: 'DCS0001557854455.JPG', + description: 'Title in normal namespace without anything invalid but with "file extension"' + }, + { + title: 'MediaWiki:Msg-awesome', + expected: 'MediaWiki:Msg-awesome', + description: 'Full title (page in MediaWiki namespace) supplied as string' + }, + { + title: 'The/Mw/Sound.flac', + defaultNamespace: -2, + expected: 'Media:The-Mw-Sound.flac', + description: 'Page in Media-namespace without explicit options' + }, + { + title: 'File:The/Mw/Sound.kml', + defaultNamespace: 6, + options: { + forUploading: false + }, + expected: 'File:The/Mw/Sound.kml', + description: 'Page in File-namespace without explicit options' + }, + { + title: 'File:Foo.JPEG', + expected: 'File:Foo.JPEG', + description: 'Page in File-namespace with non-canonical extension' + }, + { + title: 'File:Foo.JPEG ', + expected: 'File:Foo.JPEG', + description: 'Page in File-namespace with trailing whitespace' + } + ]; + + for ( i = 0; i < cases.length; i++ ) { + thisCase = cases[ i ]; + title = mw.Title.newFromUserInput( thisCase.title, thisCase.defaultNamespace, thisCase.options ); + + if ( thisCase.expected !== undefined ) { + prefix = '[' + thisCase.description + '] '; + + assert.notStrictEqual( title, null, prefix + 'Parses successfully' ); + assert.equal( title.toText(), thisCase.expected, prefix + 'Title as expected' ); + } else { + assert.strictEqual( title, null, thisCase.description + ', should not produce an mw.Title object' ); + } + } + } ); + + QUnit.test( 'newFromFileName', function ( assert ) { + var title, i, thisCase, prefix, + cases = [ + { + fileName: 'DCS0001557854455.JPG', + typeOfName: 'Standard camera output', + nameText: 'DCS0001557854455', + prefixedText: 'File:DCS0001557854455.JPG' + }, + { + fileName: 'File:Sample.png', + typeOfName: 'Carrying namespace', + nameText: 'File-Sample', + prefixedText: 'File:File-Sample.png' + }, + { + fileName: 'Treppe 2222 Test upload.jpg', + typeOfName: 'File name with spaces in it and lower case file extension', + nameText: 'Treppe 2222 Test upload', + prefixedText: 'File:Treppe 2222 Test upload.jpg' + }, + { + fileName: 'I contain a \ttab.jpg', + typeOfName: 'Name containing a tab character', + nameText: 'I contain a tab', + prefixedText: 'File:I contain a tab.jpg' + }, + { + fileName: 'I_contain multiple__ ___ _underscores.jpg', + typeOfName: 'Name containing multiple underscores', + nameText: 'I contain multiple underscores', + prefixedText: 'File:I contain multiple underscores.jpg' + }, + { + fileName: 'I like ~~~~~~~~es.jpg', + typeOfName: 'Name containing more than three consecutive tilde characters', + nameText: 'I like ~~es', + prefixedText: 'File:I like ~~es.jpg' + }, + { + fileName: 'BI\u200EDI.jpg', + typeOfName: 'Name containing BIDI overrides', + nameText: 'BIDI', + prefixedText: 'File:BIDI.jpg' + }, + { + fileName: '100%ab progress.jpg', + typeOfName: 'File name with URL encoding', + nameText: '100% ab progress', + prefixedText: 'File:100% ab progress.jpg' + }, + { + fileName: '<([>]):/#.jpg', + typeOfName: 'File name with characters not permitted in titles that are replaced', + nameText: '((()))---', + prefixedText: 'File:((()))---.jpg' + }, + { + fileName: 'spaces\u0009\u2000\u200A\u200Bx.djvu', + typeOfName: 'File name with different kind of spaces', + nameText: 'Spaces \u200Bx', + prefixedText: 'File:Spaces \u200Bx.djvu' + }, + { + fileName: 'dot.dot.dot.dot.dotdot', + typeOfName: 'File name with a lot of dots', + nameText: 'Dot.dot.dot.dot', + prefixedText: 'File:Dot.dot.dot.dot.dotdot' + }, + { + fileName: 'dot. dot ._dot', + typeOfName: 'File name with multiple dots and spaces', + nameText: 'Dot. dot', + prefixedText: 'File:Dot. dot. dot' + }, + { + fileName: '𠜎𠜱𠝹𠱓𠱸𠲖𠳏𠳕𠴕𠵼𠵿𠸎𠸏𠹷𠺝𠺢𠻗𠻹𠻺𠼭𠼮𠽌𠾴𠾼𠿪𡁜𡁯𡁵𡁶𡁻𡃁𡃉𡇙𢃇𢞵𢫕𢭃𢯊𢱑𢱕𢳂𠻹𠻺𠼭𠼮𠽌𠾴𠾼𠿪𡁜𡁯𡁵𡁶𡁻𡃁𡃉𡇙𢃇𢞵𢫕𢭃𢯊𢱑𢱕𢳂.png', + typeOfName: 'File name longer than 240 bytes', + nameText: '𠜎𠜱𠝹𠱓𠱸𠲖𠳏𠳕𠴕𠵼𠵿𠸎𠸏𠹷𠺝𠺢𠻗𠻹𠻺𠼭𠼮𠽌𠾴𠾼𠿪𡁜𡁯𡁵𡁶𡁻𡃁𡃉𡇙𢃇𢞵𢫕𢭃𢯊𢱑𢱕𢳂𠻹𠻺𠼭𠼮𠽌𠾴𠾼𠿪𡁜𡁯𡁵𡁶𡁻𡃁𡃉𡇙𢃇𢞵', + prefixedText: 'File:𠜎𠜱𠝹𠱓𠱸𠲖𠳏𠳕𠴕𠵼𠵿𠸎𠸏𠹷𠺝𠺢𠻗𠻹𠻺𠼭𠼮𠽌𠾴𠾼𠿪𡁜𡁯𡁵𡁶𡁻𡃁𡃉𡇙𢃇𢞵𢫕𢭃𢯊𢱑𢱕𢳂𠻹𠻺𠼭𠼮𠽌𠾴𠾼𠿪𡁜𡁯𡁵𡁶𡁻𡃁𡃉𡇙𢃇𢞵.png' + }, + { + fileName: '', + typeOfName: 'Empty string' + }, + { + fileName: 'foo', + typeOfName: 'String with only alphabet characters' + } + ]; + + for ( i = 0; i < cases.length; i++ ) { + thisCase = cases[ i ]; + title = mw.Title.newFromFileName( thisCase.fileName ); + + if ( thisCase.nameText !== undefined ) { + prefix = '[' + thisCase.typeOfName + '] '; + + assert.notStrictEqual( title, null, prefix + 'Parses successfully' ); + assert.equal( title.getNameText(), thisCase.nameText, prefix + 'Filename matches original' ); + assert.equal( title.getPrefixedText(), thisCase.prefixedText, prefix + 'File page title matches original' ); + assert.equal( title.getNamespaceId(), 6, prefix + 'Namespace ID matches File namespace' ); + } else { + assert.strictEqual( title, null, thisCase.typeOfName + ', should not produce an mw.Title object' ); + } + } + } ); + +}( mediaWiki, jQuery ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.Uri.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.Uri.test.js new file mode 100644 index 00000000..918c923a --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.Uri.test.js @@ -0,0 +1,509 @@ +( function ( mw ) { + QUnit.module( 'mediawiki.Uri', QUnit.newMwEnvironment( { + setup: function () { + this.mwUriOrg = mw.Uri; + mw.Uri = mw.UriRelative( 'http://example.org/w/index.php' ); + }, + teardown: function () { + mw.Uri = this.mwUriOrg; + delete this.mwUriOrg; + } + } ) ); + + [ true, false ].forEach( function ( strictMode ) { + QUnit.test( 'Basic construction and properties (' + ( strictMode ? '' : 'non-' ) + 'strict mode)', function ( assert ) { + var uriString, uri; + uriString = 'http://www.ietf.org/rfc/rfc2396.txt'; + uri = new mw.Uri( uriString, { + strictMode: strictMode + } ); + + assert.deepEqual( + { + protocol: uri.protocol, + host: uri.host, + port: uri.port, + path: uri.path, + query: uri.query, + fragment: uri.fragment + }, { + protocol: 'http', + host: 'www.ietf.org', + port: undefined, + path: '/rfc/rfc2396.txt', + query: {}, + fragment: undefined + }, + 'basic object properties' + ); + + assert.deepEqual( + { + userInfo: uri.getUserInfo(), + authority: uri.getAuthority(), + hostPort: uri.getHostPort(), + queryString: uri.getQueryString(), + relativePath: uri.getRelativePath(), + toString: uri.toString() + }, + { + userInfo: '', + authority: 'www.ietf.org', + hostPort: 'www.ietf.org', + queryString: '', + relativePath: '/rfc/rfc2396.txt', + toString: uriString + }, + 'construct composite components of URI on request' + ); + } ); + } ); + + QUnit.test( 'Constructor( String[, Object ] )', function ( assert ) { + var uri; + + uri = new mw.Uri( 'http://www.example.com/dir/?m=foo&m=bar&n=1', { + overrideKeys: true + } ); + + // Strict comparison to assert that numerical values stay strings + assert.strictEqual( uri.query.n, '1', 'Simple parameter with overrideKeys:true' ); + assert.strictEqual( uri.query.m, 'bar', 'Last key overrides earlier keys with overrideKeys:true' ); + + uri = new mw.Uri( 'http://www.example.com/dir/?m=foo&m=bar&n=1', { + overrideKeys: false + } ); + + assert.strictEqual( uri.query.n, '1', 'Simple parameter with overrideKeys:false' ); + assert.strictEqual( uri.query.m[ 0 ], 'foo', 'Order of multi-value parameters with overrideKeys:true' ); + assert.strictEqual( uri.query.m[ 1 ], 'bar', 'Order of multi-value parameters with overrideKeys:true' ); + assert.strictEqual( uri.query.m.length, 2, 'Number of mult-value field is correct' ); + + uri = new mw.Uri( 'ftp://usr:pwd@192.0.2.16/' ); + + assert.deepEqual( + { + protocol: uri.protocol, + user: uri.user, + password: uri.password, + host: uri.host, + port: uri.port, + path: uri.path, + query: uri.query, + fragment: uri.fragment + }, + { + protocol: 'ftp', + user: 'usr', + password: 'pwd', + host: '192.0.2.16', + port: undefined, + path: '/', + query: {}, + fragment: undefined + }, + 'Parse an ftp URI correctly with user and password' + ); + + assert.throws( + function () { + return new mw.Uri( 'glaswegian penguins' ); + }, + function ( e ) { + return e.message === 'Bad constructor arguments'; + }, + 'throw error on non-URI as argument to constructor' + ); + + assert.throws( + function () { + return new mw.Uri( 'example.com/bar/baz', { + strictMode: true + } ); + }, + function ( e ) { + return e.message === 'Bad constructor arguments'; + }, + 'throw error on URI without protocol or // or leading / in strict mode' + ); + + uri = new mw.Uri( 'example.com/bar/baz', { + strictMode: false + } ); + assert.equal( uri.toString(), 'http://example.com/bar/baz', 'normalize URI without protocol or // in loose mode' ); + + uri = new mw.Uri( 'http://example.com/index.php?key=key&hasOwnProperty=hasOwnProperty&constructor=constructor&watch=watch' ); + assert.deepEqual( + uri.query, + { + key: 'key', + constructor: 'constructor', + hasOwnProperty: 'hasOwnProperty', + watch: 'watch' + }, + 'Keys in query strings support names of Object prototypes (bug T114344)' + ); + } ); + + QUnit.test( 'Constructor( Object )', function ( assert ) { + var uri = new mw.Uri( { + protocol: 'http', + host: 'www.foo.local', + path: '/this' + } ); + assert.equal( uri.toString(), 'http://www.foo.local/this', 'Basic properties' ); + + uri = new mw.Uri( { + protocol: 'http', + host: 'www.foo.local', + path: '/this', + query: { hi: 'there' }, + fragment: 'blah' + } ); + assert.equal( uri.toString(), 'http://www.foo.local/this?hi=there#blah', 'More complex properties' ); + + assert.throws( + function () { + return new mw.Uri( { + protocol: 'http', + host: 'www.foo.local' + } ); + }, + function ( e ) { + return e.message === 'Bad constructor arguments'; + }, + 'Construction failed when missing required properties' + ); + } ); + + QUnit.test( 'Constructor( empty[, Object ] )', function ( assert ) { + var testuri, MyUri, uri; + + testuri = 'http://example.org/w/index.php?a=1&a=2'; + MyUri = mw.UriRelative( testuri ); + + uri = new MyUri(); + assert.equal( uri.toString(), testuri, 'no arguments' ); + + uri = new MyUri( undefined ); + assert.equal( uri.toString(), testuri, 'undefined' ); + + uri = new MyUri( null ); + assert.equal( uri.toString(), testuri, 'null' ); + + uri = new MyUri( '' ); + assert.equal( uri.toString(), testuri, 'empty string' ); + + uri = new MyUri( null, { overrideKeys: true } ); + assert.deepEqual( uri.query, { a: '2' }, 'null, with options' ); + } ); + + QUnit.test( 'Properties', function ( assert ) { + var uriBase, uri; + + uriBase = new mw.Uri( 'http://en.wiki.local/w/api.php' ); + + uri = uriBase.clone(); + uri.fragment = 'frag'; + assert.equal( uri.toString(), 'http://en.wiki.local/w/api.php#frag', 'add a fragment' ); + uri.fragment = 'café'; + assert.equal( uri.toString(), 'http://en.wiki.local/w/api.php#caf%C3%A9', 'fragment is url-encoded' ); + + uri = uriBase.clone(); + uri.host = 'fr.wiki.local'; + uri.port = '8080'; + assert.equal( uri.toString(), 'http://fr.wiki.local:8080/w/api.php', 'change host and port' ); + + uri = uriBase.clone(); + uri.query.foo = 'bar'; + assert.equal( uri.toString(), 'http://en.wiki.local/w/api.php?foo=bar', 'add query arguments' ); + + delete uri.query.foo; + assert.equal( uri.toString(), 'http://en.wiki.local/w/api.php', 'delete query arguments' ); + + uri = uriBase.clone(); + uri.query.foo = 'bar'; + assert.equal( uri.toString(), 'http://en.wiki.local/w/api.php?foo=bar', 'extend query arguments' ); + uri.extend( { + foo: 'quux', + pif: 'paf' + } ); + assert.ok( uri.toString().indexOf( 'foo=quux' ) >= 0, 'extend query arguments' ); + assert.ok( uri.toString().indexOf( 'foo=bar' ) === -1, 'extend query arguments' ); + assert.ok( uri.toString().indexOf( 'pif=paf' ) >= 0, 'extend query arguments' ); + } ); + + QUnit.test( '.getQueryString()', function ( assert ) { + var uri = new mw.Uri( 'http://search.example.com/?q=uri' ); + + assert.deepEqual( + { + protocol: uri.protocol, + host: uri.host, + port: uri.port, + path: uri.path, + query: uri.query, + fragment: uri.fragment, + queryString: uri.getQueryString() + }, + { + protocol: 'http', + host: 'search.example.com', + port: undefined, + path: '/', + query: { q: 'uri' }, + fragment: undefined, + queryString: 'q=uri' + }, + 'basic object properties' + ); + + uri = new mw.Uri( 'https://example.com/mw/index.php?title=Sandbox/7&other=Sandbox/7&foo' ); + assert.equal( + uri.getQueryString(), + 'title=Sandbox/7&other=Sandbox%2F7&foo', + 'title parameter is escaped the wiki-way' + ); + + } ); + + QUnit.test( '.clone()', function ( assert ) { + var original, clone; + + original = new mw.Uri( 'http://foo.example.org/index.php?one=1&two=2' ); + clone = original.clone(); + + assert.deepEqual( clone, original, 'clone has equivalent properties' ); + assert.equal( original.toString(), clone.toString(), 'toString matches original' ); + + assert.notStrictEqual( clone, original, 'clone is a different object when compared by reference' ); + + clone.host = 'bar.example.org'; + assert.notEqual( original.host, clone.host, 'manipulating clone did not effect original' ); + assert.notEqual( original.toString(), clone.toString(), 'Stringified url no longer matches original' ); + + clone.query.three = 3; + + assert.deepEqual( + original.query, + { one: '1', two: '2' }, + 'Properties is deep cloned (T39708)' + ); + } ); + + QUnit.test( '.toString() after query manipulation', function ( assert ) { + var uri; + + uri = new mw.Uri( 'http://www.example.com/dir/?m=foo&m=bar&n=1', { + overrideKeys: true + } ); + + uri.query.n = [ 'x', 'y', 'z' ]; + + // Verify parts and total length instead of entire string because order + // of iteration can vary. + assert.ok( uri.toString().indexOf( 'm=bar' ), 'toString preserves other values' ); + assert.ok( uri.toString().indexOf( 'n=x&n=y&n=z' ), 'toString parameter includes all values of an array query parameter' ); + assert.equal( uri.toString().length, 'http://www.example.com/dir/?m=bar&n=x&n=y&n=z'.length, 'toString matches expected string' ); + + uri = new mw.Uri( 'http://www.example.com/dir/?m=foo&m=bar&n=1', { + overrideKeys: false + } ); + + // Change query values + uri.query.n = [ 'x', 'y', 'z' ]; + + // Verify parts and total length instead of entire string because order + // of iteration can vary. + assert.ok( uri.toString().indexOf( 'm=foo&m=bar' ) >= 0, 'toString preserves other values' ); + assert.ok( uri.toString().indexOf( 'n=x&n=y&n=z' ) >= 0, 'toString parameter includes all values of an array query parameter' ); + assert.equal( uri.toString().length, 'http://www.example.com/dir/?m=foo&m=bar&n=x&n=y&n=z'.length, 'toString matches expected string' ); + + // Remove query values + uri.query.m.splice( 0, 1 ); + delete uri.query.n; + + assert.equal( uri.toString(), 'http://www.example.com/dir/?m=bar', 'deletion properties' ); + + // Remove more query values, leaving an empty array + uri.query.m.splice( 0, 1 ); + assert.equal( uri.toString(), 'http://www.example.com/dir/', 'empty array value is ommitted' ); + } ); + + QUnit.test( 'Variable defaultUri', function ( assert ) { + var uri, + href = 'http://example.org/w/index.php#here', + UriClass = mw.UriRelative( function () { + return href; + } ); + + uri = new UriClass(); + assert.deepEqual( + { + protocol: uri.protocol, + user: uri.user, + password: uri.password, + host: uri.host, + port: uri.port, + path: uri.path, + query: uri.query, + fragment: uri.fragment + }, + { + protocol: 'http', + user: undefined, + password: undefined, + host: 'example.org', + port: undefined, + path: '/w/index.php', + query: {}, + fragment: 'here' + }, + 'basic object properties' + ); + + // Default URI may change, e.g. via history.replaceState, pushState or location.hash (T74334) + href = 'https://example.com/wiki/Foo?v=2'; + uri = new UriClass(); + assert.deepEqual( + { + protocol: uri.protocol, + user: uri.user, + password: uri.password, + host: uri.host, + port: uri.port, + path: uri.path, + query: uri.query, + fragment: uri.fragment + }, + { + protocol: 'https', + user: undefined, + password: undefined, + host: 'example.com', + port: undefined, + path: '/wiki/Foo', + query: { v: '2' }, + fragment: undefined + }, + 'basic object properties' + ); + } ); + + QUnit.test( 'Advanced URL', function ( assert ) { + var uri, queryString, relativePath; + + uri = new mw.Uri( 'http://auth@www.example.com:81/dir/dir.2/index.htm?q1=0&&test1&test2=value+%28escaped%29#caf%C3%A9' ); + + assert.deepEqual( + { + protocol: uri.protocol, + user: uri.user, + password: uri.password, + host: uri.host, + port: uri.port, + path: uri.path, + query: uri.query, + fragment: uri.fragment + }, + { + protocol: 'http', + user: 'auth', + password: undefined, + host: 'www.example.com', + port: '81', + path: '/dir/dir.2/index.htm', + query: { q1: '0', test1: null, test2: 'value (escaped)' }, + fragment: 'café' + }, + 'basic object properties' + ); + + assert.equal( uri.getUserInfo(), 'auth', 'user info' ); + + assert.equal( uri.getAuthority(), 'auth@www.example.com:81', 'authority equal to auth@hostport' ); + + assert.equal( uri.getHostPort(), 'www.example.com:81', 'hostport equal to host:port' ); + + queryString = uri.getQueryString(); + assert.ok( queryString.indexOf( 'q1=0' ) >= 0, 'query param with numbers' ); + assert.ok( queryString.indexOf( 'test1' ) >= 0, 'query param with null value is included' ); + assert.ok( queryString.indexOf( 'test1=' ) === -1, 'query param with null value does not generate equals sign' ); + assert.ok( queryString.indexOf( 'test2=value+%28escaped%29' ) >= 0, 'query param is url escaped' ); + + relativePath = uri.getRelativePath(); + assert.ok( relativePath.indexOf( uri.path ) >= 0, 'path in relative path' ); + assert.ok( relativePath.indexOf( uri.getQueryString() ) >= 0, 'query string in relative path' ); + assert.ok( relativePath.indexOf( mw.Uri.encode( uri.fragment ) ) >= 0, 'escaped fragment in relative path' ); + } ); + + QUnit.test( 'Parse a uri with an @ symbol in the path and query', function ( assert ) { + var uri = new mw.Uri( 'http://www.example.com/test@test?x=@uri&y@=uri&z@=@' ); + + assert.deepEqual( + { + protocol: uri.protocol, + user: uri.user, + password: uri.password, + host: uri.host, + port: uri.port, + path: uri.path, + query: uri.query, + fragment: uri.fragment, + queryString: uri.getQueryString() + }, + { + protocol: 'http', + user: undefined, + password: undefined, + host: 'www.example.com', + port: undefined, + path: '/test@test', + query: { x: '@uri', 'y@': 'uri', 'z@': '@' }, + fragment: undefined, + queryString: 'x=%40uri&y%40=uri&z%40=%40' + }, + 'basic object properties' + ); + } ); + + QUnit.test( 'Handle protocol-relative URLs', function ( assert ) { + var UriRel, uri; + + UriRel = mw.UriRelative( 'glork://en.wiki.local/foo.php' ); + + uri = new UriRel( '//en.wiki.local/w/api.php' ); + assert.equal( uri.protocol, 'glork', 'create protocol-relative URLs with same protocol as document' ); + + uri = new UriRel( '/foo.com' ); + assert.equal( uri.toString(), 'glork://en.wiki.local/foo.com', 'handle absolute paths by supplying protocol and host from document in loose mode' ); + + uri = new UriRel( 'http:/foo.com' ); + assert.equal( uri.toString(), 'http://en.wiki.local/foo.com', 'handle absolute paths by supplying host from document in loose mode' ); + + uri = new UriRel( '/foo.com', true ); + assert.equal( uri.toString(), 'glork://en.wiki.local/foo.com', 'handle absolute paths by supplying protocol and host from document in strict mode' ); + + uri = new UriRel( 'http:/foo.com', true ); + assert.equal( uri.toString(), 'http://en.wiki.local/foo.com', 'handle absolute paths by supplying host from document in strict mode' ); + } ); + + QUnit.test( 'T37658', function ( assert ) { + var testProtocol, testServer, testPort, testPath, UriClass, uri, href; + + testProtocol = 'https://'; + testServer = 'foo.example.org'; + testPort = '3004'; + testPath = '/!1qy'; + + UriClass = mw.UriRelative( testProtocol + testServer + '/some/path/index.html' ); + uri = new UriClass( testPath ); + href = uri.toString(); + assert.equal( href, testProtocol + testServer + testPath, 'Root-relative URL gets host & protocol supplied' ); + + UriClass = mw.UriRelative( testProtocol + testServer + ':' + testPort + '/some/path.php' ); + uri = new UriClass( testPath ); + href = uri.toString(); + assert.equal( href, testProtocol + testServer + ':' + testPort + testPath, 'Root-relative URL gets host, protocol, and port supplied' ); + } ); +}( mediaWiki ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.cldr.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.cldr.test.js new file mode 100644 index 00000000..4170897c --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.cldr.test.js @@ -0,0 +1,82 @@ +( function ( mw, $ ) { + var pluralTestcases = { + /* + * Sample: + * languagecode : [ + * [ number, [ 'form1', 'form2', ... ], 'expected', 'description' ] + * ]; + */ + en: [ + [ 0, [ 'one', 'other' ], 'other', 'English plural test- 0 is other' ], + [ 1, [ 'one', 'other' ], 'one', 'English plural test- 1 is one' ] + ], + fa: [ + [ 0, [ 'one', 'other' ], 'other', 'Persian plural test- 0 is other' ], + [ 1, [ 'one', 'other' ], 'one', 'Persian plural test- 1 is one' ], + [ 2, [ 'one', 'other' ], 'other', 'Persian plural test- 2 is other' ] + ], + fr: [ + [ 0, [ 'one', 'other' ], 'other', 'French plural test- 0 is other' ], + [ 1, [ 'one', 'other' ], 'one', 'French plural test- 1 is one' ] + ], + hi: [ + [ 0, [ 'one', 'other' ], 'one', 'Hindi plural test- 0 is one' ], + [ 1, [ 'one', 'other' ], 'one', 'Hindi plural test- 1 is one' ], + [ 2, [ 'one', 'other' ], 'other', 'Hindi plural test- 2 is other' ] + ], + he: [ + [ 0, [ 'one', 'other' ], 'other', 'Hebrew plural test- 0 is other' ], + [ 1, [ 'one', 'other' ], 'one', 'Hebrew plural test- 1 is one' ], + [ 2, [ 'one', 'other' ], 'other', 'Hebrew plural test- 2 is other with 2 forms' ], + [ 2, [ 'one', 'dual', 'other' ], 'dual', 'Hebrew plural test- 2 is dual with 3 forms' ] + ], + hu: [ + [ 0, [ 'one', 'other' ], 'other', 'Hungarian plural test- 0 is other' ], + [ 1, [ 'one', 'other' ], 'one', 'Hungarian plural test- 1 is one' ], + [ 2, [ 'one', 'other' ], 'other', 'Hungarian plural test- 2 is other' ] + ], + hy: [ + [ 0, [ 'one', 'other' ], 'other', 'Armenian plural test- 0 is other' ], + [ 1, [ 'one', 'other' ], 'one', 'Armenian plural test- 1 is one' ], + [ 2, [ 'one', 'other' ], 'other', 'Armenian plural test- 2 is other' ] + ], + ar: [ + [ 0, [ 'zero', 'one', 'two', 'few', 'many', 'other' ], 'zero', 'Arabic plural test - 0 is zero' ], + [ 1, [ 'zero', 'one', 'two', 'few', 'many', 'other' ], 'one', 'Arabic plural test - 1 is one' ], + [ 2, [ 'zero', 'one', 'two', 'few', 'many', 'other' ], 'two', 'Arabic plural test - 2 is two' ], + [ 3, [ 'zero', 'one', 'two', 'few', 'many', 'other' ], 'few', 'Arabic plural test - 3 is few' ], + [ 9, [ 'zero', 'one', 'two', 'few', 'many', 'other' ], 'few', 'Arabic plural test - 9 is few' ], + [ '9', [ 'zero', 'one', 'two', 'few', 'many', 'other' ], 'few', 'Arabic plural test - 9 is few' ], + [ 110, [ 'zero', 'one', 'two', 'few', 'many', 'other' ], 'few', 'Arabic plural test - 110 is few' ], + [ 11, [ 'zero', 'one', 'two', 'few', 'many', 'other' ], 'many', 'Arabic plural test - 11 is many' ], + [ 15, [ 'zero', 'one', 'two', 'few', 'many', 'other' ], 'many', 'Arabic plural test - 15 is many' ], + [ 99, [ 'zero', 'one', 'two', 'few', 'many', 'other' ], 'many', 'Arabic plural test - 99 is many' ], + [ 9999, [ 'zero', 'one', 'two', 'few', 'many', 'other' ], 'many', 'Arabic plural test - 9999 is many' ], + [ 100, [ 'zero', 'one', 'two', 'few', 'many', 'other' ], 'other', 'Arabic plural test - 100 is other' ], + [ 102, [ 'zero', 'one', 'two', 'few', 'many', 'other' ], 'other', 'Arabic plural test - 102 is other' ], + [ 1000, [ 'zero', 'one', 'two', 'few', 'many', 'other' ], 'other', 'Arabic plural test - 1000 is other' ], + [ 1.7, [ 'zero', 'one', 'two', 'few', 'many', 'other' ], 'other', 'Arabic plural test - 1.7 is other' ] + ] + }; + + QUnit.module( 'mediawiki.cldr', QUnit.newMwEnvironment() ); + + function pluralTest( langCode, tests ) { + QUnit.test( 'Plural Test for ' + langCode, function ( assert ) { + var i; + for ( i = 0; i < tests.length; i++ ) { + assert.equal( + mw.language.convertPlural( tests[ i ][ 0 ], tests[ i ][ 1 ] ), + tests[ i ][ 2 ], + tests[ i ][ 3 ] + ); + } + } ); + } + + $.each( pluralTestcases, function ( langCode, tests ) { + if ( langCode === mw.config.get( 'wgUserLanguage' ) ) { + pluralTest( langCode, tests ); + } + } ); +}( mediaWiki, jQuery ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.cookie.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.cookie.test.js new file mode 100644 index 00000000..59bf7376 --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.cookie.test.js @@ -0,0 +1,179 @@ +( function ( mw, $ ) { + + var NOW = 9012, // miliseconds + DEFAULT_DURATION = 5678, // seconds + expiryDate = new Date(); + + expiryDate.setTime( NOW + ( DEFAULT_DURATION * 1000 ) ); + + QUnit.module( 'mediawiki.cookie', QUnit.newMwEnvironment( { + setup: function () { + this.stub( $, 'cookie' ).returns( null ); + + this.sandbox.useFakeTimers( NOW ); + }, + config: { + wgCookiePrefix: 'mywiki', + wgCookieDomain: 'example.org', + wgCookiePath: '/path', + wgCookieExpiration: DEFAULT_DURATION + } + } ) ); + + QUnit.test( 'set( key, value )', function ( assert ) { + var call; + + // Simple case + mw.cookie.set( 'foo', 'bar' ); + + call = $.cookie.lastCall.args; + assert.strictEqual( call[ 0 ], 'mywikifoo' ); + assert.strictEqual( call[ 1 ], 'bar' ); + assert.deepEqual( call[ 2 ], { + expires: expiryDate, + domain: 'example.org', + path: '/path', + secure: false + } ); + + mw.cookie.set( 'foo', null ); + call = $.cookie.lastCall.args; + assert.strictEqual( call[ 1 ], null, 'null removes cookie' ); + + mw.cookie.set( 'foo', undefined ); + call = $.cookie.lastCall.args; + assert.strictEqual( call[ 1 ], 'undefined', 'undefined is value' ); + + mw.cookie.set( 'foo', false ); + call = $.cookie.lastCall.args; + assert.strictEqual( call[ 1 ], 'false', 'false is a value' ); + + mw.cookie.set( 'foo', 0 ); + call = $.cookie.lastCall.args; + assert.strictEqual( call[ 1 ], '0', '0 is value' ); + } ); + + QUnit.test( 'set( key, value, expires )', function ( assert ) { + var date, options; + + date = new Date(); + date.setTime( 1234 ); + + mw.cookie.set( 'foo', 'bar' ); + options = $.cookie.lastCall.args[ 2 ]; + assert.deepEqual( options.expires, expiryDate, 'default expiration' ); + + mw.cookie.set( 'foo', 'bar', date ); + options = $.cookie.lastCall.args[ 2 ]; + assert.strictEqual( options.expires, date, 'custom expiration as Date' ); + + date = new Date(); + date.setDate( date.getDate() + 1 ); + + mw.cookie.set( 'foo', 'bar', 86400 ); + options = $.cookie.lastCall.args[ 2 ]; + assert.deepEqual( options.expires, date, 'custom expiration as lifetime in seconds' ); + + mw.cookie.set( 'foo', 'bar', null ); + options = $.cookie.lastCall.args[ 2 ]; + assert.strictEqual( options.expires, undefined, 'null forces session cookie' ); + + // Per DefaultSettings.php, when wgCookieExpiration is 0, the default should + // be session cookies + mw.config.set( 'wgCookieExpiration', 0 ); + + mw.cookie.set( 'foo', 'bar' ); + options = $.cookie.lastCall.args[ 2 ]; + assert.strictEqual( options.expires, undefined, 'wgCookieExpiration=0 results in session cookies by default' ); + + mw.cookie.set( 'foo', 'bar', date ); + options = $.cookie.lastCall.args[ 2 ]; + assert.strictEqual( options.expires, date, 'custom expiration (with wgCookieExpiration=0)' ); + } ); + + QUnit.test( 'set( key, value, options )', function ( assert ) { + var date, call; + + mw.cookie.set( 'foo', 'bar', { + prefix: 'myPrefix', + domain: 'myDomain', + path: 'myPath', + secure: true + } ); + + call = $.cookie.lastCall.args; + assert.strictEqual( call[ 0 ], 'myPrefixfoo' ); + assert.deepEqual( call[ 2 ], { + expires: expiryDate, + domain: 'myDomain', + path: 'myPath', + secure: true + }, 'Options (without expires)' ); + + date = new Date(); + date.setTime( 1234 ); + + mw.cookie.set( 'foo', 'bar', { + expires: date, + prefix: 'myPrefix', + domain: 'myDomain', + path: 'myPath', + secure: true + } ); + + call = $.cookie.lastCall.args; + assert.strictEqual( call[ 0 ], 'myPrefixfoo' ); + assert.deepEqual( call[ 2 ], { + expires: date, + domain: 'myDomain', + path: 'myPath', + secure: true + }, 'Options (incl. expires)' ); + } ); + + QUnit.test( 'get( key ) - no values', function ( assert ) { + var key, value; + + mw.cookie.get( 'foo' ); + + key = $.cookie.lastCall.args[ 0 ]; + assert.strictEqual( key, 'mywikifoo', 'Default prefix' ); + + mw.cookie.get( 'foo', undefined ); + key = $.cookie.lastCall.args[ 0 ]; + assert.strictEqual( key, 'mywikifoo', 'Use default prefix for undefined' ); + + mw.cookie.get( 'foo', null ); + key = $.cookie.lastCall.args[ 0 ]; + assert.strictEqual( key, 'mywikifoo', 'Use default prefix for null' ); + + mw.cookie.get( 'foo', '' ); + key = $.cookie.lastCall.args[ 0 ]; + assert.strictEqual( key, 'foo', 'Don\'t use default prefix for empty string' ); + + value = mw.cookie.get( 'foo' ); + assert.strictEqual( value, null, 'Return null by default' ); + + value = mw.cookie.get( 'foo', null, 'bar' ); + assert.strictEqual( value, 'bar', 'Custom default value' ); + } ); + + QUnit.test( 'get( key ) - with value', function ( assert ) { + var value; + + $.cookie.returns( 'bar' ); + + value = mw.cookie.get( 'foo' ); + assert.strictEqual( value, 'bar', 'Return value of cookie' ); + } ); + + QUnit.test( 'get( key, prefix )', function ( assert ) { + var key; + + mw.cookie.get( 'foo', 'bar' ); + + key = $.cookie.lastCall.args[ 0 ]; + assert.strictEqual( key, 'barfoo' ); + } ); + +}( mediaWiki, jQuery ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.errorLogger.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.errorLogger.test.js new file mode 100644 index 00000000..2a4d9912 --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.errorLogger.test.js @@ -0,0 +1,42 @@ +( function ( mw ) { + QUnit.module( 'mediawiki.errorLogger', QUnit.newMwEnvironment() ); + + QUnit.test( 'installGlobalHandler', function ( assert ) { + var w = {}, + errorMessage = 'Foo', + errorUrl = 'http://example.com', + errorLine = '123', + errorColumn = '45', + errorObject = new Error( 'Foo' ), + oldHandler = this.sandbox.stub(); + + this.sandbox.stub( mw, 'track' ); + + mw.errorLogger.installGlobalHandler( w ); + + assert.ok( w.onerror, 'Global handler has been installed' ); + assert.strictEqual( w.onerror( errorMessage, errorUrl, errorLine ), false, + 'Global handler returns false when there is no previous handler' ); + sinon.assert.calledWithExactly( mw.track, 'global.error', + sinon.match( { errorMessage: errorMessage, url: errorUrl, lineNumber: errorLine } ) ); + + mw.track.reset(); + w.onerror( errorMessage, errorUrl, errorLine, errorColumn, errorObject ); + sinon.assert.calledWithExactly( mw.track, 'global.error', + sinon.match( { errorMessage: errorMessage, url: errorUrl, lineNumber: errorLine, + columnNumber: errorColumn, errorObject: errorObject } ) ); + + w = { onerror: oldHandler }; + + mw.errorLogger.installGlobalHandler( w ); + w.onerror( errorMessage, errorUrl, errorLine ); + sinon.assert.calledWithExactly( oldHandler, errorMessage, errorUrl, errorLine ); + + oldHandler.returns( false ); + assert.strictEqual( w.onerror( errorMessage, errorUrl, errorLine ), false, + 'Global handler preserves false return from previous handler' ); + oldHandler.returns( true ); + assert.strictEqual( w.onerror( errorMessage, errorUrl, errorLine ), true, + 'Global handler preserves true return from previous handler' ); + } ); +}( mediaWiki ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.experiments.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.experiments.test.js new file mode 100644 index 00000000..177c3580 --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.experiments.test.js @@ -0,0 +1,63 @@ +( function ( mw ) { + + var getBucket = mw.experiments.getBucket; + + function createExperiment() { + return { + name: 'experiment', + enabled: true, + buckets: { + control: 0.25, + A: 0.25, + B: 0.25, + C: 0.25 + } + }; + } + + QUnit.module( 'mediawiki.experiments' ); + + QUnit.test( 'getBucket( experiment, token )', function ( assert ) { + var experiment = createExperiment(), + token = '123457890'; + + assert.equal( + getBucket( experiment, token ), + getBucket( experiment, token ), + 'It returns the same bucket for the same experiment-token pair.' + ); + + // -------- + experiment = createExperiment(); + experiment.buckets = { + A: 0.314159265359 + }; + + assert.equal( + 'A', + getBucket( experiment, token ), + 'It returns the bucket if only one is defined.' + ); + + // -------- + experiment = createExperiment(); + experiment.enabled = false; + + assert.equal( + 'control', + getBucket( experiment, token ), + 'It returns "control" if the experiment is disabled.' + ); + + // -------- + experiment = createExperiment(); + experiment.buckets = {}; + + assert.equal( + 'control', + getBucket( experiment, token ), + 'It returns "control" if the experiment doesn\'t have any buckets.' + ); + } ); + +}( mediaWiki ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.html.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.html.test.js new file mode 100644 index 00000000..16f8cf3b --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.html.test.js @@ -0,0 +1,105 @@ +( function ( mw ) { + QUnit.module( 'mediawiki.html' ); + + QUnit.test( 'escape', function ( assert ) { + assert.throws( + function () { + mw.html.escape(); + }, + TypeError, + 'throw a TypeError if argument is not a string' + ); + + assert.equal( + mw.html.escape( '<mw awesome="awesome" value=\'test\' />' ), + '<mw awesome="awesome" value='test' />', + 'Escape special characters to html entities' + ); + } ); + + QUnit.test( 'element()', function ( assert ) { + assert.equal( + mw.html.element(), + '<undefined/>', + 'return valid html even without arguments' + ); + } ); + + QUnit.test( 'element( tagName )', function ( assert ) { + assert.equal( mw.html.element( 'div' ), '<div/>', 'DIV' ); + } ); + + QUnit.test( 'element( tagName, attrs )', function ( assert ) { + assert.equal( mw.html.element( 'div', {} ), '<div/>', 'DIV' ); + + assert.equal( + mw.html.element( + 'div', { + id: 'foobar' + } + ), + '<div id="foobar"/>', + 'DIV with attribs' + ); + } ); + + QUnit.test( 'element( tagName, attrs, content )', function ( assert ) { + + assert.equal( mw.html.element( 'div', {}, '' ), '<div></div>', 'DIV with empty attributes and content' ); + + assert.equal( mw.html.element( 'p', {}, 12 ), '<p>12</p>', 'numbers as content cast to strings' ); + + assert.equal( mw.html.element( 'p', { title: 12 }, '' ), '<p title="12"></p>', 'number as attribute value' ); + + assert.equal( + mw.html.element( + 'div', + {}, + new mw.html.Raw( + mw.html.element( 'img', { src: '<' } ) + ) + ), + '<div><img src="<"/></div>', + 'unescaped content with mw.html.Raw' + ); + + assert.equal( + mw.html.element( + 'option', + { + selected: true + }, + 'Foo' + ), + '<option selected="selected">Foo</option>', + 'boolean true attribute value' + ); + + assert.equal( + mw.html.element( + 'option', + { + value: 'foo', + selected: false + }, + 'Foo' + ), + '<option value="foo">Foo</option>', + 'boolean false attribute value' + ); + + assert.equal( + mw.html.element( 'div', null, 'a' ), + '<div>a</div>', + 'Skip attributes with null' ); + + assert.equal( + mw.html.element( 'a', { + href: 'http://mediawiki.org/w/index.php?title=RL&action=history' + }, 'a' ), + '<a href="http://mediawiki.org/w/index.php?title=RL&action=history">a</a>', + 'Andhor tag with attributes and content' + ); + } ); + +}( mediaWiki ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.inspect.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.inspect.test.js new file mode 100644 index 00000000..1f7a5ece --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.inspect.test.js @@ -0,0 +1,74 @@ +( function ( mw ) { + + QUnit.module( 'mediawiki.inspect' ); + + QUnit.test( '.getModuleSize() - scripts', function ( assert ) { + mw.loader.implement( + 'test.inspect.script', + function () { 'example'; } + ); + + return mw.loader.using( 'test.inspect.script' ).then( function () { + assert.equal( + mw.inspect.getModuleSize( 'test.inspect.script' ), + // name, script function + 43, + 'test.inspect.script' + ); + } ); + } ); + + QUnit.test( '.getModuleSize() - scripts, styles', function ( assert ) { + mw.loader.implement( + 'test.inspect.both', + function () { 'example'; }, + { css: [ '.example {}' ] } + ); + + return mw.loader.using( 'test.inspect.both' ).then( function () { + assert.equal( + mw.inspect.getModuleSize( 'test.inspect.both' ), + // name, script function, styles object + 64, + 'test.inspect.both' + ); + } ); + } ); + + QUnit.test( '.getModuleSize() - scripts, messages', function ( assert ) { + mw.loader.implement( + 'test.inspect.scriptmsg', + function () { 'example'; }, + {}, + { example: 'Hello world.' } + ); + + return mw.loader.using( 'test.inspect.scriptmsg' ).then( function () { + assert.equal( + mw.inspect.getModuleSize( 'test.inspect.scriptmsg' ), + // name, script function, empty styles object, messages object + 74, + 'test.inspect.scriptmsg' + ); + } ); + } ); + + QUnit.test( '.getModuleSize() - scripts, styles, messages, templates', function ( assert ) { + mw.loader.implement( + 'test.inspect.all', + function () { 'example'; }, + { css: [ '.example {}' ] }, + { example: 'Hello world.' }, + { 'example.html': '<p>Hello world.<p>' } + ); + + return mw.loader.using( 'test.inspect.all' ).then( function () { + assert.equal( + mw.inspect.getModuleSize( 'test.inspect.all' ), + // name, script function, styles object, messages object, templates object + 126, + 'test.inspect.all' + ); + } ); + } ); +}( mediaWiki ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.jqueryMsg.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.jqueryMsg.test.js new file mode 100644 index 00000000..0653dfd3 --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.jqueryMsg.test.js @@ -0,0 +1,1233 @@ +( function ( mw, $ ) { + /* eslint-disable camelcase */ + var formatText, formatParse, formatnumTests, specialCharactersPageName, expectedListUsers, + expectedListUsersSitename, expectedLinkPagenamee, expectedEntrypoints, + mwLanguageCache = {}, + hasOwn = Object.hasOwnProperty; + + // When the expected result is the same in both modes + function assertBothModes( assert, parserArguments, expectedResult, assertMessage ) { + assert.equal( formatText.apply( null, parserArguments ), expectedResult, assertMessage + ' when format is \'text\'' ); + assert.equal( formatParse.apply( null, parserArguments ), expectedResult, assertMessage + ' when format is \'parse\'' ); + } + + QUnit.module( 'mediawiki.jqueryMsg', QUnit.newMwEnvironment( { + setup: function () { + this.originalMwLanguage = mw.language; + this.parserDefaults = mw.jqueryMsg.getParserDefaults(); + mw.jqueryMsg.setParserDefaults( { + magic: { + PAGENAME: '2 + 2', + PAGENAMEE: mw.util.wikiUrlencode( '2 + 2' ), + SITENAME: 'Wiki' + } + } ); + + specialCharactersPageName = '"Who" wants to be a millionaire & live on \'Exotic Island\'?'; + + expectedListUsers = '注册<a title="Special:ListUsers" href="/wiki/Special:ListUsers">用户</a>'; + expectedListUsersSitename = '注册<a title="Special:ListUsers" href="/wiki/Special:ListUsers">用户' + + 'Wiki</a>'; + expectedLinkPagenamee = '<a href="https://example.org/wiki/Foo?bar=baz#val/2_%2B_2">Test</a>'; + + expectedEntrypoints = '<a href="https://www.mediawiki.org/wiki/Manual:index.php">index.php</a>'; + + formatText = mw.jqueryMsg.getMessageFunction( { + format: 'text' + } ); + + formatParse = mw.jqueryMsg.getMessageFunction( { + format: 'parse' + } ); + }, + teardown: function () { + mw.language = this.originalMwLanguage; + mw.jqueryMsg.setParserDefaults( this.parserDefaults ); + }, + config: { + wgArticlePath: '/wiki/$1', + wgNamespaceIds: { + template: 10, + template_talk: 11, + // Localised + szablon: 10, + dyskusja_szablonu: 11 + }, + wgFormattedNamespaces: { + // Localised + 10: 'Szablon', + 11: 'Dyskusja szablonu' + } + }, + // Messages that are reused in multiple tests + messages: { + // The values for gender are not significant, + // what matters is which of the values is choosen by the parser + 'gender-msg': '$1: {{GENDER:$2|blue|pink|green}}', + 'gender-msg-currentuser': '{{GENDER:|blue|pink|green}}', + + 'plural-msg': 'Found $1 {{PLURAL:$1|item|items}}', + // See https://phabricator.wikimedia.org/T71993 + 'plural-msg-explicit-forms-nested': 'Found {{PLURAL:$1|$1 results|0=no results in {{SITENAME}}|1=$1 result}}', + // Assume the grammar form grammar_case_foo is not valid in any language + 'grammar-msg': 'Przeszukaj {{GRAMMAR:grammar_case_foo|{{SITENAME}}}}', + + 'formatnum-msg': '{{formatnum:$1}}', + + 'portal-url': 'Project:Community portal', + 'see-portal-url': '{{Int:portal-url}} is an important community page.', + + 'jquerymsg-test-statistics-users': '注册[[Special:ListUsers|用户]]', + 'jquerymsg-test-statistics-users-sitename': '注册[[Special:ListUsers|用户{{SITENAME}}]]', + 'jquerymsg-test-link-pagenamee': '[https://example.org/wiki/Foo?bar=baz#val/{{PAGENAMEE}} Test]', + + 'jquerymsg-test-version-entrypoints-index-php': '[https://www.mediawiki.org/wiki/Manual:index.php index.php]', + + 'external-link-replace': 'Foo [$1 bar]', + 'external-link-plural': 'Foo {{PLURAL:$1|is [$2 one]|are [$2 some]|2=[$2 two]|3=three|4=a=b}} things.', + 'plural-only-explicit-forms': 'It is a {{PLURAL:$1|1=single|2=double}} room.', + 'plural-empty-explicit-form': 'There is me{{PLURAL:$1|0=| and other people}}.' + } + } ) ); + + /** + * Be careful to no run this in parallel as it uses a global identifier (mw.language) + * to transport the module back to the test. It musn't be overwritten concurrentely. + * + * This function caches the mw.language data to avoid having to request the same module + * multiple times. There is more than one test case for any given language. + */ + function getMwLanguage( langCode ) { + if ( !hasOwn.call( mwLanguageCache, langCode ) ) { + mwLanguageCache[ langCode ] = $.ajax( { + url: mw.util.wikiScript( 'load' ), + data: { + skin: mw.config.get( 'skin' ), + lang: langCode, + debug: mw.config.get( 'debug' ), + modules: [ + 'mediawiki.language.data', + 'mediawiki.language' + ].join( '|' ), + only: 'scripts' + }, + dataType: 'script', + cache: true + } ).then( function () { + return mw.language; + } ); + } + return mwLanguageCache[ langCode ]; + } + + /** + * @param {Function[]} tasks List of functions that perform tasks + * that may be asynchronous. Invoke the callback parameter when done. + */ + function process( tasks ) { + function abort() { + tasks.splice( 0, tasks.length ); + // eslint-disable-next-line no-use-before-define + next(); + } + function next() { + var task; + if ( !tasks ) { + // This happens if after the process is completed, one of our callbacks is + // invoked. This can happen if a test timed out but the process was still + // running. In that case, ignore it. Don't invoke complete() a second time. + return; + } + task = tasks.shift(); + if ( task ) { + task( next, abort ); + } else { + // Remove tasks list to indicate the process is final. + tasks = null; + } + } + next(); + } + + QUnit.test( 'Replace', function ( assert ) { + mw.messages.set( 'simple', 'Foo $1 baz $2' ); + + assert.equal( formatParse( 'simple' ), 'Foo $1 baz $2', 'Replacements with no substitutes' ); + assert.equal( formatParse( 'simple', 'bar' ), 'Foo bar baz $2', 'Replacements with less substitutes' ); + assert.equal( formatParse( 'simple', 'bar', 'quux' ), 'Foo bar baz quux', 'Replacements with all substitutes' ); + + mw.messages.set( 'plain-input', '<foo foo="foo">x$1y<</foo>z' ); + + assert.equal( + formatParse( 'plain-input', 'bar' ), + '<foo foo="foo">xbary&lt;</foo>z', + 'Input is not considered html' + ); + + mw.messages.set( 'plain-replace', 'Foo $1' ); + + assert.equal( + formatParse( 'plain-replace', '<bar bar="bar">></bar>' ), + 'Foo <bar bar="bar">&gt;</bar>', + 'Replacement is not considered html' + ); + + mw.messages.set( 'object-replace', 'Foo $1' ); + + assert.equal( + formatParse( 'object-replace', $( '<div class="bar">></div>' ) ), + 'Foo <div class="bar">></div>', + 'jQuery objects are preserved as raw html' + ); + + assert.equal( + formatParse( 'object-replace', $( '<div class="bar">></div>' ).get( 0 ) ), + 'Foo <div class="bar">></div>', + 'HTMLElement objects are preserved as raw html' + ); + + assert.equal( + formatParse( 'object-replace', $( '<div class="bar">></div>' ).toArray() ), + 'Foo <div class="bar">></div>', + 'HTMLElement[] arrays are preserved as raw html' + ); + + assert.equal( + formatParse( 'external-link-replace', 'http://example.org/?x=y&z' ), + 'Foo <a href="http://example.org/?x=y&z">bar</a>', + 'Href is not double-escaped in wikilink function' + ); + assert.equal( + formatParse( 'external-link-plural', 1, 'http://example.org' ), + 'Foo is <a href="http://example.org">one</a> things.', + 'Link is expanded inside plural and is not escaped html' + ); + assert.equal( + formatParse( 'external-link-plural', 2, 'http://example.org' ), + 'Foo <a href="http://example.org">two</a> things.', + 'Link is expanded inside an explicit plural form and is not escaped html' + ); + assert.equal( + formatParse( 'external-link-plural', 3 ), + 'Foo three things.', + 'A simple explicit plural form co-existing with complex explicit plural forms' + ); + assert.equal( + formatParse( 'external-link-plural', 4, 'http://example.org' ), + 'Foo a=b things.', + 'Only first equal sign is used as delimiter for explicit plural form. Repeated equal signs does not create issue' + ); + assert.equal( + formatParse( 'external-link-plural', 6, 'http://example.org' ), + 'Foo are <a href="http://example.org">some</a> things.', + 'Plural fallback to the "other" plural form' + ); + assert.equal( + formatParse( 'plural-only-explicit-forms', 2 ), + 'It is a double room.', + 'Plural with explicit forms alone.' + ); + } ); + + QUnit.test( 'Plural', function ( assert ) { + assert.equal( formatParse( 'plural-msg', 0 ), 'Found 0 items', 'Plural test for english with zero as count' ); + assert.equal( formatParse( 'plural-msg', 1 ), 'Found 1 item', 'Singular test for english' ); + assert.equal( formatParse( 'plural-msg', 2 ), 'Found 2 items', 'Plural test for english' ); + assert.equal( formatParse( 'plural-msg-explicit-forms-nested', 6 ), 'Found 6 results', 'Plural message with explicit plural forms' ); + assert.equal( formatParse( 'plural-msg-explicit-forms-nested', 0 ), 'Found no results in Wiki', 'Plural message with explicit plural forms, with nested {{SITENAME}}' ); + assert.equal( formatParse( 'plural-msg-explicit-forms-nested', 1 ), 'Found 1 result', 'Plural message with explicit plural forms with placeholder nested' ); + assert.equal( formatParse( 'plural-empty-explicit-form', 0 ), 'There is me.' ); + assert.equal( formatParse( 'plural-empty-explicit-form', 1 ), 'There is me and other people.' ); + assert.equal( formatParse( 'plural-empty-explicit-form', 2 ), 'There is me and other people.' ); + } ); + + QUnit.test( 'Gender', function ( assert ) { + var originalGender = mw.user.options.get( 'gender' ); + + // TODO: These tests should be for mw.msg once mw.msg integrated with mw.jqueryMsg + // TODO: English may not be the best language for these tests. Use a language like Arabic or Russian + mw.user.options.set( 'gender', 'male' ); + assert.equal( + formatParse( 'gender-msg', 'Bob', 'male' ), + 'Bob: blue', + 'Masculine from string "male"' + ); + assert.equal( + formatParse( 'gender-msg', 'Bob', mw.user ), + 'Bob: blue', + 'Masculine from mw.user object' + ); + assert.equal( + formatParse( 'gender-msg-currentuser' ), + 'blue', + 'Masculine for current user' + ); + + mw.user.options.set( 'gender', 'female' ); + assert.equal( + formatParse( 'gender-msg', 'Alice', 'female' ), + 'Alice: pink', + 'Feminine from string "female"' ); + assert.equal( + formatParse( 'gender-msg', 'Alice', mw.user ), + 'Alice: pink', + 'Feminine from mw.user object' + ); + assert.equal( + formatParse( 'gender-msg-currentuser' ), + 'pink', + 'Feminine for current user' + ); + + mw.user.options.set( 'gender', 'unknown' ); + assert.equal( + formatParse( 'gender-msg', 'Foo', mw.user ), + 'Foo: green', + 'Neutral from mw.user object' ); + assert.equal( + formatParse( 'gender-msg', 'User' ), + 'User: green', + 'Neutral when no parameter given' ); + assert.equal( + formatParse( 'gender-msg', 'User', 'unknown' ), + 'User: green', + 'Neutral from string "unknown"' + ); + assert.equal( + formatParse( 'gender-msg-currentuser' ), + 'green', + 'Neutral for current user' + ); + + mw.messages.set( 'gender-msg-one-form', '{{GENDER:$1|User}}: $2 {{PLURAL:$2|edit|edits}}' ); + + assert.equal( + formatParse( 'gender-msg-one-form', 'male', 10 ), + 'User: 10 edits', + 'Gender neutral and plural form' + ); + assert.equal( + formatParse( 'gender-msg-one-form', 'female', 1 ), + 'User: 1 edit', + 'Gender neutral and singular form' + ); + + mw.messages.set( 'gender-msg-lowercase', '{{gender:$1|he|she}} is awesome' ); + assert.equal( + formatParse( 'gender-msg-lowercase', 'male' ), + 'he is awesome', + 'Gender masculine' + ); + assert.equal( + formatParse( 'gender-msg-lowercase', 'female' ), + 'she is awesome', + 'Gender feminine' + ); + + mw.messages.set( 'gender-msg-wrong', '{{gender}} test' ); + assert.equal( + formatParse( 'gender-msg-wrong', 'female' ), + ' test', + 'Invalid syntax should result in {{gender}} simply being stripped away' + ); + + mw.user.options.set( 'gender', originalGender ); + } ); + + QUnit.test( 'Case changing', function ( assert ) { + mw.messages.set( 'to-lowercase', '{{lc:thIS hAS MEsSed uP CapItaliZatiON}}' ); + assert.equal( formatParse( 'to-lowercase' ), 'this has messed up capitalization', 'To lowercase' ); + + mw.messages.set( 'to-caps', '{{uc:thIS hAS MEsSed uP CapItaliZatiON}}' ); + assert.equal( formatParse( 'to-caps' ), 'THIS HAS MESSED UP CAPITALIZATION', 'To caps' ); + + mw.messages.set( 'uc-to-lcfirst', '{{lcfirst:THis hAS MEsSed uP CapItaliZatiON}}' ); + mw.messages.set( 'lc-to-lcfirst', '{{lcfirst:thIS hAS MEsSed uP CapItaliZatiON}}' ); + assert.equal( formatParse( 'uc-to-lcfirst' ), 'tHis hAS MEsSed uP CapItaliZatiON', 'Lcfirst caps' ); + assert.equal( formatParse( 'lc-to-lcfirst' ), 'thIS hAS MEsSed uP CapItaliZatiON', 'Lcfirst lowercase' ); + + mw.messages.set( 'uc-to-ucfirst', '{{ucfirst:THis hAS MEsSed uP CapItaliZatiON}}' ); + mw.messages.set( 'lc-to-ucfirst', '{{ucfirst:thIS hAS MEsSed uP CapItaliZatiON}}' ); + assert.equal( formatParse( 'uc-to-ucfirst' ), 'THis hAS MEsSed uP CapItaliZatiON', 'Ucfirst caps' ); + assert.equal( formatParse( 'lc-to-ucfirst' ), 'ThIS hAS MEsSed uP CapItaliZatiON', 'Ucfirst lowercase' ); + + mw.messages.set( 'mixed-to-sentence', '{{ucfirst:{{lc:thIS hAS MEsSed uP CapItaliZatiON}}}}' ); + assert.equal( formatParse( 'mixed-to-sentence' ), 'This has messed up capitalization', 'To sentence case' ); + mw.messages.set( 'all-caps-except-first', '{{lcfirst:{{uc:thIS hAS MEsSed uP CapItaliZatiON}}}}' ); + assert.equal( formatParse( 'all-caps-except-first' ), 'tHIS HAS MESSED UP CAPITALIZATION', 'To opposite sentence case' ); + } ); + + QUnit.test( 'Grammar', function ( assert ) { + assert.equal( formatParse( 'grammar-msg' ), 'Przeszukaj Wiki', 'Grammar Test with sitename' ); + + mw.messages.set( 'grammar-msg-wrong-syntax', 'Przeszukaj {{GRAMMAR:grammar_case_xyz}}' ); + assert.equal( formatParse( 'grammar-msg-wrong-syntax' ), 'Przeszukaj ', 'Grammar Test with wrong grammar template syntax' ); + } ); + + QUnit.test( 'Match PHP parser', function ( assert ) { + var tasks; + mw.messages.set( mw.libs.phpParserData.messages ); + tasks = $.map( mw.libs.phpParserData.tests, function ( test ) { + var done = assert.async(); + return function ( next, abort ) { + getMwLanguage( test.lang ) + .then( function ( langClass ) { + var parser; + mw.config.set( 'wgUserLanguage', test.lang ); + parser = new mw.jqueryMsg.Parser( { language: langClass } ); + assert.equal( + parser.parse( test.key, test.args ).html(), + test.result, + test.name + ); + }, function () { + assert.ok( false, 'Language "' + test.lang + '" failed to load.' ); + } ) + .then( done, done ) + .then( next, abort ); + }; + } ); + + process( tasks ); + } ); + + QUnit.test( 'Links', function ( assert ) { + var testCases, + expectedDisambiguationsText, + expectedMultipleBars, + expectedSpecialCharacters; + + // The below three are all identical to or based on real messages. For disambiguations-text, + // the bold was removed because it is not yet implemented. + + assert.htmlEqual( + formatParse( 'jquerymsg-test-statistics-users' ), + expectedListUsers, + 'Piped wikilink' + ); + + expectedDisambiguationsText = 'The following pages contain at least one link to a disambiguation page.\nThey may have to link to a more appropriate page instead.\nA page is treated as a disambiguation page if it uses a template that is linked from ' + + '<a title="MediaWiki:Disambiguationspage" href="/wiki/MediaWiki:Disambiguationspage">MediaWiki:Disambiguationspage</a>.'; + + mw.messages.set( 'disambiguations-text', 'The following pages contain at least one link to a disambiguation page.\nThey may have to link to a more appropriate page instead.\nA page is treated as a disambiguation page if it uses a template that is linked from [[MediaWiki:Disambiguationspage]].' ); + assert.htmlEqual( + formatParse( 'disambiguations-text' ), + expectedDisambiguationsText, + 'Wikilink without pipe' + ); + + assert.htmlEqual( + formatParse( 'jquerymsg-test-version-entrypoints-index-php' ), + expectedEntrypoints, + 'External link' + ); + + // Pipe trick is not supported currently, but should not parse as text either. + mw.messages.set( 'pipe-trick', '[[Tampa, Florida|]]' ); + mw.messages.set( 'reverse-pipe-trick', '[[|Tampa, Florida]]' ); + mw.messages.set( 'empty-link', '[[]]' ); + this.suppressWarnings(); + assert.equal( + formatParse( 'pipe-trick' ), + '[[Tampa, Florida|]]', + 'Pipe trick should not be parsed.' + ); + assert.equal( + formatParse( 'reverse-pipe-trick' ), + '[[|Tampa, Florida]]', + 'Reverse pipe trick should not be parsed.' + ); + assert.equal( + formatParse( 'empty-link' ), + '[[]]', + 'Empty link should not be parsed.' + ); + this.restoreWarnings(); + + expectedMultipleBars = '<a title="Main Page" href="/wiki/Main_Page">Main|Page</a>'; + mw.messages.set( 'multiple-bars', '[[Main Page|Main|Page]]' ); + assert.htmlEqual( + formatParse( 'multiple-bars' ), + expectedMultipleBars, + 'Bar in anchor' + ); + + expectedSpecialCharacters = '<a title=""Who" wants to be a millionaire & live on 'Exotic Island'?" href="/wiki/%22Who%22_wants_to_be_a_millionaire_%26_live_on_%27Exotic_Island%27%3F">"Who" wants to be a millionaire & live on 'Exotic Island'?</a>'; + + mw.messages.set( 'special-characters', '[[' + specialCharactersPageName + ']]' ); + assert.htmlEqual( + formatParse( 'special-characters' ), + expectedSpecialCharacters, + 'Special characters' + ); + + mw.messages.set( 'leading-colon', '[[:File:Foo.jpg]]' ); + assert.htmlEqual( + formatParse( 'leading-colon' ), + '<a title="File:Foo.jpg" href="/wiki/File:Foo.jpg">File:Foo.jpg</a>', + 'Leading colon in links is stripped' + ); + + assert.htmlEqual( + formatParse( 'jquerymsg-test-statistics-users-sitename' ), + expectedListUsersSitename, + 'Piped wikilink with parser function in the text' + ); + + assert.htmlEqual( + formatParse( 'jquerymsg-test-link-pagenamee' ), + expectedLinkPagenamee, + 'External link with parser function in the URL' + ); + + testCases = [ + [ + 'extlink-html-full', + 'asd [http://example.org <strong>Example</strong>] asd', + 'asd <a href="http://example.org"><strong>Example</strong></a> asd' + ], + [ + 'extlink-html-partial', + 'asd [http://example.org foo <strong>Example</strong> bar] asd', + 'asd <a href="http://example.org">foo <strong>Example</strong> bar</a> asd' + ], + [ + 'wikilink-html-full', + 'asd [[Example|<strong>Example</strong>]] asd', + 'asd <a title="Example" href="/wiki/Example"><strong>Example</strong></a> asd' + ], + [ + 'wikilink-html-partial', + 'asd [[Example|foo <strong>Example</strong> bar]] asd', + 'asd <a title="Example" href="/wiki/Example">foo <strong>Example</strong> bar</a> asd' + ] + ]; + + testCases.forEach( function ( testCase ) { + var + key = testCase[ 0 ], + input = testCase[ 1 ], + output = testCase[ 2 ]; + mw.messages.set( key, input ); + assert.htmlEqual( + formatParse( key ), + output, + 'HTML in links: ' + key + ); + } ); + } ); + + QUnit.test( 'Replacements in links', function ( assert ) { + var testCases = [ + [ + 'extlink-param-href-full', + 'asd [$1 Example] asd', + 'asd <a href="http://example.com">Example</a> asd' + ], + [ + 'extlink-param-href-partial', + 'asd [$1/example Example] asd', + 'asd <a href="http://example.com/example">Example</a> asd' + ], + [ + 'extlink-param-text-full', + 'asd [http://example.org $2] asd', + 'asd <a href="http://example.org">Text</a> asd' + ], + [ + 'extlink-param-text-partial', + 'asd [http://example.org Example $2] asd', + 'asd <a href="http://example.org">Example Text</a> asd' + ], + [ + 'extlink-param-both-full', + 'asd [$1 $2] asd', + 'asd <a href="http://example.com">Text</a> asd' + ], + [ + 'extlink-param-both-partial', + 'asd [$1/example Example $2] asd', + 'asd <a href="http://example.com/example">Example Text</a> asd' + ], + [ + 'wikilink-param-href-full', + 'asd [[$1|Example]] asd', + 'asd <a title="Example" href="/wiki/Example">Example</a> asd' + ], + [ + 'wikilink-param-href-partial', + 'asd [[$1/Test|Example]] asd', + 'asd <a title="Example/Test" href="/wiki/Example/Test">Example</a> asd' + ], + [ + 'wikilink-param-text-full', + 'asd [[Example|$2]] asd', + 'asd <a title="Example" href="/wiki/Example">Text</a> asd' + ], + [ + 'wikilink-param-text-partial', + 'asd [[Example|Example $2]] asd', + 'asd <a title="Example" href="/wiki/Example">Example Text</a> asd' + ], + [ + 'wikilink-param-both-full', + 'asd [[$1|$2]] asd', + 'asd <a title="Example" href="/wiki/Example">Text</a> asd' + ], + [ + 'wikilink-param-both-partial', + 'asd [[$1/Test|Example $2]] asd', + 'asd <a title="Example/Test" href="/wiki/Example/Test">Example Text</a> asd' + ], + [ + 'wikilink-param-unpiped-full', + 'asd [[$1]] asd', + 'asd <a title="Example" href="/wiki/Example">Example</a> asd' + ], + [ + 'wikilink-param-unpiped-partial', + 'asd [[$1/Test]] asd', + 'asd <a title="Example/Test" href="/wiki/Example/Test">Example/Test</a> asd' + ] + ]; + + testCases.forEach( function ( testCase ) { + var + key = testCase[ 0 ], + input = testCase[ 1 ], + output = testCase[ 2 ], + paramHref = key.slice( 0, 8 ) === 'wikilink' ? 'Example' : 'http://example.com', + paramText = 'Text'; + mw.messages.set( key, input ); + assert.htmlEqual( + formatParse( key, paramHref, paramText ), + output, + 'Replacements in links: ' + key + ); + } ); + } ); + + // Tests that {{-transformation vs. general parsing are done as requested + QUnit.test( 'Curly brace transformation', function ( assert ) { + var oldUserLang = mw.config.get( 'wgUserLanguage' ); + + assertBothModes( assert, [ 'gender-msg', 'Bob', 'male' ], 'Bob: blue', 'gender is resolved' ); + + assertBothModes( assert, [ 'plural-msg', 5 ], 'Found 5 items', 'plural is resolved' ); + + assertBothModes( assert, [ 'grammar-msg' ], 'Przeszukaj Wiki', 'grammar is resolved' ); + + mw.config.set( 'wgUserLanguage', 'en' ); + assertBothModes( assert, [ 'formatnum-msg', '987654321.654321' ], '987,654,321.654', 'formatnum is resolved' ); + + // Test non-{{ wikitext, where behavior differs + + // Wikilink + assert.equal( + formatText( 'jquerymsg-test-statistics-users' ), + mw.messages.get( 'jquerymsg-test-statistics-users' ), + 'Internal link message unchanged when format is \'text\'' + ); + assert.htmlEqual( + formatParse( 'jquerymsg-test-statistics-users' ), + expectedListUsers, + 'Internal link message parsed when format is \'parse\'' + ); + + // External link + assert.equal( + formatText( 'jquerymsg-test-version-entrypoints-index-php' ), + mw.messages.get( 'jquerymsg-test-version-entrypoints-index-php' ), + 'External link message unchanged when format is \'text\'' + ); + assert.htmlEqual( + formatParse( 'jquerymsg-test-version-entrypoints-index-php' ), + expectedEntrypoints, + 'External link message processed when format is \'parse\'' + ); + + // External link with parameter + assert.equal( + formatText( 'external-link-replace', 'http://example.com' ), + 'Foo [http://example.com bar]', + 'External link message only substitutes parameter when format is \'text\'' + ); + assert.htmlEqual( + formatParse( 'external-link-replace', 'http://example.com' ), + 'Foo <a href="http://example.com">bar</a>', + 'External link message processed when format is \'parse\'' + ); + assert.htmlEqual( + formatParse( 'external-link-replace', $( '<i>' ) ), + 'Foo <i>bar</i>', + 'External link message processed as jQuery object when format is \'parse\'' + ); + assert.htmlEqual( + formatParse( 'external-link-replace', function () {} ), + 'Foo <a role="button" tabindex="0">bar</a>', + 'External link message processed as function when format is \'parse\'' + ); + + mw.config.set( 'wgUserLanguage', oldUserLang ); + } ); + + QUnit.test( 'Int', function ( assert ) { + var newarticletextSource = 'You have followed a link to a page that does not exist yet. To create the page, start typing in the box below (see the [[{{Int:Foobar}}|foobar]] for more info). If you are here by mistake, click your browser\'s back button.', + expectedNewarticletext, + helpPageTitle = 'Help:Foobar'; + + mw.messages.set( 'foobar', helpPageTitle ); + + expectedNewarticletext = 'You have followed a link to a page that does not exist yet. To create the page, start typing in the box below (see the ' + + '<a title="Help:Foobar" href="/wiki/Help:Foobar">foobar</a> for more info). If you are here by mistake, click your browser\'s back button.'; + + mw.messages.set( 'newarticletext', newarticletextSource ); + + assert.htmlEqual( + formatParse( 'newarticletext' ), + expectedNewarticletext, + 'Link with nested message' + ); + + assert.equal( + formatParse( 'see-portal-url' ), + 'Project:Community portal is an important community page.', + 'Nested message' + ); + + mw.messages.set( 'newarticletext-lowercase', + newarticletextSource.replace( 'Int:Helppage', 'int:helppage' ) ); + + assert.htmlEqual( + formatParse( 'newarticletext-lowercase' ), + expectedNewarticletext, + 'Link with nested message, lowercase include' + ); + + mw.messages.set( 'uses-missing-int', '{{int:doesnt-exist}}' ); + + assert.equal( + formatParse( 'uses-missing-int' ), + '⧼doesnt-exist⧽', + 'int: where nested message does not exist' + ); + } ); + + QUnit.test( 'Ns', function ( assert ) { + mw.messages.set( 'ns-template-talk', '{{ns:Template talk}}' ); + assert.equal( + formatParse( 'ns-template-talk' ), + 'Dyskusja szablonu', + 'ns: returns localised namespace when used with a canonical namespace name' + ); + + mw.messages.set( 'ns-10', '{{ns:10}}' ); + assert.equal( + formatParse( 'ns-10' ), + 'Szablon', + 'ns: returns localised namespace when used with a namespace number' + ); + + mw.messages.set( 'ns-unknown', '{{ns:doesnt-exist}}' ); + assert.equal( + formatParse( 'ns-unknown' ), + '', + 'ns: returns empty string for unknown namespace name' + ); + + mw.messages.set( 'ns-in-a-link', '[[{{ns:template}}:Foo]]' ); + assert.equal( + formatParse( 'ns-in-a-link' ), + '<a title="Szablon:Foo" href="/wiki/Szablon:Foo">Szablon:Foo</a>', + 'ns: works when used inside a wikilink' + ); + } ); + + // Tests that getMessageFunction is used for non-plain messages with curly braces or + // square brackets, but not otherwise. + QUnit.test( 'mw.Message.prototype.parser monkey-patch', function ( assert ) { + var oldGMF, outerCalled, innerCalled; + + mw.messages.set( { + 'curly-brace': '{{int:message}}', + 'single-square-bracket': '[https://www.mediawiki.org/ MediaWiki]', + 'double-square-bracket': '[[Some page]]', + regular: 'Other message' + } ); + + oldGMF = mw.jqueryMsg.getMessageFunction; + + mw.jqueryMsg.getMessageFunction = function () { + outerCalled = true; + return function () { + innerCalled = true; + }; + }; + + function verifyGetMessageFunction( key, format, shouldCall ) { + var message; + outerCalled = false; + innerCalled = false; + message = mw.message( key ); + message[ format ](); + assert.strictEqual( outerCalled, shouldCall, 'Outer function called for ' + key ); + assert.strictEqual( innerCalled, shouldCall, 'Inner function called for ' + key ); + delete mw.messages[ format ]; + } + + verifyGetMessageFunction( 'curly-brace', 'parse', true ); + verifyGetMessageFunction( 'curly-brace', 'plain', false ); + + verifyGetMessageFunction( 'single-square-bracket', 'parse', true ); + verifyGetMessageFunction( 'single-square-bracket', 'plain', false ); + + verifyGetMessageFunction( 'double-square-bracket', 'parse', true ); + verifyGetMessageFunction( 'double-square-bracket', 'plain', false ); + + verifyGetMessageFunction( 'regular', 'parse', false ); + verifyGetMessageFunction( 'regular', 'plain', false ); + + verifyGetMessageFunction( 'jquerymsg-test-pagetriage-del-talk-page-notify-summary', 'plain', false ); + verifyGetMessageFunction( 'jquerymsg-test-categorytree-collapse-bullet', 'plain', false ); + verifyGetMessageFunction( 'jquerymsg-test-wikieditor-toolbar-help-content-signature-result', 'plain', false ); + + mw.jqueryMsg.getMessageFunction = oldGMF; + } ); + + formatnumTests = [ + { + lang: 'en', + number: 987654321.654321, + result: '987,654,321.654', + description: 'formatnum test for English, decimal separator' + }, + { + lang: 'ar', + number: 987654321.654321, + result: '٩٨٧٬٦٥٤٬٣٢١٫٦٥٤', + description: 'formatnum test for Arabic, with decimal separator' + }, + { + lang: 'ar', + number: '٩٨٧٦٥٤٣٢١٫٦٥٤٣٢١', + result: 987654321, + integer: true, + description: 'formatnum test for Arabic, with decimal separator, reverse' + }, + { + lang: 'ar', + number: -12.89, + result: '-١٢٫٨٩', + description: 'formatnum test for Arabic, negative number' + }, + { + lang: 'ar', + number: '-١٢٫٨٩', + result: -12, + integer: true, + description: 'formatnum test for Arabic, negative number, reverse' + }, + { + lang: 'nl', + number: 987654321.654321, + result: '987.654.321,654', + description: 'formatnum test for Nederlands, decimal separator' + }, + { + lang: 'nl', + number: -12.89, + result: '-12,89', + description: 'formatnum test for Nederlands, negative number' + }, + { + lang: 'nl', + number: '.89', + result: '0,89', + description: 'formatnum test for Nederlands' + }, + { + lang: 'nl', + number: 'invalidnumber', + result: 'invalidnumber', + description: 'formatnum test for Nederlands, invalid number' + }, + { + lang: 'ml', + number: '1000000000', + result: '1,00,00,00,000', + description: 'formatnum test for Malayalam' + }, + { + lang: 'ml', + number: '-1000000000', + result: '-1,00,00,00,000', + description: 'formatnum test for Malayalam, negative number' + }, + /* + * This will fail because of wrong pattern for ml in MW(different from CLDR) + { + lang: 'ml', + number: '1000000000.000', + result: '1,00,00,00,000.000', + description: 'formatnum test for Malayalam with decimal place' + }, + */ + { + lang: 'hi', + number: '123456789.123456789', + result: '१२,३४,५६,७८९', + description: 'formatnum test for Hindi' + }, + { + lang: 'hi', + number: '१२,३४,५६,७८९', + result: '१२,३४,५६,७८९', + description: 'formatnum test for Hindi, Devanagari digits passed' + }, + { + lang: 'hi', + number: '१,२३,४५६', + result: '123456', + integer: true, + description: 'formatnum test for Hindi, Devanagari digits passed to get integer value' + } + ]; + + QUnit.test( 'formatnum', function ( assert ) { + var queue; + mw.messages.set( 'formatnum-msg', '{{formatnum:$1}}' ); + mw.messages.set( 'formatnum-msg-int', '{{formatnum:$1|R}}' ); + queue = formatnumTests.map( function ( test ) { + var done = assert.async(); + return function ( next, abort ) { + getMwLanguage( test.lang ) + .then( function ( langClass ) { + var parser; + mw.config.set( 'wgUserLanguage', test.lang ); + parser = new mw.jqueryMsg.Parser( { language: langClass } ); + assert.equal( + parser.parse( test.integer ? 'formatnum-msg-int' : 'formatnum-msg', + [ test.number ] ).html(), + test.result, + test.description + ); + }, function () { + assert.ok( false, 'Language "' + test.lang + '" failed to load' ); + } ) + .then( done, done ) + .then( next, abort ); + }; + } ); + process( queue ); + } ); + + // HTML in wikitext + QUnit.test( 'HTML', function ( assert ) { + mw.messages.set( 'jquerymsg-italics-msg', '<i>Very</i> important' ); + + assertBothModes( assert, [ 'jquerymsg-italics-msg' ], mw.messages.get( 'jquerymsg-italics-msg' ), 'Simple italics unchanged' ); + + mw.messages.set( 'jquerymsg-bold-msg', '<b>Strong</b> speaker' ); + assertBothModes( assert, [ 'jquerymsg-bold-msg' ], mw.messages.get( 'jquerymsg-bold-msg' ), 'Simple bold unchanged' ); + + mw.messages.set( 'jquerymsg-bold-italics-msg', 'It is <b><i>key</i></b>' ); + assertBothModes( assert, [ 'jquerymsg-bold-italics-msg' ], mw.messages.get( 'jquerymsg-bold-italics-msg' ), 'Bold and italics nesting order preserved' ); + + mw.messages.set( 'jquerymsg-italics-bold-msg', 'It is <i><b>vital</b></i>' ); + assertBothModes( assert, [ 'jquerymsg-italics-bold-msg' ], mw.messages.get( 'jquerymsg-italics-bold-msg' ), 'Italics and bold nesting order preserved' ); + + mw.messages.set( 'jquerymsg-italics-with-link', 'An <i>italicized [[link|wiki-link]]</i>' ); + + assert.htmlEqual( + formatParse( 'jquerymsg-italics-with-link' ), + 'An <i>italicized <a title="link" href="' + mw.html.escape( mw.util.getUrl( 'link' ) ) + '">wiki-link</i>', + 'Italics with link inside in parse mode' + ); + + assert.equal( + formatText( 'jquerymsg-italics-with-link' ), + mw.messages.get( 'jquerymsg-italics-with-link' ), + 'Italics with link unchanged in text mode' + ); + + mw.messages.set( 'jquerymsg-italics-id-class', '<i id="foo" class="bar">Foo</i>' ); + assert.htmlEqual( + formatParse( 'jquerymsg-italics-id-class' ), + mw.messages.get( 'jquerymsg-italics-id-class' ), + 'ID and class are allowed' + ); + + mw.messages.set( 'jquerymsg-italics-onclick', '<i onclick="alert(\'foo\')">Foo</i>' ); + assert.htmlEqual( + formatParse( 'jquerymsg-italics-onclick' ), + '<i onclick="alert(\'foo\')">Foo</i>', + 'element with onclick is escaped because it is not allowed' + ); + + mw.messages.set( 'jquerymsg-script-msg', '<script >alert( "Who put this tag here?" );</script>' ); + assert.htmlEqual( + formatParse( 'jquerymsg-script-msg' ), + '<script >alert( "Who put this tag here?" );</script>', + 'Tag outside whitelist escaped in parse mode' + ); + + assert.equal( + formatText( 'jquerymsg-script-msg' ), + mw.messages.get( 'jquerymsg-script-msg' ), + 'Tag outside whitelist unchanged in text mode' + ); + + mw.messages.set( 'jquerymsg-script-link-msg', '<script>[[Foo|bar]]</script>' ); + assert.htmlEqual( + formatParse( 'jquerymsg-script-link-msg' ), + '<script><a title="Foo" href="' + mw.html.escape( mw.util.getUrl( 'Foo' ) ) + '">bar</a></script>', + 'Script tag text is escaped because that element is not allowed, but link inside is still HTML' + ); + + mw.messages.set( 'jquerymsg-mismatched-html', '<i class="important">test</b>' ); + assert.htmlEqual( + formatParse( 'jquerymsg-mismatched-html' ), + '<i class="important">test</b>', + 'Mismatched HTML start and end tag treated as text' + ); + + mw.messages.set( 'jquerymsg-script-and-external-link', '<script>alert( "jquerymsg-script-and-external-link test" );</script> [http://example.com <i>Foo</i> bar]' ); + assert.htmlEqual( + formatParse( 'jquerymsg-script-and-external-link' ), + '<script>alert( "jquerymsg-script-and-external-link test" );</script> <a href="http://example.com"><i>Foo</i> bar</a>', + 'HTML tags in external links not interfering with escaping of other tags' + ); + + mw.messages.set( 'jquerymsg-link-script', '[http://example.com <script>alert( "jquerymsg-link-script test" );</script>]' ); + assert.htmlEqual( + formatParse( 'jquerymsg-link-script' ), + '<a href="http://example.com"><script>alert( "jquerymsg-link-script test" );</script></a>', + 'Non-whitelisted HTML tag in external link anchor treated as text' + ); + + // Intentionally not using htmlEqual for the quote tests + mw.messages.set( 'jquerymsg-double-quotes-preserved', '<i id="double">Double</i>' ); + assert.equal( + formatParse( 'jquerymsg-double-quotes-preserved' ), + mw.messages.get( 'jquerymsg-double-quotes-preserved' ), + 'Attributes with double quotes are preserved as such' + ); + + mw.messages.set( 'jquerymsg-single-quotes-normalized-to-double', '<i id=\'single\'>Single</i>' ); + assert.equal( + formatParse( 'jquerymsg-single-quotes-normalized-to-double' ), + '<i id="single">Single</i>', + 'Attributes with single quotes are normalized to double' + ); + + mw.messages.set( 'jquerymsg-escaped-double-quotes-attribute', '<i style="font-family:"Arial"">Styled</i>' ); + assert.htmlEqual( + formatParse( 'jquerymsg-escaped-double-quotes-attribute' ), + mw.messages.get( 'jquerymsg-escaped-double-quotes-attribute' ), + 'Escaped attributes are parsed correctly' + ); + + mw.messages.set( 'jquerymsg-escaped-single-quotes-attribute', '<i style=\'font-family:'Arial'\'>Styled</i>' ); + assert.htmlEqual( + formatParse( 'jquerymsg-escaped-single-quotes-attribute' ), + mw.messages.get( 'jquerymsg-escaped-single-quotes-attribute' ), + 'Escaped attributes are parsed correctly' + ); + + mw.messages.set( 'jquerymsg-wikitext-contents-parsed', '<i>[http://example.com Example]</i>' ); + assert.htmlEqual( + formatParse( 'jquerymsg-wikitext-contents-parsed' ), + '<i><a href="http://example.com">Example</a></i>', + 'Contents of valid tag are treated as wikitext, so external link is parsed' + ); + + mw.messages.set( 'jquerymsg-wikitext-contents-script', '<i><script>Script inside</script></i>' ); + assert.htmlEqual( + formatParse( 'jquerymsg-wikitext-contents-script' ), + '<i><script>Script inside</script></i>', + 'Contents of valid tag are treated as wikitext, so invalid HTML element is treated as text' + ); + + mw.messages.set( 'jquerymsg-unclosed-tag', 'Foo<tag>bar' ); + assert.htmlEqual( + formatParse( 'jquerymsg-unclosed-tag' ), + 'Foo<tag>bar', + 'Nonsupported unclosed tags are escaped' + ); + + mw.messages.set( 'jquerymsg-self-closing-tag', 'Foo<tag/>bar' ); + assert.htmlEqual( + formatParse( 'jquerymsg-self-closing-tag' ), + 'Foo<tag/>bar', + 'Self-closing tags don\'t cause a parse error' + ); + + mw.messages.set( 'jquerymsg-asciialphabetliteral-regression', '<b >>>="dir">asd</b>' ); + assert.htmlEqual( + formatParse( 'jquerymsg-asciialphabetliteral-regression' ), + '<b>>>="dir">asd</b>', + 'Regression test for bad "asciiAlphabetLiteral" definition' + ); + + mw.messages.set( 'jquerymsg-entities1', 'A&B' ); + mw.messages.set( 'jquerymsg-entities2', 'A>B' ); + mw.messages.set( 'jquerymsg-entities3', 'A→B' ); + assert.htmlEqual( + formatParse( 'jquerymsg-entities1' ), + 'A&B', + 'Lone "&" is escaped in text' + ); + assert.htmlEqual( + formatParse( 'jquerymsg-entities2' ), + 'A&gt;B', + '">" entity is double-escaped in text' // (WHY?) + ); + assert.htmlEqual( + formatParse( 'jquerymsg-entities3' ), + 'A&rarr;B', + '"→" entity is double-escaped in text' + ); + + mw.messages.set( 'jquerymsg-entities-attr1', '<i title="A&B"></i>' ); + mw.messages.set( 'jquerymsg-entities-attr2', '<i title="A>B"></i>' ); + mw.messages.set( 'jquerymsg-entities-attr3', '<i title="A→B"></i>' ); + assert.htmlEqual( + formatParse( 'jquerymsg-entities-attr1' ), + '<i title="A&B"></i>', + 'Lone "&" is escaped in attribute' + ); + assert.htmlEqual( + formatParse( 'jquerymsg-entities-attr2' ), + '<i title="A>B"></i>', + '">" entity is not double-escaped in attribute' // (WHY?) + ); + assert.htmlEqual( + formatParse( 'jquerymsg-entities-attr3' ), + '<i title="A&rarr;B"></i>', + '"→" entity is double-escaped in attribute' + ); + } ); + + QUnit.test( 'Nowiki', function ( assert ) { + mw.messages.set( 'jquerymsg-nowiki-link', 'Foo <nowiki>[[bar]]</nowiki> baz.' ); + assert.equal( + formatParse( 'jquerymsg-nowiki-link' ), + 'Foo [[bar]] baz.', + 'Link inside nowiki is not parsed' + ); + + mw.messages.set( 'jquerymsg-nowiki-htmltag', 'Foo <nowiki><b>bar</b></nowiki> baz.' ); + assert.equal( + formatParse( 'jquerymsg-nowiki-htmltag' ), + 'Foo <b>bar</b> baz.', + 'HTML inside nowiki is not parsed and escaped' + ); + + mw.messages.set( 'jquerymsg-nowiki-template', 'Foo <nowiki>{{bar}}</nowiki> baz.' ); + assert.equal( + formatParse( 'jquerymsg-nowiki-template' ), + 'Foo {{bar}} baz.', + 'Template inside nowiki is not parsed and does not cause a parse error' + ); + } ); + + QUnit.test( 'Behavior in case of invalid wikitext', function ( assert ) { + var logSpy; + mw.messages.set( 'invalid-wikitext', '<b>{{FAIL}}</b>' ); + + this.suppressWarnings(); + logSpy = this.sandbox.spy( mw.log, 'warn' ); + + assert.equal( + formatParse( 'invalid-wikitext' ), + '<b>{{FAIL}}</b>', + 'Invalid wikitext: \'parse\' format' + ); + + assert.equal( + formatText( 'invalid-wikitext' ), + '<b>{{FAIL}}</b>', + 'Invalid wikitext: \'text\' format' + ); + + assert.equal( logSpy.callCount, 2, 'mw.log.warn calls' ); + } ); + + QUnit.test( 'Integration', function ( assert ) { + var expected, logSpy, msg; + + expected = '<b><a title="Bold" href="/wiki/Bold">Bold</a>!</b>'; + mw.messages.set( 'integration-test', '<b>[[Bold]]!</b>' ); + + this.suppressWarnings(); + logSpy = this.sandbox.spy( mw.log, 'warn' ); + assert.equal( + window.gM( 'integration-test' ), + expected, + 'Global function gM() works correctly' + ); + assert.equal( logSpy.callCount, 1, 'mw.log.warn called' ); + this.restoreWarnings(); + + assert.equal( + mw.message( 'integration-test' ).parse(), + expected, + 'mw.message().parse() works correctly' + ); + + assert.equal( + $( '<span>' ).msg( 'integration-test' ).html(), + expected, + 'jQuery plugin $.fn.msg() works correctly' + ); + + mw.messages.set( 'integration-test-extlink', '[$1 Link]' ); + msg = mw.message( + 'integration-test-extlink', + $( '<a>' ).attr( 'href', 'http://example.com/' ) + ); + msg.parse(); // Not a no-op + assert.equal( + msg.parse(), + '<a href="http://example.com/">Link</a>', + 'Calling .parse() multiple times does not duplicate link contents' + ); + } ); + + QUnit.test( 'setParserDefaults', function ( assert ) { + mw.jqueryMsg.setParserDefaults( { + magic: { + FOO: 'foo', + BAR: 'bar' + } + } ); + + assert.deepEqual( + mw.jqueryMsg.getParserDefaults().magic, + { + FOO: 'foo', + BAR: 'bar' + }, + 'setParserDefaults is shallow by default' + ); + + mw.jqueryMsg.setParserDefaults( + { + magic: { + BAZ: 'baz' + } + }, + true + ); + + assert.deepEqual( + mw.jqueryMsg.getParserDefaults().magic, + { + FOO: 'foo', + BAR: 'bar', + BAZ: 'baz' + }, + 'setParserDefaults is deep if requested' + ); + } ); +}( mediaWiki, jQuery ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.jscompat.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.jscompat.test.js new file mode 100644 index 00000000..b0b2e7a8 --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.jscompat.test.js @@ -0,0 +1,69 @@ +/** + * Some misc JavaScript compatibility tests, + * just to make sure the environments we run in are consistent. + */ +( function ( $ ) { + QUnit.module( 'mediawiki.jscompat', QUnit.newMwEnvironment() ); + + QUnit.test( 'Variable with Unicode letter in name', function ( assert ) { + var orig, ŝablono; + + orig = 'some token'; + ŝablono = orig; + + assert.deepEqual( ŝablono, orig, 'ŝablono' ); + assert.deepEqual( \u015dablono, orig, '\\u015dablono' ); + assert.deepEqual( \u015Dablono, orig, '\\u015Dablono' ); + } ); + + /* + // Not that we need this. ;) + // This fails on IE 6-8 + // Works on IE 9, Firefox 6, Chrome 14 + QUnit.test( 'Keyword workaround: "if" as variable name using Unicode escapes', function ( assert ) { + var orig = "another token"; + \u0069\u0066 = orig; + assert.deepEqual( \u0069\u0066, orig, '\\u0069\\u0066' ); + }); + */ + + /* + // Not that we need this. ;) + // This fails on IE 6-9 + // Works on Firefox 6, Chrome 14 + QUnit.test( 'Keyword workaround: "if" as member variable name using Unicode escapes', function ( assert ) { + var orig = "another token"; + var foo = {}; + foo.\u0069\u0066 = orig; + assert.deepEqual( foo.\u0069\u0066, orig, 'foo.\\u0069\\u0066' ); + }); + */ + + QUnit.test( 'Stripping of single initial newline from textarea\'s literal contents (T14130)', function ( assert ) { + var maxn, n, + expected, $textarea; + + maxn = 4; + + function repeat( str, n ) { + var out; + if ( n <= 0 ) { + return ''; + } else { + out = []; + out.length = n + 1; + return out.join( str ); + } + } + + for ( n = 0; n < maxn; n++ ) { + expected = repeat( '\n', n ) + 'some text'; + + $textarea = $( '<textarea>\n' + expected + '</textarea>' ); + assert.equal( $textarea.val(), expected, 'Expecting ' + n + ' newlines (HTML contained ' + ( n + 1 ) + ')' ); + + $textarea = $( '<textarea>' ).val( expected ); + assert.equal( $textarea.val(), expected, 'Expecting ' + n + ' newlines (from DOM set with ' + n + ')' ); + } + } ); +}( jQuery ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.language.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.language.test.js new file mode 100644 index 00000000..e4db771c --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.language.test.js @@ -0,0 +1,708 @@ +( function ( mw, $ ) { + 'use strict'; + + var grammarTests, bcp47Tests; + + QUnit.module( 'mediawiki.language', QUnit.newMwEnvironment( { + setup: function () { + this.liveLangData = mw.language.data; + mw.language.data = {}; + }, + teardown: function () { + mw.language.data = this.liveLangData; + }, + messages: { + // mw.language.listToText test + and: ' and', + 'comma-separator': ', ', + 'word-separator': ' ' + } + } ) ); + + QUnit.test( 'mw.language getData and setData', function ( assert ) { + mw.language.setData( 'en', 'testkey', 'testvalue' ); + assert.equal( mw.language.getData( 'en', 'testkey' ), 'testvalue', 'Getter setter test for mw.language' ); + assert.equal( mw.language.getData( 'en', 'invalidkey' ), undefined, 'Getter setter test for mw.language with invalid key' ); + mw.language.setData( 'en-us', 'testkey', 'testvalue' ); + assert.equal( mw.language.getData( 'en-US', 'testkey' ), 'testvalue', 'Case insensitive test for mw.language' ); + } ); + + QUnit.test( 'mw.language.commafy test', function ( assert ) { + mw.language.setData( 'en', 'digitGroupingPattern', null ); + mw.language.setData( 'en', 'digitTransformTable', null ); + mw.language.setData( 'en', 'separatorTransformTable', null ); + + mw.config.set( 'wgUserLanguage', 'en' ); + // Number grouping patterns are as per http://cldr.unicode.org/translation/number-patterns + assert.equal( mw.language.commafy( 1234.567, '###0.#####' ), '1234.567', 'Pattern with no digit grouping separator defined' ); + assert.equal( mw.language.commafy( 123456789.567, '###0.#####' ), '123456789.567', 'Pattern with no digit grouping separator defined, bigger decimal part' ); + assert.equal( mw.language.commafy( 0.567, '###0.#####' ), '0.567', 'Decimal part 0' ); + assert.equal( mw.language.commafy( '.567', '###0.#####' ), '0.567', 'Decimal part missing. replace with zero' ); + assert.equal( mw.language.commafy( 1234, '##,#0.#####' ), '12,34', 'Pattern with no fractional part' ); + assert.equal( mw.language.commafy( -1234.567, '###0.#####' ), '-1234.567', 'Negative number' ); + assert.equal( mw.language.commafy( -1234.567, '#,###.00' ), '-1,234.56', 'Fractional part bigger than pattern.' ); + assert.equal( mw.language.commafy( 123456789.567, '###,##0.00' ), '123,456,789.56', 'Decimal part as group of 3' ); + assert.equal( mw.language.commafy( 123456789.567, '###,###,#0.00' ), '1,234,567,89.56', 'Decimal part as group of 3 and last one 2' ); + } ); + + QUnit.test( 'mw.language.convertNumber', function ( assert ) { + mw.language.setData( 'en', 'digitGroupingPattern', null ); + mw.language.setData( 'en', 'digitTransformTable', null ); + mw.language.setData( 'en', 'separatorTransformTable', { ',': '.', '.': ',' } ); + mw.language.setData( 'en', 'minimumGroupingDigits', null ); + mw.config.set( 'wgUserLanguage', 'en' ); + mw.config.set( 'wgTranslateNumerals', true ); + + assert.equal( mw.language.convertNumber( 180 ), '180', 'formatting 3-digit' ); + assert.equal( mw.language.convertNumber( 1800 ), '1.800', 'formatting 4-digit' ); + assert.equal( mw.language.convertNumber( 18000 ), '18.000', 'formatting 5-digit' ); + + assert.equal( mw.language.convertNumber( '1.800', true ), '1800', 'unformatting' ); + + mw.language.setData( 'en', 'minimumGroupingDigits', 2 ); + assert.equal( mw.language.convertNumber( 180 ), '180', 'formatting 3-digit with minimumGroupingDigits=2' ); + assert.equal( mw.language.convertNumber( 1800 ), '1800', 'formatting 4-digit with minimumGroupingDigits=2' ); + assert.equal( mw.language.convertNumber( 18000 ), '18.000', 'formatting 5-digit with minimumGroupingDigits=2' ); + } ); + + QUnit.test( 'mw.language.convertNumber - digitTransformTable', function ( assert ) { + mw.config.set( 'wgUserLanguage', 'hi' ); + mw.config.set( 'wgTranslateNumerals', true ); + mw.language.setData( 'hi', 'digitGroupingPattern', null ); + mw.language.setData( 'hi', 'separatorTransformTable', { ',': '.', '.': ',' } ); + mw.language.setData( 'hi', 'minimumGroupingDigits', null ); + + // Example from Hindi (MessagesHi.php) + mw.language.setData( 'hi', 'digitTransformTable', { + 0: '०', + 1: '१', + 2: '२' + } ); + + assert.equal( mw.language.convertNumber( 1200 ), '१.२००', 'format' ); + assert.equal( mw.language.convertNumber( '१.२००', true ), '1200', 'unformat from digit transform' ); + assert.equal( mw.language.convertNumber( '1.200', true ), '1200', 'unformat plain' ); + + mw.config.set( 'wgTranslateNumerals', false ); + + assert.equal( mw.language.convertNumber( 1200 ), '1.200', 'format (digit transform disabled)' ); + assert.equal( mw.language.convertNumber( '१.२००', true ), '1200', 'unformat from digit transform (when disabled)' ); + assert.equal( mw.language.convertNumber( '1.200', true ), '1200', 'unformat plain (digit transform disabled)' ); + } ); + + function grammarTest( langCode, test ) { + // The test works only if the content language is opt.language + // because it requires [lang].js to be loaded. + QUnit.test( 'Grammar test for lang=' + langCode, function ( assert ) { + var i; + for ( i = 0; i < test.length; i++ ) { + assert.equal( + mw.language.convertGrammar( test[ i ].word, test[ i ].grammarForm ), + test[ i ].expected, + test[ i ].description + ); + } + } ); + } + + // These tests run only for the current UI language. + grammarTests = { + bs: [ + { + word: 'word', + grammarForm: 'instrumental', + expected: 's word', + description: 'Grammar test for instrumental case' + }, + { + word: 'word', + grammarForm: 'lokativ', + expected: 'o word', + description: 'Grammar test for lokativ case' + } + ], + + he: [ + { + word: 'ויקיפדיה', + grammarForm: 'prefixed', + expected: 'וויקיפדיה', + description: 'Duplicate the "Waw" if prefixed' + }, + { + word: 'וולפגנג', + grammarForm: 'prefixed', + expected: 'וולפגנג', + description: 'Duplicate the "Waw" if prefixed, but not if it is already duplicated.' + }, + { + word: 'הקובץ', + grammarForm: 'prefixed', + expected: 'קובץ', + description: 'Remove the "He" if prefixed' + }, + { + word: 'Wikipedia', + grammarForm: 'תחילית', + expected: '־Wikipedia', + description: 'Add a hyphen (maqaf) before non-Hebrew letters' + }, + { + word: '1995', + grammarForm: 'תחילית', + expected: '־1995', + description: 'Add a hyphen (maqaf) before numbers' + } + ], + + hsb: [ + { + word: 'word', + grammarForm: 'instrumental', + expected: 'z word', + description: 'Grammar test for instrumental case' + }, + { + word: 'word', + grammarForm: 'lokatiw', + expected: 'wo word', + description: 'Grammar test for lokatiw case' + } + ], + + dsb: [ + { + word: 'word', + grammarForm: 'instrumental', + expected: 'z word', + description: 'Grammar test for instrumental case' + }, + { + word: 'word', + grammarForm: 'lokatiw', + expected: 'wo word', + description: 'Grammar test for lokatiw case' + } + ], + + hy: [ + { + word: 'Մաունա', + grammarForm: 'genitive', + expected: 'Մաունայի', + description: 'Grammar test for genitive case' + }, + { + word: 'հետո', + grammarForm: 'genitive', + expected: 'հետոյի', + description: 'Grammar test for genitive case' + }, + { + word: 'գիրք', + grammarForm: 'genitive', + expected: 'գրքի', + description: 'Grammar test for genitive case' + }, + { + word: 'ժամանակի', + grammarForm: 'genitive', + expected: 'ժամանակիի', + description: 'Grammar test for genitive case' + } + ], + + fi: [ + { + word: 'talo', + grammarForm: 'genitive', + expected: 'talon', + description: 'Grammar test for genitive case' + }, + { + word: 'linux', + grammarForm: 'genitive', + expected: 'linuxin', + description: 'Grammar test for genitive case' + }, + { + word: 'talo', + grammarForm: 'elative', + expected: 'talosta', + description: 'Grammar test for elative case' + }, + { + word: 'pastöroitu', + grammarForm: 'partitive', + expected: 'pastöroitua', + description: 'Grammar test for partitive case' + }, + { + word: 'talo', + grammarForm: 'partitive', + expected: 'taloa', + description: 'Grammar test for partitive case' + }, + { + word: 'talo', + grammarForm: 'illative', + expected: 'taloon', + description: 'Grammar test for illative case' + }, + { + word: 'linux', + grammarForm: 'inessive', + expected: 'linuxissa', + description: 'Grammar test for inessive case' + } + ], + + ru: [ + { + word: 'тесть', + grammarForm: 'genitive', + expected: 'тестя', + description: 'Grammar test for genitive case, тесть -> тестя' + }, + { + word: 'привилегия', + grammarForm: 'genitive', + expected: 'привилегии', + description: 'Grammar test for genitive case, привилегия -> привилегии' + }, + { + word: 'установка', + grammarForm: 'genitive', + expected: 'установки', + description: 'Grammar test for genitive case, установка -> установки' + }, + { + word: 'похоти', + grammarForm: 'genitive', + expected: 'похотей', + description: 'Grammar test for genitive case, похоти -> похотей' + }, + { + word: 'доводы', + grammarForm: 'genitive', + expected: 'доводов', + description: 'Grammar test for genitive case, доводы -> доводов' + }, + { + word: 'песчаник', + grammarForm: 'genitive', + expected: 'песчаника', + description: 'Grammar test for genitive case, песчаник -> песчаника' + }, + { + word: 'данные', + grammarForm: 'genitive', + expected: 'данных', + description: 'Grammar test for genitive case, данные -> данных' + }, + { + word: 'тесть', + grammarForm: 'prepositional', + expected: 'тесте', + description: 'Grammar test for prepositional case, тесть -> тесте' + }, + { + word: 'привилегия', + grammarForm: 'prepositional', + expected: 'привилегии', + description: 'Grammar test for prepositional case, привилегия -> привилегии' + }, + { + word: 'университет', + grammarForm: 'prepositional', + expected: 'университете', + description: 'Grammar test for prepositional case, университет -> университете' + }, + { + word: 'университет', + grammarForm: 'genitive', + expected: 'университета', + description: 'Grammar test for prepositional case, университет -> университете' + }, + { + word: 'установка', + grammarForm: 'prepositional', + expected: 'установке', + description: 'Grammar test for prepositional case, установка -> установке' + }, + { + word: 'похоти', + grammarForm: 'prepositional', + expected: 'похотях', + description: 'Grammar test for prepositional case, похоти -> похотях' + }, + { + word: 'доводы', + grammarForm: 'prepositional', + expected: 'доводах', + description: 'Grammar test for prepositional case, доводы -> доводах' + }, + { + word: 'Викисклад', + grammarForm: 'prepositional', + expected: 'Викискладе', + description: 'Grammar test for prepositional case, Викисклад -> Викискладе' + }, + { + word: 'Викисклад', + grammarForm: 'genitive', + expected: 'Викисклада', + description: 'Grammar test for genitive case, Викисклад -> Викисклада' + }, + { + word: 'песчаник', + grammarForm: 'prepositional', + expected: 'песчанике', + description: 'Grammar test for prepositional case, песчаник -> песчанике' + }, + { + word: 'данные', + grammarForm: 'prepositional', + expected: 'данных', + description: 'Grammar test for prepositional case, данные -> данных' + }, + { + word: 'русский', + grammarForm: 'languagegen', + expected: 'русского', + description: 'Grammar test for languagegen case, русский -> русского' + }, + { + word: 'немецкий', + grammarForm: 'languagegen', + expected: 'немецкого', + description: 'Grammar test for languagegen case, немецкий -> немецкого' + }, + { + word: 'иврит', + grammarForm: 'languagegen', + expected: 'иврита', + description: 'Grammar test for languagegen case, иврит -> иврита' + }, + { + word: 'эсперанто', + grammarForm: 'languagegen', + expected: 'эсперанто', + description: 'Grammar test for languagegen case, эсперанто -> эсперанто' + }, + { + word: 'русский', + grammarForm: 'languageprep', + expected: 'русском', + description: 'Grammar test for languageprep case, русский -> русском' + }, + { + word: 'немецкий', + grammarForm: 'languageprep', + expected: 'немецком', + description: 'Grammar test for languageprep case, немецкий -> немецком' + }, + { + word: 'идиш', + grammarForm: 'languageprep', + expected: 'идише', + description: 'Grammar test for languageprep case, идиш -> идише' + }, + { + word: 'эсперанто', + grammarForm: 'languageprep', + expected: 'эсперанто', + description: 'Grammar test for languageprep case, эсперанто -> эсперанто' + }, + { + word: 'русский', + grammarForm: 'languageadverb', + expected: 'по-русски', + description: 'Grammar test for languageadverb case, русский -> по-русски' + }, + { + word: 'немецкий', + grammarForm: 'languageadverb', + expected: 'по-немецки', + description: 'Grammar test for languageadverb case, немецкий -> по-немецки' + }, + { + word: 'иврит', + grammarForm: 'languageadverb', + expected: 'на иврите', + description: 'Grammar test for languageadverb case, иврит -> на иврите' + }, + { + word: 'эсперанто', + grammarForm: 'languageadverb', + expected: 'на эсперанто', + description: 'Grammar test for languageadverb case, эсперанто -> на эсперанто' + }, + { + word: 'гуарани', + grammarForm: 'languageadverb', + expected: 'на языке гуарани', + description: 'Grammar test for languageadverb case, гуарани -> на языке гуарани' + } + ], + + hu: [ + { + word: 'Wikipédiá', + grammarForm: 'rol', + expected: 'Wikipédiáról', + description: 'Grammar test for rol case' + }, + { + word: 'Wikipédiá', + grammarForm: 'ba', + expected: 'Wikipédiába', + description: 'Grammar test for ba case' + }, + { + word: 'Wikipédiá', + grammarForm: 'k', + expected: 'Wikipédiák', + description: 'Grammar test for k case' + } + ], + + ga: [ + { + word: 'an Domhnach', + grammarForm: 'ainmlae', + expected: 'Dé Domhnaigh', + description: 'Grammar test for ainmlae case' + }, + { + word: 'an Luan', + grammarForm: 'ainmlae', + expected: 'Dé Luain', + description: 'Grammar test for ainmlae case' + }, + { + word: 'an Satharn', + grammarForm: 'ainmlae', + expected: 'Dé Sathairn', + description: 'Grammar test for ainmlae case' + } + ], + + uk: [ + { + word: 'Вікіпедія', + grammarForm: 'genitive', + expected: 'Вікіпедії', + description: 'Grammar test for genitive case' + }, + { + word: 'Віківиди', + grammarForm: 'genitive', + expected: 'Віківидів', + description: 'Grammar test for genitive case' + }, + { + word: 'Вікіцитати', + grammarForm: 'genitive', + expected: 'Вікіцитат', + description: 'Grammar test for genitive case' + }, + { + word: 'Вікіпідручник', + grammarForm: 'genitive', + expected: 'Вікіпідручника', + description: 'Grammar test for genitive case' + }, + { + word: 'Вікіпедія', + grammarForm: 'accusative', + expected: 'Вікіпедію', + description: 'Grammar test for accusative case' + } + ], + + sl: [ + { + word: 'word', + grammarForm: 'orodnik', + expected: 'z word', + description: 'Grammar test for orodnik case' + }, + { + word: 'word', + grammarForm: 'mestnik', + expected: 'o word', + description: 'Grammar test for mestnik case' + } + ], + + os: [ + { + word: 'бæстæ', + grammarForm: 'genitive', + expected: 'бæсты', + description: 'Grammar test for genitive case' + }, + { + word: 'бæстæ', + grammarForm: 'allative', + expected: 'бæстæм', + description: 'Grammar test for allative case' + }, + { + word: 'Тигр', + grammarForm: 'dative', + expected: 'Тигрæн', + description: 'Grammar test for dative case' + }, + { + word: 'цъити', + grammarForm: 'dative', + expected: 'цъитийæн', + description: 'Grammar test for dative case' + }, + { + word: 'лæппу', + grammarForm: 'genitive', + expected: 'лæппуйы', + description: 'Grammar test for genitive case' + }, + { + word: '2011', + grammarForm: 'equative', + expected: '2011-ау', + description: 'Grammar test for equative case' + } + ], + + la: [ + { + word: 'Translatio', + grammarForm: 'genitive', + expected: 'Translationis', + description: 'Grammar test for genitive case' + }, + { + word: 'Translatio', + grammarForm: 'accusative', + expected: 'Translationem', + description: 'Grammar test for accusative case' + }, + { + word: 'Translatio', + grammarForm: 'ablative', + expected: 'Translatione', + description: 'Grammar test for ablative case' + } + ] + }; + + $.each( grammarTests, function ( langCode, test ) { + if ( langCode === mw.config.get( 'wgUserLanguage' ) ) { + grammarTest( langCode, test ); + } + } ); + + QUnit.test( 'List to text test', function ( assert ) { + assert.equal( mw.language.listToText( [] ), '', 'Blank list' ); + assert.equal( mw.language.listToText( [ 'a' ] ), 'a', 'Single item' ); + assert.equal( mw.language.listToText( [ 'a', 'b' ] ), 'a and b', 'Two items' ); + assert.equal( mw.language.listToText( [ 'a', 'b', 'c' ] ), 'a, b and c', 'More than two items' ); + } ); + + bcp47Tests = [ + // Extracted from BCP 47 (list not exhaustive) + // # 2.1.1 + [ 'en-ca-x-ca', 'en-CA-x-ca' ], + [ 'sgn-be-fr', 'sgn-BE-FR' ], + [ 'az-latn-x-latn', 'az-Latn-x-latn' ], + // # 2.2 + [ 'sr-Latn-RS', 'sr-Latn-RS' ], + [ 'az-arab-ir', 'az-Arab-IR' ], + + // # 2.2.5 + [ 'sl-nedis', 'sl-nedis' ], + [ 'de-ch-1996', 'de-CH-1996' ], + + // # 2.2.6 + [ + 'en-latn-gb-boont-r-extended-sequence-x-private', + 'en-Latn-GB-boont-r-extended-sequence-x-private' + ], + + // Examples from BCP 47 Appendix A + // # Simple language subtag: + [ 'DE', 'de' ], + [ 'fR', 'fr' ], + [ 'ja', 'ja' ], + + // # Language subtag plus script subtag: + [ 'zh-hans', 'zh-Hans' ], + [ 'sr-cyrl', 'sr-Cyrl' ], + [ 'sr-latn', 'sr-Latn' ], + + // # Extended language subtags and their primary language subtag + // # counterparts: + [ 'zh-cmn-hans-cn', 'zh-cmn-Hans-CN' ], + [ 'cmn-hans-cn', 'cmn-Hans-CN' ], + [ 'zh-yue-hk', 'zh-yue-HK' ], + [ 'yue-hk', 'yue-HK' ], + + // # Language-Script-Region: + [ 'zh-hans-cn', 'zh-Hans-CN' ], + [ 'sr-latn-RS', 'sr-Latn-RS' ], + + // # Language-Variant: + [ 'sl-rozaj', 'sl-rozaj' ], + [ 'sl-rozaj-biske', 'sl-rozaj-biske' ], + [ 'sl-nedis', 'sl-nedis' ], + + // # Language-Region-Variant: + [ 'de-ch-1901', 'de-CH-1901' ], + [ 'sl-it-nedis', 'sl-IT-nedis' ], + + // # Language-Script-Region-Variant: + [ 'hy-latn-it-arevela', 'hy-Latn-IT-arevela' ], + + // # Language-Region: + [ 'de-de', 'de-DE' ], + [ 'en-us', 'en-US' ], + [ 'es-419', 'es-419' ], + + // # Private use subtags: + [ 'de-ch-x-phonebk', 'de-CH-x-phonebk' ], + [ 'az-arab-x-aze-derbend', 'az-Arab-x-aze-derbend' ], + /** + * Previous test does not reflect the BCP 47 which states: + * az-Arab-x-AZE-derbend + * AZE being private, it should be lower case, hence the test above + * should probably be: + * [ 'az-arab-x-aze-derbend', 'az-Arab-x-AZE-derbend' ], + */ + + // # Private use registry values: + [ 'x-whatever', 'x-whatever' ], + [ 'qaa-qaaa-qm-x-southern', 'qaa-Qaaa-QM-x-southern' ], + [ 'de-qaaa', 'de-Qaaa' ], + [ 'sr-latn-qm', 'sr-Latn-QM' ], + [ 'sr-qaaa-rs', 'sr-Qaaa-RS' ], + + // # Tags that use extensions + [ 'en-us-u-islamcal', 'en-US-u-islamcal' ], + [ 'zh-cn-a-myext-x-private', 'zh-CN-a-myext-x-private' ], + [ 'en-a-myext-b-another', 'en-a-myext-b-another' ] + + // # Invalid: + // de-419-DE + // a-DE + // ar-a-aaa-b-bbb-a-ccc + ]; + + QUnit.test( 'mw.language.bcp47', function ( assert ) { + bcp47Tests.forEach( function ( data ) { + var input = data[ 0 ], + expected = data[ 1 ]; + assert.equal( mw.language.bcp47( input ), expected ); + } ); + } ); +}( mediaWiki, jQuery ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.loader.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.loader.test.js new file mode 100644 index 00000000..42bc0a76 --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.loader.test.js @@ -0,0 +1,980 @@ +( function ( mw, $ ) { + QUnit.module( 'mediawiki.loader', QUnit.newMwEnvironment( { + setup: function ( assert ) { + mw.loader.store.enabled = false; + + // Expose for load.mock.php + mw.loader.testFail = function ( reason ) { + assert.ok( false, reason ); + }; + }, + teardown: function () { + mw.loader.store.enabled = false; + // Teardown for StringSet shim test + if ( this.nativeSet ) { + window.Set = this.nativeSet; + mw.redefineFallbacksForTest(); + } + // Remove any remaining temporary statics + // exposed for cross-file mocks. + delete mw.loader.testCallback; + delete mw.loader.testFail; + } + } ) ); + + mw.loader.addSource( + 'testloader', + QUnit.fixurl( mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/load.mock.php' ) + ); + + /** + * The sync style load test (for @import). This is, in a way, also an open bug for + * ResourceLoader ("execute js after styles are loaded"), but browsers don't offer a + * way to get a callback from when a stylesheet is loaded (that is, including any + * `@import` rules inside). To work around this, we'll have a little time loop to check + * if the styles apply. + * + * Note: This test originally used new Image() and onerror to get a callback + * when the url is loaded, but that is fragile since it doesn't monitor the + * same request as the css @import, and Safari 4 has issues with + * onerror/onload not being fired at all in weird cases like this. + */ + function assertStyleAsync( assert, $element, prop, val, fn ) { + var styleTestStart, + el = $element.get( 0 ), + styleTestTimeout = ( QUnit.config.testTimeout || 5000 ) - 200; + + function isCssImportApplied() { + // Trigger reflow, repaint, redraw, whatever (cross-browser) + $element.css( 'height' ); + // eslint-disable-next-line no-unused-expressions + el.innerHTML; + el.className = el.className; + // eslint-disable-next-line no-unused-expressions + document.documentElement.clientHeight; + + return $element.css( prop ) === val; + } + + function styleTestLoop() { + var styleTestSince = new Date().getTime() - styleTestStart; + // If it is passing or if we timed out, run the real test and stop the loop + if ( isCssImportApplied() || styleTestSince > styleTestTimeout ) { + assert.equal( $element.css( prop ), val, + 'style "' + prop + ': ' + val + '" from url is applied (after ' + styleTestSince + 'ms)' + ); + + if ( fn ) { + fn(); + } + + return; + } + // Otherwise, keep polling + setTimeout( styleTestLoop ); + } + + // Start the loop + styleTestStart = new Date().getTime(); + styleTestLoop(); + } + + function urlStyleTest( selector, prop, val ) { + return QUnit.fixurl( + mw.config.get( 'wgScriptPath' ) + + '/tests/qunit/data/styleTest.css.php?' + + $.param( { + selector: selector, + prop: prop, + val: val + } ) + ); + } + + QUnit.test( '.using( .., Function callback ) Promise', function ( assert ) { + var script = 0, callback = 0; + mw.loader.testCallback = function () { + script++; + }; + mw.loader.implement( 'test.promise', [ QUnit.fixurl( mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/mwLoaderTestCallback.js' ) ] ); + + return mw.loader.using( 'test.promise', function () { + callback++; + } ).then( function () { + assert.strictEqual( script, 1, 'module script ran' ); + assert.strictEqual( callback, 1, 'using() callback ran' ); + } ); + } ); + + QUnit.test( 'Prototype method as module name', function ( assert ) { + var call = 0; + mw.loader.testCallback = function () { + call++; + }; + mw.loader.implement( 'hasOwnProperty', [ QUnit.fixurl( mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/mwLoaderTestCallback.js' ) ], {}, {} ); + + return mw.loader.using( 'hasOwnProperty', function () { + assert.strictEqual( call, 1, 'module script ran' ); + } ); + } ); + + // Covers mw.loader#sortDependencies (with native Set if available) + QUnit.test( '.using() - Error: Circular dependency [StringSet default]', function ( assert ) { + var done = assert.async(); + + mw.loader.register( [ + [ 'test.circle1', '0', [ 'test.circle2' ] ], + [ 'test.circle2', '0', [ 'test.circle3' ] ], + [ 'test.circle3', '0', [ 'test.circle1' ] ] + ] ); + mw.loader.using( 'test.circle3' ).then( + function done() { + assert.ok( false, 'Unexpected resolution, expected error.' ); + }, + function fail( e ) { + assert.ok( /Circular/.test( String( e ) ), 'Detect circular dependency' ); + } + ) + .always( done ); + } ); + + // @covers mw.loader#sortDependencies (with fallback shim) + QUnit.test( '.using() - Error: Circular dependency [StringSet shim]', function ( assert ) { + var done = assert.async(); + + if ( !window.Set ) { + assert.expect( 0 ); + done(); + return; + } + + this.nativeSet = window.Set; + window.Set = undefined; + mw.redefineFallbacksForTest(); + + mw.loader.register( [ + [ 'test.shim.circle1', '0', [ 'test.shim.circle2' ] ], + [ 'test.shim.circle2', '0', [ 'test.shim.circle3' ] ], + [ 'test.shim.circle3', '0', [ 'test.shim.circle1' ] ] + ] ); + mw.loader.using( 'test.shim.circle3' ).then( + function done() { + assert.ok( false, 'Unexpected resolution, expected error.' ); + }, + function fail( e ) { + assert.ok( /Circular/.test( String( e ) ), 'Detect circular dependency' ); + } + ) + .always( done ); + } ); + + QUnit.test( '.load() - Error: Circular dependency', function ( assert ) { + var capture = []; + mw.loader.register( [ + [ 'test.circleA', '0', [ 'test.circleB' ] ], + [ 'test.circleB', '0', [ 'test.circleC' ] ], + [ 'test.circleC', '0', [ 'test.circleA' ] ] + ] ); + this.sandbox.stub( mw, 'track', function ( topic, data ) { + capture.push( { + topic: topic, + error: data.exception && data.exception.message, + source: data.source + } ); + } ); + + mw.loader.load( 'test.circleC' ); + assert.deepEqual( + [ { + topic: 'resourceloader.exception', + error: 'Circular reference detected: test.circleB -> test.circleC', + source: 'resolve' + } ], + capture, + 'Detect circular dependency' + ); + } ); + + QUnit.test( '.using() - Error: Unregistered', function ( assert ) { + var done = assert.async(); + + mw.loader.using( 'test.using.unreg' ).then( + function done() { + assert.ok( false, 'Unexpected resolution, expected error.' ); + }, + function fail( e ) { + assert.ok( /Unknown/.test( String( e ) ), 'Detect unknown dependency' ); + } + ).always( done ); + } ); + + QUnit.test( '.load() - Error: Unregistered', function ( assert ) { + var capture = []; + this.sandbox.stub( mw, 'track', function ( topic, data ) { + capture.push( { + topic: topic, + error: data.exception && data.exception.message, + source: data.source + } ); + } ); + + mw.loader.load( 'test.load.unreg' ); + assert.deepEqual( + [ { + topic: 'resourceloader.exception', + error: 'Unknown dependency: test.load.unreg', + source: 'resolve' + } ], + capture + ); + } ); + + // Regression test for T36853 + QUnit.test( '.load() - Error: Missing dependency', function ( assert ) { + var capture = []; + this.sandbox.stub( mw, 'track', function ( topic, data ) { + capture.push( { + topic: topic, + error: data.exception && data.exception.message, + source: data.source + } ); + } ); + + mw.loader.register( [ + [ 'test.load.missingdep1', '0', [ 'test.load.missingdep2' ] ], + [ 'test.load.missingdep', '0', [ 'test.load.missingdep1' ] ] + ] ); + mw.loader.load( 'test.load.missingdep' ); + assert.deepEqual( + [ { + topic: 'resourceloader.exception', + error: 'Unknown dependency: test.load.missingdep2', + source: 'resolve' + } ], + capture + ); + } ); + + QUnit.test( '.implement( styles={ "css": [text, ..] } )', function ( assert ) { + var $element = $( '<div class="mw-test-implement-a"></div>' ).appendTo( '#qunit-fixture' ); + + assert.notEqual( + $element.css( 'float' ), + 'right', + 'style is clear' + ); + + mw.loader.implement( + 'test.implement.a', + function () { + assert.equal( + $element.css( 'float' ), + 'right', + 'style is applied' + ); + }, + { + all: '.mw-test-implement-a { float: right; }' + } + ); + + return mw.loader.using( 'test.implement.a' ); + } ); + + QUnit.test( '.implement( styles={ "url": { <media>: [url, ..] } } )', function ( assert ) { + var $element1 = $( '<div class="mw-test-implement-b1"></div>' ).appendTo( '#qunit-fixture' ), + $element2 = $( '<div class="mw-test-implement-b2"></div>' ).appendTo( '#qunit-fixture' ), + $element3 = $( '<div class="mw-test-implement-b3"></div>' ).appendTo( '#qunit-fixture' ), + done = assert.async(); + + assert.notEqual( + $element1.css( 'text-align' ), + 'center', + 'style is clear' + ); + assert.notEqual( + $element2.css( 'float' ), + 'left', + 'style is clear' + ); + assert.notEqual( + $element3.css( 'text-align' ), + 'right', + 'style is clear' + ); + + mw.loader.implement( + 'test.implement.b', + function () { + // Note: done() must only be called when the entire test is + // complete. So, make sure that we don't start until *both* + // assertStyleAsync calls have completed. + var pending = 2; + assertStyleAsync( assert, $element2, 'float', 'left', function () { + assert.notEqual( $element1.css( 'text-align' ), 'center', 'print style is not applied' ); + + pending--; + if ( pending === 0 ) { + done(); + } + } ); + assertStyleAsync( assert, $element3, 'float', 'right', function () { + assert.notEqual( $element1.css( 'text-align' ), 'center', 'print style is not applied' ); + + pending--; + if ( pending === 0 ) { + done(); + } + } ); + }, + { + url: { + print: [ urlStyleTest( '.mw-test-implement-b1', 'text-align', 'center' ) ], + screen: [ + // T42834: Make sure it actually works with more than 1 stylesheet reference + urlStyleTest( '.mw-test-implement-b2', 'float', 'left' ), + urlStyleTest( '.mw-test-implement-b3', 'float', 'right' ) + ] + } + } + ); + + mw.loader.load( 'test.implement.b' ); + } ); + + // Backwards compatibility + QUnit.test( '.implement( styles={ <media>: text } ) (back-compat)', function ( assert ) { + var $element = $( '<div class="mw-test-implement-c"></div>' ).appendTo( '#qunit-fixture' ); + + assert.notEqual( + $element.css( 'float' ), + 'right', + 'style is clear' + ); + + mw.loader.implement( + 'test.implement.c', + function () { + assert.equal( + $element.css( 'float' ), + 'right', + 'style is applied' + ); + }, + { + all: '.mw-test-implement-c { float: right; }' + } + ); + + return mw.loader.using( 'test.implement.c' ); + } ); + + // Backwards compatibility + QUnit.test( '.implement( styles={ <media>: [url, ..] } ) (back-compat)', function ( assert ) { + var $element = $( '<div class="mw-test-implement-d"></div>' ).appendTo( '#qunit-fixture' ), + $element2 = $( '<div class="mw-test-implement-d2"></div>' ).appendTo( '#qunit-fixture' ), + done = assert.async(); + + assert.notEqual( + $element.css( 'float' ), + 'right', + 'style is clear' + ); + assert.notEqual( + $element2.css( 'text-align' ), + 'center', + 'style is clear' + ); + + mw.loader.implement( + 'test.implement.d', + function () { + assertStyleAsync( assert, $element, 'float', 'right', function () { + assert.notEqual( $element2.css( 'text-align' ), 'center', 'print style is not applied (T42500)' ); + done(); + } ); + }, + { + all: [ urlStyleTest( '.mw-test-implement-d', 'float', 'right' ) ], + print: [ urlStyleTest( '.mw-test-implement-d2', 'text-align', 'center' ) ] + } + ); + + mw.loader.load( 'test.implement.d' ); + } ); + + QUnit.test( '.implement( messages before script )', function ( assert ) { + mw.loader.implement( + 'test.implement.order', + function () { + assert.equal( mw.loader.getState( 'test.implement.order' ), 'executing', 'state during script execution' ); + assert.equal( mw.msg( 'test-foobar' ), 'Hello Foobar, $1!', 'messages load before script execution' ); + }, + {}, + { + 'test-foobar': 'Hello Foobar, $1!' + } + ); + + return mw.loader.using( 'test.implement.order' ).then( function () { + assert.equal( mw.loader.getState( 'test.implement.order' ), 'ready', 'final success state' ); + } ); + } ); + + // @import (T33676) + QUnit.test( '.implement( styles with @import )', function ( assert ) { + var $element, + done = assert.async(); + + mw.loader.implement( + 'test.implement.import', + function () { + $element = $( '<div class="mw-test-implement-import">Foo bar</div>' ).appendTo( '#qunit-fixture' ); + + assertStyleAsync( assert, $element, 'float', 'right', function () { + assert.equal( $element.css( 'text-align' ), 'center', + 'CSS styles after the @import rule are working' + ); + + done(); + } ); + }, + { + css: [ + '@import url(\'' + + urlStyleTest( '.mw-test-implement-import', 'float', 'right' ) + + '\');\n' + + '.mw-test-implement-import { text-align: center; }' + ] + } + ); + + return mw.loader.using( 'test.implement.import' ); + } ); + + QUnit.test( '.implement( dependency with styles )', function ( assert ) { + var $element = $( '<div class="mw-test-implement-e"></div>' ).appendTo( '#qunit-fixture' ), + $element2 = $( '<div class="mw-test-implement-e2"></div>' ).appendTo( '#qunit-fixture' ); + + assert.notEqual( + $element.css( 'float' ), + 'right', + 'style is clear' + ); + assert.notEqual( + $element2.css( 'float' ), + 'left', + 'style is clear' + ); + + mw.loader.register( [ + [ 'test.implement.e', '0', [ 'test.implement.e2' ] ], + [ 'test.implement.e2', '0' ] + ] ); + + mw.loader.implement( + 'test.implement.e', + function () { + assert.equal( + $element.css( 'float' ), + 'right', + 'Depending module\'s style is applied' + ); + }, + { + all: '.mw-test-implement-e { float: right; }' + } + ); + + mw.loader.implement( + 'test.implement.e2', + function () { + assert.equal( + $element2.css( 'float' ), + 'left', + 'Dependency\'s style is applied' + ); + }, + { + all: '.mw-test-implement-e2 { float: left; }' + } + ); + + return mw.loader.using( 'test.implement.e' ); + } ); + + QUnit.test( '.implement( only scripts )', function ( assert ) { + mw.loader.implement( 'test.onlyscripts', function () {} ); + assert.strictEqual( mw.loader.getState( 'test.onlyscripts' ), 'ready' ); + } ); + + QUnit.test( '.implement( only messages )', function ( assert ) { + assert.assertFalse( mw.messages.exists( 'T31107' ), 'Verify that the test message doesn\'t exist yet' ); + + mw.loader.implement( 'test.implement.msgs', [], {}, { T31107: 'loaded' } ); + + return mw.loader.using( 'test.implement.msgs', function () { + assert.ok( mw.messages.exists( 'T31107' ), 'T31107: messages-only module should implement ok' ); + } ); + } ); + + QUnit.test( '.implement( empty )', function ( assert ) { + mw.loader.implement( 'test.empty' ); + assert.strictEqual( mw.loader.getState( 'test.empty' ), 'ready' ); + } ); + + // @covers mw.loader#batchRequest + // This is a regression test because in the past we called getCombinedVersion() + // for all requested modules, before url splitting took place. + // Discovered as part of T188076, but not directly related. + QUnit.test( 'Url composition (modules considered for version)', function ( assert ) { + mw.loader.register( [ + // [module, version, dependencies, group, source] + [ 'testUrlInc', 'url', [], null, 'testloader' ], + [ 'testUrlIncDump', 'dump', [], null, 'testloader' ] + ] ); + + mw.config.set( 'wgResourceLoaderMaxQueryLength', 10 ); + + return mw.loader.using( [ 'testUrlIncDump', 'testUrlInc' ] ).then( function ( require ) { + assert.propEqual( + require( 'testUrlIncDump' ).query, + { + modules: 'testUrlIncDump', + // Expected: Wrapped hash just for this one module + // $hash = hash( 'fnv132', 'dump'); + // base_convert( $hash, 16, 36 ); // "13e9zzn" + // Previously: Wrapped hash for both modules, despite being in separate requests + // $hash = hash( 'fnv132', 'urldump' ); + // base_convert( $hash, 16, 36 ); // "18kz9ca" + version: '13e9zzn' + }, + 'Query parameters' + ); + + assert.strictEqual( mw.loader.getState( 'testUrlInc' ), 'ready', 'testUrlInc also loaded' ); + } ); + } ); + + // @covers mw.loader#batchRequest + // @covers mw.loader#buildModulesString + QUnit.test( 'Url composition (order of modules for version) – T188076', function ( assert ) { + mw.loader.register( [ + // [module, version, dependencies, group, source] + [ 'testUrlOrder', 'url', [], null, 'testloader' ], + [ 'testUrlOrder.a', '1', [], null, 'testloader' ], + [ 'testUrlOrder.b', '2', [], null, 'testloader' ], + [ 'testUrlOrderDump', 'dump', [], null, 'testloader' ] + ] ); + + return mw.loader.using( [ + 'testUrlOrderDump', + 'testUrlOrder.b', + 'testUrlOrder.a', + 'testUrlOrder' + ] ).then( function ( require ) { + assert.propEqual( + require( 'testUrlOrderDump' ).query, + { + modules: 'testUrlOrder,testUrlOrderDump|testUrlOrder.a,b', + // Expected: Combined in order after string packing + // $hash = hash( 'fnv132', 'urldump12' ); + // base_convert( $hash, 16, 36 ); // "1knqzan" + // Previously: Combined in order of before string packing + // $hash = hash( 'fnv132', 'url12dump' ); + // base_convert( $hash, 16, 36 ); // "11eo3in" + version: '1knqzan' + }, + 'Query parameters' + ); + } ); + } ); + + QUnit.test( 'Broken indirect dependency', function ( assert ) { + // don't emit an error event + this.sandbox.stub( mw, 'track' ); + + mw.loader.register( [ + [ 'test.module1', '0' ], + [ 'test.module2', '0', [ 'test.module1' ] ], + [ 'test.module3', '0', [ 'test.module2' ] ] + ] ); + mw.loader.implement( 'test.module1', function () { + throw new Error( 'expected' ); + }, {}, {} ); + assert.strictEqual( mw.loader.getState( 'test.module1' ), 'error', 'Expected "error" state for test.module1' ); + assert.strictEqual( mw.loader.getState( 'test.module2' ), 'error', 'Expected "error" state for test.module2' ); + assert.strictEqual( mw.loader.getState( 'test.module3' ), 'error', 'Expected "error" state for test.module3' ); + + assert.strictEqual( mw.track.callCount, 1 ); + } ); + + QUnit.test( 'Out-of-order implementation', function ( assert ) { + mw.loader.register( [ + [ 'test.module4', '0' ], + [ 'test.module5', '0', [ 'test.module4' ] ], + [ 'test.module6', '0', [ 'test.module5' ] ] + ] ); + mw.loader.implement( 'test.module4', function () {} ); + assert.strictEqual( mw.loader.getState( 'test.module4' ), 'ready', 'Expected "ready" state for test.module4' ); + assert.strictEqual( mw.loader.getState( 'test.module5' ), 'registered', 'Expected "registered" state for test.module5' ); + assert.strictEqual( mw.loader.getState( 'test.module6' ), 'registered', 'Expected "registered" state for test.module6' ); + mw.loader.implement( 'test.module6', function () {} ); + assert.strictEqual( mw.loader.getState( 'test.module4' ), 'ready', 'Expected "ready" state for test.module4' ); + assert.strictEqual( mw.loader.getState( 'test.module5' ), 'registered', 'Expected "registered" state for test.module5' ); + assert.strictEqual( mw.loader.getState( 'test.module6' ), 'loaded', 'Expected "loaded" state for test.module6' ); + mw.loader.implement( 'test.module5', function () {} ); + assert.strictEqual( mw.loader.getState( 'test.module4' ), 'ready', 'Expected "ready" state for test.module4' ); + assert.strictEqual( mw.loader.getState( 'test.module5' ), 'ready', 'Expected "ready" state for test.module5' ); + assert.strictEqual( mw.loader.getState( 'test.module6' ), 'ready', 'Expected "ready" state for test.module6' ); + } ); + + QUnit.test( 'Missing dependency', function ( assert ) { + mw.loader.register( [ + [ 'test.module7', '0' ], + [ 'test.module8', '0', [ 'test.module7' ] ], + [ 'test.module9', '0', [ 'test.module8' ] ] + ] ); + mw.loader.implement( 'test.module8', function () {} ); + assert.strictEqual( mw.loader.getState( 'test.module7' ), 'registered', 'Expected "registered" state for test.module7' ); + assert.strictEqual( mw.loader.getState( 'test.module8' ), 'loaded', 'Expected "loaded" state for test.module8' ); + assert.strictEqual( mw.loader.getState( 'test.module9' ), 'registered', 'Expected "registered" state for test.module9' ); + mw.loader.state( 'test.module7', 'missing' ); + assert.strictEqual( mw.loader.getState( 'test.module7' ), 'missing', 'Expected "missing" state for test.module7' ); + assert.strictEqual( mw.loader.getState( 'test.module8' ), 'error', 'Expected "error" state for test.module8' ); + assert.strictEqual( mw.loader.getState( 'test.module9' ), 'error', 'Expected "error" state for test.module9' ); + mw.loader.implement( 'test.module9', function () {} ); + assert.strictEqual( mw.loader.getState( 'test.module7' ), 'missing', 'Expected "missing" state for test.module7' ); + assert.strictEqual( mw.loader.getState( 'test.module8' ), 'error', 'Expected "error" state for test.module8' ); + assert.strictEqual( mw.loader.getState( 'test.module9' ), 'error', 'Expected "error" state for test.module9' ); + mw.loader.using( + [ 'test.module7' ], + function () { + assert.ok( false, 'Success fired despite missing dependency' ); + assert.ok( true, 'QUnit expected() count dummy' ); + }, + function ( e, dependencies ) { + assert.strictEqual( Array.isArray( dependencies ), true, 'Expected array of dependencies' ); + assert.deepEqual( dependencies, [ 'test.module7' ], 'Error callback called with module test.module7' ); + } + ); + mw.loader.using( + [ 'test.module9' ], + function () { + assert.ok( false, 'Success fired despite missing dependency' ); + assert.ok( true, 'QUnit expected() count dummy' ); + }, + function ( e, dependencies ) { + assert.strictEqual( Array.isArray( dependencies ), true, 'Expected array of dependencies' ); + dependencies.sort(); + assert.deepEqual( + dependencies, + [ 'test.module7', 'test.module8', 'test.module9' ], + 'Error callback called with all three modules as dependencies' + ); + } + ); + } ); + + QUnit.test( 'Dependency handling', function ( assert ) { + var done = assert.async(); + mw.loader.register( [ + // [module, version, dependencies, group, source] + [ 'testMissing', '1', [], null, 'testloader' ], + [ 'testUsesMissing', '1', [ 'testMissing' ], null, 'testloader' ], + [ 'testUsesNestedMissing', '1', [ 'testUsesMissing' ], null, 'testloader' ] + ] ); + + function verifyModuleStates() { + assert.equal( mw.loader.getState( 'testMissing' ), 'missing', 'Module "testMissing" state' ); + assert.equal( mw.loader.getState( 'testUsesMissing' ), 'error', 'Module "testUsesMissing" state' ); + assert.equal( mw.loader.getState( 'testUsesNestedMissing' ), 'error', 'Module "testUsesNestedMissing" state' ); + } + + mw.loader.using( [ 'testUsesNestedMissing' ], + function () { + assert.ok( false, 'Error handler should be invoked.' ); + assert.ok( true ); // Dummy to reach QUnit expect() + + verifyModuleStates(); + + done(); + }, + function ( e, badmodules ) { + assert.ok( true, 'Error handler should be invoked.' ); + // As soon as server spits out state('testMissing', 'missing'); + // it will bubble up and trigger the error callback. + // Therefor the badmodules array is not testUsesMissing or testUsesNestedMissing. + assert.deepEqual( badmodules, [ 'testMissing' ], 'Bad modules as expected.' ); + + verifyModuleStates(); + + done(); + } + ); + } ); + + QUnit.test( 'Skip-function handling', function ( assert ) { + mw.loader.register( [ + // [module, version, dependencies, group, source, skip] + [ 'testSkipped', '1', [], null, 'testloader', 'return true;' ], + [ 'testNotSkipped', '1', [], null, 'testloader', 'return false;' ], + [ 'testUsesSkippable', '1', [ 'testSkipped', 'testNotSkipped' ], null, 'testloader' ] + ] ); + + return mw.loader.using( [ 'testUsesSkippable' ] ).then( + function () { + assert.equal( mw.loader.getState( 'testSkipped' ), 'ready', 'Skipped module' ); + assert.equal( mw.loader.getState( 'testNotSkipped' ), 'ready', 'Regular module' ); + assert.equal( mw.loader.getState( 'testUsesSkippable' ), 'ready', 'Regular module with skippable dependency' ); + }, + function ( e, badmodules ) { + // Should not fail and QUnit would already catch this, + // but add a handler anyway to report details from 'badmodules + assert.deepEqual( badmodules, [], 'Bad modules' ); + } + ); + } ); + + // This bug was actually already fixed in 1.18 and later when discovered in 1.17. + QUnit.test( '.load( "//protocol-relative" ) - T32825', function ( assert ) { + var target, + done = assert.async(); + + // URL to the callback script + target = QUnit.fixurl( + mw.config.get( 'wgServer' ) + mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/mwLoaderTestCallback.js' + ); + // Ensure a protocol-relative URL for this test + target = target.replace( /https?:/, '' ); + assert.equal( target.slice( 0, 2 ), '//', 'URL is protocol-relative' ); + + mw.loader.testCallback = function () { + // Ensure once, delete now + delete mw.loader.testCallback; + assert.ok( true, 'callback' ); + done(); + }; + + // Go! + mw.loader.load( target ); + } ); + + QUnit.test( '.load( "/absolute-path" )', function ( assert ) { + var target, + done = assert.async(); + + // URL to the callback script + target = QUnit.fixurl( mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/mwLoaderTestCallback.js' ); + assert.equal( target.slice( 0, 1 ), '/', 'URL is relative to document root' ); + + mw.loader.testCallback = function () { + // Ensure once, delete now + delete mw.loader.testCallback; + assert.ok( true, 'callback' ); + done(); + }; + + // Go! + mw.loader.load( target ); + } ); + + QUnit.test( 'Empty string module name - T28804', function ( assert ) { + var done = false; + + assert.strictEqual( mw.loader.getState( '' ), null, 'State (unregistered)' ); + + mw.loader.register( '', 'v1' ); + assert.strictEqual( mw.loader.getState( '' ), 'registered', 'State (registered)' ); + assert.strictEqual( mw.loader.getVersion( '' ), 'v1', 'Version' ); + + mw.loader.implement( '', function () { + done = true; + } ); + + return mw.loader.using( '', function () { + assert.strictEqual( done, true, 'script ran' ); + assert.strictEqual( mw.loader.getState( '' ), 'ready', 'State (ready)' ); + } ); + } ); + + QUnit.test( 'Executing race - T112232', function ( assert ) { + var done = false; + + // The red herring schedules its CSS buffer first. In T112232, a bug in the + // state machine would cause the job for testRaceLoadMe to run with an earlier job. + mw.loader.implement( + 'testRaceRedHerring', + function () {}, + { css: [ '.mw-testRaceRedHerring {}' ] } + ); + mw.loader.implement( + 'testRaceLoadMe', + function () { + done = true; + }, + { css: [ '.mw-testRaceLoadMe { float: left; }' ] } + ); + + mw.loader.load( [ 'testRaceRedHerring', 'testRaceLoadMe' ] ); + return mw.loader.using( 'testRaceLoadMe', function () { + assert.strictEqual( done, true, 'script ran' ); + assert.strictEqual( mw.loader.getState( 'testRaceLoadMe' ), 'ready', 'state' ); + } ); + } ); + + QUnit.test( 'Stale response caching - T117587', function ( assert ) { + var count = 0; + mw.loader.store.enabled = true; + mw.loader.register( 'test.stale', 'v2' ); + assert.strictEqual( mw.loader.store.get( 'test.stale' ), false, 'Not in store' ); + + mw.loader.implement( 'test.stale@v1', function () { + count++; + } ); + + return mw.loader.using( 'test.stale' ) + .then( function () { + assert.strictEqual( count, 1 ); + // After implementing, registry contains version as implemented by the response. + assert.strictEqual( mw.loader.getVersion( 'test.stale' ), 'v1', 'Override version' ); + assert.strictEqual( mw.loader.getState( 'test.stale' ), 'ready' ); + assert.ok( mw.loader.store.get( 'test.stale' ), 'In store' ); + } ) + .then( function () { + // Reset run time, but keep mw.loader.store + mw.loader.moduleRegistry[ 'test.stale' ].script = undefined; + mw.loader.moduleRegistry[ 'test.stale' ].state = 'registered'; + mw.loader.moduleRegistry[ 'test.stale' ].version = 'v2'; + + // Module was stored correctly as v1 + // On future navigations, it will be ignored until evicted + assert.strictEqual( mw.loader.store.get( 'test.stale' ), false, 'Not in store' ); + } ); + } ); + + QUnit.test( 'Stale response caching - backcompat', function ( assert ) { + var script = 0; + mw.loader.store.enabled = true; + mw.loader.register( 'test.stalebc', 'v2' ); + assert.strictEqual( mw.loader.store.get( 'test.stalebc' ), false, 'Not in store' ); + + mw.loader.implement( 'test.stalebc', function () { + script++; + } ); + + return mw.loader.using( 'test.stalebc' ) + .then( function () { + assert.strictEqual( script, 1, 'module script ran' ); + assert.strictEqual( mw.loader.getState( 'test.stalebc' ), 'ready' ); + assert.ok( mw.loader.store.get( 'test.stalebc' ), 'In store' ); + } ) + .then( function () { + // Reset run time, but keep mw.loader.store + mw.loader.moduleRegistry[ 'test.stalebc' ].script = undefined; + mw.loader.moduleRegistry[ 'test.stalebc' ].state = 'registered'; + mw.loader.moduleRegistry[ 'test.stalebc' ].version = 'v2'; + + // Legacy behaviour is storing under the expected version, + // which woudl lead to whitewashing and stale values (T117587). + assert.ok( mw.loader.store.get( 'test.stalebc' ), 'In store' ); + } ); + } ); + + QUnit.test( 'require()', function ( assert ) { + mw.loader.register( [ + [ 'test.require1', '0' ], + [ 'test.require2', '0' ], + [ 'test.require3', '0' ], + [ 'test.require4', '0', [ 'test.require3' ] ] + ] ); + mw.loader.implement( 'test.require1', function () {} ); + mw.loader.implement( 'test.require2', function ( $, jQuery, require, module ) { + module.exports = 1; + } ); + mw.loader.implement( 'test.require3', function ( $, jQuery, require, module ) { + module.exports = function () { + return 'hello world'; + }; + } ); + mw.loader.implement( 'test.require4', function ( $, jQuery, require, module ) { + var other = require( 'test.require3' ); + module.exports = { + pizza: function () { + return other(); + } + }; + } ); + return mw.loader.using( [ 'test.require1', 'test.require2', 'test.require3', 'test.require4' ] ).then( function ( require ) { + var module1, module2, module3, module4; + + module1 = require( 'test.require1' ); + module2 = require( 'test.require2' ); + module3 = require( 'test.require3' ); + module4 = require( 'test.require4' ); + + assert.strictEqual( typeof module1, 'object', 'export of module with no export' ); + assert.strictEqual( module2, 1, 'export a number' ); + assert.strictEqual( module3(), 'hello world', 'export a function' ); + assert.strictEqual( typeof module4.pizza, 'function', 'export an object' ); + assert.strictEqual( module4.pizza(), 'hello world', 'module can require other modules' ); + + assert.throws( function () { + require( '_badmodule' ); + }, /is not loaded/, 'Requesting non-existent modules throws error.' ); + } ); + } ); + + QUnit.test( 'require() in debug mode', function ( assert ) { + var path = mw.config.get( 'wgScriptPath' ); + mw.loader.register( [ + [ 'test.require.define', '0' ], + [ 'test.require.callback', '0', [ 'test.require.define' ] ] + ] ); + mw.loader.implement( 'test.require.callback', [ QUnit.fixurl( path + '/tests/qunit/data/requireCallMwLoaderTestCallback.js' ) ] ); + mw.loader.implement( 'test.require.define', [ QUnit.fixurl( path + '/tests/qunit/data/defineCallMwLoaderTestCallback.js' ) ] ); + + return mw.loader.using( 'test.require.callback' ).then( function ( require ) { + var cb = require( 'test.require.callback' ); + assert.strictEqual( cb.immediate, 'Defined.', 'module.exports and require work in debug mode' ); + // Must use try-catch because cb.later() will throw if require is undefined, + // which doesn't work well inside Deferred.then() when using jQuery 1.x with QUnit + try { + assert.strictEqual( cb.later(), 'Defined.', 'require works asynchrously in debug mode' ); + } catch ( e ) { + assert.equal( null, String( e ), 'require works asynchrously in debug mode' ); + } + } ); + } ); + + QUnit.test( 'Implicit dependencies', function ( assert ) { + var user = 0, + site = 0, + siteFromUser = 0; + + mw.loader.implement( + 'site', + function () { + site++; + } + ); + mw.loader.implement( + 'user', + function () { + user++; + siteFromUser = site; + } + ); + + return mw.loader.using( 'user', function () { + assert.strictEqual( site, 1, 'site module' ); + assert.strictEqual( user, 1, 'user module' ); + assert.strictEqual( siteFromUser, 1, 'site ran before user' ); + } ).always( function () { + // Reset + mw.loader.moduleRegistry[ 'site' ].state = 'registered'; + mw.loader.moduleRegistry[ 'user' ].state = 'registered'; + } ); + } ); + +}( mediaWiki, jQuery ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.messagePoster.factory.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.messagePoster.factory.test.js new file mode 100644 index 00000000..923f97d1 --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.messagePoster.factory.test.js @@ -0,0 +1,28 @@ +( function ( mw ) { + var TEST_MODEL = 'test-content-model'; + + QUnit.module( 'mediawiki.messagePoster', QUnit.newMwEnvironment( { + teardown: function () { + mw.messagePoster.factory.unregister( TEST_MODEL ); + } + } ) ); + + QUnit.test( 'register', function ( assert ) { + var testMessagePosterConstructor = function () {}; + + mw.messagePoster.factory.register( TEST_MODEL, testMessagePosterConstructor ); + assert.strictEqual( + mw.messagePoster.factory.contentModelToClass[ TEST_MODEL ], + testMessagePosterConstructor, + 'Constructor is registered' + ); + + assert.throws( + function () { + mw.messagePoster.factory.register( TEST_MODEL, testMessagePosterConstructor ); + }, + new RegExp( 'Content model "' + TEST_MODEL + '" is already registered' ), + 'Throws exception is same model is registered a second time' + ); + } ); +}( mediaWiki ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.requestIdleCallback.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.requestIdleCallback.test.js new file mode 100644 index 00000000..df02693b --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.requestIdleCallback.test.js @@ -0,0 +1,108 @@ +( function ( mw ) { + QUnit.module( 'mediawiki.requestIdleCallback', QUnit.newMwEnvironment( { + setup: function () { + var clock = this.clock = this.sandbox.useFakeTimers(); + + this.sandbox.stub( mw, 'now', function () { + return +new Date(); + } ); + + this.tick = function ( forward ) { + return clock.tick( forward || 1 ); + }; + + // Always test the polyfill, not native + this.sandbox.stub( mw, 'requestIdleCallback', mw.requestIdleCallbackInternal ); + } + } ) ); + + QUnit.test( 'callback', function ( assert ) { + var sequence; + + mw.requestIdleCallback( function () { + sequence.push( 'x' ); + } ); + mw.requestIdleCallback( function () { + sequence.push( 'y' ); + } ); + mw.requestIdleCallback( function () { + sequence.push( 'z' ); + } ); + + sequence = []; + this.tick(); + assert.deepEqual( sequence, [ 'x', 'y', 'z' ] ); + } ); + + QUnit.test( 'nested', function ( assert ) { + var sequence; + + mw.requestIdleCallback( function () { + sequence.push( 'x' ); + } ); + // Task Y is a task that schedules another task. + mw.requestIdleCallback( function () { + function other() { + sequence.push( 'y' ); + } + mw.requestIdleCallback( other ); + } ); + mw.requestIdleCallback( function () { + sequence.push( 'z' ); + } ); + + sequence = []; + this.tick(); + assert.deepEqual( sequence, [ 'x', 'z' ] ); + + sequence = []; + this.tick(); + assert.deepEqual( sequence, [ 'y' ] ); + } ); + + QUnit.test( 'timeRemaining', function ( assert ) { + var sequence, + tick = this.tick, + jobs = [ + { time: 10, key: 'a' }, + { time: 20, key: 'b' }, + { time: 10, key: 'c' }, + { time: 20, key: 'd' }, + { time: 10, key: 'e' } + ]; + + mw.requestIdleCallback( function doWork( deadline ) { + var job; + while ( jobs[ 0 ] && deadline.timeRemaining() > 15 ) { + job = jobs.shift(); + tick( job.time ); + sequence.push( job.key ); + } + if ( jobs[ 0 ] ) { + mw.requestIdleCallback( doWork ); + } + } ); + + sequence = []; + tick(); + assert.deepEqual( sequence, [ 'a', 'b', 'c' ] ); + + sequence = []; + tick(); + assert.deepEqual( sequence, [ 'd', 'e' ] ); + } ); + + if ( window.requestIdleCallback ) { + QUnit.test( 'native', function ( assert ) { + var done = assert.async(); + // Remove polyfill and clock stub + mw.requestIdleCallback.restore(); + this.clock.restore(); + mw.requestIdleCallback( function () { + assert.expect( 0 ); + done(); + } ); + } ); + } + +}( mediaWiki ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.storage.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.storage.test.js new file mode 100644 index 00000000..436cb2ed --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.storage.test.js @@ -0,0 +1,56 @@ +( function ( mw ) { + QUnit.module( 'mediawiki.storage' ); + + QUnit.test( 'set/get with storage support', function ( assert ) { + var stub = { + setItem: this.sandbox.spy(), + getItem: this.sandbox.stub() + }; + stub.getItem.withArgs( 'foo' ).returns( 'test' ); + stub.getItem.returns( null ); + this.sandbox.stub( mw.storage, 'store', stub ); + + mw.storage.set( 'foo', 'test' ); + assert.ok( stub.setItem.calledOnce ); + + assert.strictEqual( mw.storage.get( 'foo' ), 'test', 'Check value gets stored.' ); + assert.strictEqual( mw.storage.get( 'bar' ), null, 'Unset values are null.' ); + } ); + + QUnit.test( 'set/get with storage methods disabled', function ( assert ) { + // This covers browsers where storage is disabled + // (quota full, or security/privacy settings). + // On most browsers, these interface will be accessible with + // their methods throwing. + var stub = { + getItem: this.sandbox.stub(), + removeItem: this.sandbox.stub(), + setItem: this.sandbox.stub() + }; + stub.getItem.throws(); + stub.setItem.throws(); + stub.removeItem.throws(); + this.sandbox.stub( mw.storage, 'store', stub ); + + assert.strictEqual( mw.storage.get( 'foo' ), false ); + assert.strictEqual( mw.storage.set( 'foo', 'test' ), false ); + assert.strictEqual( mw.storage.remove( 'foo', 'test' ), false ); + } ); + + QUnit.test( 'set/get with storage object disabled', function ( assert ) { + // On other browsers, these entire object is disabled. + // `'localStorage' in window` would be true (and pass feature test) + // but trying to read the object as window.localStorage would throw + // an exception. Such case would instantiate SafeStorage with + // undefined after the internal try/catch. + var old = mw.storage.store; + mw.storage.store = undefined; + + assert.strictEqual( mw.storage.get( 'foo' ), false ); + assert.strictEqual( mw.storage.set( 'foo', 'test' ), false ); + assert.strictEqual( mw.storage.remove( 'foo', 'test' ), false ); + + mw.storage.store = old; + } ); + +}( mediaWiki ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.template.mustache.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.template.mustache.test.js new file mode 100644 index 00000000..cb583e7a --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.template.mustache.test.js @@ -0,0 +1,32 @@ +( function ( mw ) { + + QUnit.module( 'mediawiki.template.mustache', { + setup: function () { + // Stub register some templates + this.sandbox.stub( mw.templates, 'get' ).returns( { + 'test_greeting.mustache': '<div>{{foo}}{{>suffix}}</div>', + 'test_greeting_suffix.mustache': ' goodbye' + } ); + } + } ); + + QUnit.test( 'render', function ( assert ) { + var html, htmlPartial, data, partials, + template = mw.template.get( 'stub', 'test_greeting.mustache' ), + partial = mw.template.get( 'stub', 'test_greeting_suffix.mustache' ); + + data = { + foo: 'Hello' + }; + partials = { + suffix: partial + }; + + html = template.render( data ).html(); + htmlPartial = template.render( data, partials ).html(); + + assert.strictEqual( html, 'Hello', 'Render without partial' ); + assert.strictEqual( htmlPartial, 'Hello goodbye', 'Render with partial' ); + } ); + +}( mediaWiki ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.template.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.template.test.js new file mode 100644 index 00000000..a2823253 --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.template.test.js @@ -0,0 +1,63 @@ +( function ( mw ) { + + QUnit.module( 'mediawiki.template', { + setup: function () { + var abcCompiler = { + compile: function () { + return 'abc default compiler'; + } + }; + + // Register some template compiler languages + mw.template.registerCompiler( 'abc', abcCompiler ); + mw.template.registerCompiler( 'xyz', { + compile: function () { + return 'xyz compiler'; + } + } ); + + // Stub register some templates + this.sandbox.stub( mw.templates, 'get' ).returns( { + 'test_templates_foo.xyz': 'goodbye', + 'test_templates_foo.abc': 'thankyou' + } ); + } + } ); + + QUnit.test( 'add', function ( assert ) { + assert.throws( + function () { + mw.template.add( 'module', 'test_templates_foo', 'hello' ); + }, + 'When no prefix throw exception' + ); + } ); + + QUnit.test( 'compile', function ( assert ) { + assert.throws( + function () { + mw.template.compile( '{{foo}}', 'rainbow' ); + }, + 'Unknown compiler names throw exceptions' + ); + } ); + + QUnit.test( 'get', function ( assert ) { + assert.strictEqual( mw.template.get( 'test.mediawiki.template', 'test_templates_foo.xyz' ), 'xyz compiler' ); + assert.strictEqual( mw.template.get( 'test.mediawiki.template', 'test_templates_foo.abc' ), 'abc default compiler' ); + assert.throws( + function () { + mw.template.get( 'this.should.not.exist', 'hello' ); + }, + 'When bad module name given throw error.' + ); + + assert.throws( + function () { + mw.template.get( 'mediawiki.template', 'hello' ); + }, + 'The template hello should not exist in the mediawiki.templates module and should throw an exception.' + ); + } ); + +}( mediaWiki ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.test.js new file mode 100644 index 00000000..119222a6 --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.test.js @@ -0,0 +1,447 @@ +( function ( mw ) { + var specialCharactersPageName, + // Can't mock SITENAME since jqueryMsg caches it at load + siteName = mw.config.get( 'wgSiteName' ); + + // Since QUnitTestResources.php loads both mediawiki and mediawiki.jqueryMsg as + // dependencies, this only tests the monkey-patched behavior with the two of them combined. + + // See mediawiki.jqueryMsg.test.js for unit tests for jqueryMsg-specific functionality. + + QUnit.module( 'mediawiki', QUnit.newMwEnvironment( { + setup: function () { + specialCharactersPageName = '"Who" wants to be a millionaire & live on \'Exotic Island\'?'; + }, + config: { + wgArticlePath: '/wiki/$1', + + // For formatnum tests + wgUserLanguage: 'en' + }, + // Messages used in multiple tests + messages: { + 'other-message': 'Other Message', + 'mediawiki-test-pagetriage-del-talk-page-notify-summary': 'Notifying author of deletion nomination for [[$1]]', + 'gender-plural-msg': '{{GENDER:$1|he|she|they}} {{PLURAL:$2|is|are}} awesome', + 'grammar-msg': 'Przeszukaj {{GRAMMAR:grammar_case_foo|{{SITENAME}}}}', + 'formatnum-msg': '{{formatnum:$1}}', + 'int-msg': 'Some {{int:other-message}}', + 'mediawiki-test-version-entrypoints-index-php': '[https://www.mediawiki.org/wiki/Manual:index.php index.php]', + 'external-link-replace': 'Foo [$1 bar]' + } + } ) ); + + QUnit.test( 'Initial check', function ( assert ) { + assert.ok( window.jQuery, 'jQuery defined' ); + assert.ok( window.$, '$ defined' ); + assert.strictEqual( window.$, window.jQuery, '$ alias to jQuery' ); + + this.suppressWarnings(); + assert.ok( window.$j, '$j defined' ); + assert.strictEqual( window.$j, window.jQuery, '$j alias to jQuery' ); + this.restoreWarnings(); + + // window.mw and window.mediaWiki are not deprecated, but for some reason + // PhantomJS is triggerring the accessors on all mw.* properties in this test, + // and with that lots of unrelated deprecation notices. + this.suppressWarnings(); + assert.ok( window.mediaWiki, 'mediaWiki defined' ); + assert.ok( window.mw, 'mw defined' ); + assert.strictEqual( window.mw, window.mediaWiki, 'mw alias to mediaWiki' ); + this.restoreWarnings(); + } ); + + QUnit.test( 'mw.format', function ( assert ) { + assert.equal( + mw.format( 'Format $1 $2', 'foo', 'bar' ), + 'Format foo bar', + 'Simple parameters' + ); + assert.equal( + mw.format( 'Format $1 $2' ), + 'Format $1 $2', + 'Missing parameters' + ); + } ); + + QUnit.test( 'mw.now', function ( assert ) { + assert.equal( typeof mw.now(), 'number', 'Return a number' ); + assert.equal( + String( Math.round( mw.now() ) ).length, + String( +new Date() ).length, + 'Match size of current timestamp' + ); + } ); + + QUnit.test( 'mw.Map', function ( assert ) { + var arry, conf, funky, globalConf, nummy, someValues; + + conf = new mw.Map(); + + // Dummy variables + funky = function () {}; + arry = []; + nummy = 7; + + // Single get and set + + assert.strictEqual( conf.set( 'foo', 'Bar' ), true, 'Map.set returns boolean true if a value was set for a valid key string' ); + assert.equal( conf.get( 'foo' ), 'Bar', 'Map.get returns a single value value correctly' ); + + assert.strictEqual( conf.get( 'example' ), null, 'Map.get returns null if selection was a string and the key was not found' ); + assert.strictEqual( conf.get( 'example', arry ), arry, 'Map.get returns fallback by reference if the key was not found' ); + assert.strictEqual( conf.get( 'example', undefined ), undefined, 'Map.get supports `undefined` as fallback instead of `null`' ); + + assert.strictEqual( conf.get( 'constructor' ), null, 'Map.get does not look at Object.prototype of internal storage (constructor)' ); + assert.strictEqual( conf.get( 'hasOwnProperty' ), null, 'Map.get does not look at Object.prototype of internal storage (hasOwnProperty)' ); + + conf.set( 'hasOwnProperty', function () { return true; } ); + assert.strictEqual( conf.get( 'example', 'missing' ), 'missing', 'Map.get uses neutral hasOwnProperty method (positive)' ); + + conf.set( 'example', 'Foo' ); + conf.set( 'hasOwnProperty', function () { return false; } ); + assert.strictEqual( conf.get( 'example' ), 'Foo', 'Map.get uses neutral hasOwnProperty method (negative)' ); + + assert.strictEqual( conf.set( 'constructor', 42 ), true, 'Map.set for key "constructor"' ); + assert.strictEqual( conf.get( 'constructor' ), 42, 'Map.get for key "constructor"' ); + + assert.strictEqual( conf.set( 'undef' ), false, 'Map.set requires explicit value (no undefined default)' ); + + assert.strictEqual( conf.set( 'undef', undefined ), true, 'Map.set allows setting value to `undefined`' ); + assert.equal( conf.get( 'undef', 'fallback' ), undefined, 'Map.get supports retreiving value of `undefined`' ); + + assert.strictEqual( conf.set( funky, 'Funky' ), false, 'Map.set returns boolean false if key was invalid (Function)' ); + assert.strictEqual( conf.set( arry, 'Arry' ), false, 'Map.set returns boolean false if key was invalid (Array)' ); + assert.strictEqual( conf.set( nummy, 'Nummy' ), false, 'Map.set returns boolean false if key was invalid (Number)' ); + + conf.set( String( nummy ), 'I used to be a number' ); + + assert.strictEqual( conf.get( funky ), null, 'Map.get returns null if selection was invalid (Function)' ); + assert.strictEqual( conf.get( nummy ), null, 'Map.get returns null if selection was invalid (Number)' ); + assert.propEqual( conf.get( [ nummy ] ), {}, 'Map.get returns null if selection was invalid (multiple)' ); + assert.strictEqual( conf.get( nummy, false ), false, 'Map.get returns custom fallback for invalid selection' ); + + assert.strictEqual( conf.exists( 'doesNotExist' ), false, 'Map.exists where property does not exist' ); + assert.strictEqual( conf.exists( 'undef' ), true, 'Map.exists where value is `undefined`' ); + assert.strictEqual( conf.exists( [ 'undef', 'example' ] ), true, 'Map.exists with multiple keys (all existing)' ); + assert.strictEqual( conf.exists( [ 'example', 'doesNotExist' ] ), false, 'Map.exists with multiple keys (some non-existing)' ); + assert.strictEqual( conf.exists( [] ), true, 'Map.exists with no keys' ); + assert.strictEqual( conf.exists( nummy ), false, 'Map.exists with invalid key that looks like an existing key' ); + assert.strictEqual( conf.exists( [ nummy ] ), false, 'Map.exists with invalid key that looks like an existing key' ); + + // Multiple values at once + conf = new mw.Map(); + someValues = { + foo: 'bar', + lorem: 'ipsum', + MediaWiki: true + }; + assert.strictEqual( conf.set( someValues ), true, 'Map.set returns boolean true if multiple values were set by passing an object' ); + assert.deepEqual( conf.get( [ 'foo', 'lorem' ] ), { + foo: 'bar', + lorem: 'ipsum' + }, 'Map.get returns multiple values correctly as an object' ); + + assert.deepEqual( conf.get( [ 'foo', 'notExist' ] ), { + foo: 'bar', + notExist: null + }, 'Map.get return includes keys that were not found as null values' ); + + assert.propEqual( conf.values, someValues, 'Map.values is an internal object with all values (exposed for convenience)' ); + assert.propEqual( conf.get(), someValues, 'Map.get() returns an object with all values' ); + + // Interacting with globals + conf.set( 'globalMapChecker', 'Hi' ); + + assert.ok( ( 'globalMapChecker' in window ) === false, 'Map does not its store values in the window object by default' ); + + globalConf = new mw.Map( true ); + globalConf.set( 'anotherGlobalMapChecker', 'Hello' ); + + assert.ok( 'anotherGlobalMapChecker' in window, 'global Map stores its values in the window object' ); + + assert.equal( globalConf.get( 'anotherGlobalMapChecker' ), 'Hello', 'get value from global Map via get()' ); + this.suppressWarnings(); + assert.equal( window.anotherGlobalMapChecker, 'Hello', 'get value from global Map via window object' ); + this.restoreWarnings(); + + // Change value via global Map + globalConf.set( 'anotherGlobalMapChecker', 'Again' ); + assert.equal( globalConf.get( 'anotherGlobalMapChecker' ), 'Again', 'Change in global Map reflected via get()' ); + this.suppressWarnings(); + assert.equal( window.anotherGlobalMapChecker, 'Again', 'Change in global Map reflected window object' ); + this.restoreWarnings(); + + // Change value via window object + this.suppressWarnings(); + window.anotherGlobalMapChecker = 'World'; + assert.equal( window.anotherGlobalMapChecker, 'World', 'Change in window object works' ); + this.restoreWarnings(); + assert.equal( globalConf.get( 'anotherGlobalMapChecker' ), 'Again', 'Change in window object not reflected in global Map' ); + + // Whitelist this global variable for QUnit's 'noglobal' mode + if ( QUnit.config.noglobals ) { + QUnit.config.pollution.push( 'anotherGlobalMapChecker' ); + } + } ); + + QUnit.test( 'mw.message & mw.messages', function ( assert ) { + var goodbye, hello; + + // Convenience method for asserting the same result for multiple formats + function assertMultipleFormats( messageArguments, formats, expectedResult, assertMessage ) { + var format, i, + len = formats.length; + + for ( i = 0; i < len; i++ ) { + format = formats[ i ]; + assert.equal( mw.message.apply( null, messageArguments )[ format ](), expectedResult, assertMessage + ' when format is ' + format ); + } + } + + assert.ok( mw.messages, 'messages defined' ); + assert.ok( mw.messages.set( 'hello', 'Hello <b>awesome</b> world' ), 'mw.messages.set: Register' ); + + hello = mw.message( 'hello' ); + + // https://phabricator.wikimedia.org/T46459 + assert.equal( hello.format, 'text', 'Message property "format" defaults to "text"' ); + + assert.strictEqual( hello.map, mw.messages, 'Message property "map" defaults to the global instance in mw.messages' ); + assert.equal( hello.key, 'hello', 'Message property "key" (currect key)' ); + assert.deepEqual( hello.parameters, [], 'Message property "parameters" defaults to an empty array' ); + + // TODO + assert.ok( hello.params, 'Message prototype "params"' ); + + hello.format = 'plain'; + assert.equal( hello.toString(), 'Hello <b>awesome</b> world', 'Message.toString returns the message as a string with the current "format"' ); + + assert.equal( hello.escaped(), 'Hello <b>awesome</b> world', 'Message.escaped returns the escaped message' ); + assert.equal( hello.format, 'escaped', 'Message.escaped correctly updated the "format" property' ); + + assert.ok( mw.messages.set( 'multiple-curly-brace', '"{{SITENAME}}" is the home of {{int:other-message}}' ), 'mw.messages.set: Register' ); + assertMultipleFormats( [ 'multiple-curly-brace' ], [ 'text', 'parse' ], '"' + siteName + '" is the home of Other Message', 'Curly brace format works correctly' ); + assert.equal( mw.message( 'multiple-curly-brace' ).plain(), mw.messages.get( 'multiple-curly-brace' ), 'Plain format works correctly for curly brace message' ); + assert.equal( mw.message( 'multiple-curly-brace' ).escaped(), mw.html.escape( '"' + siteName + '" is the home of Other Message' ), 'Escaped format works correctly for curly brace message' ); + + assert.ok( mw.messages.set( 'multiple-square-brackets-and-ampersand', 'Visit the [[Project:Community portal|community portal]] & [[Project:Help desk|help desk]]' ), 'mw.messages.set: Register' ); + assertMultipleFormats( [ 'multiple-square-brackets-and-ampersand' ], [ 'plain', 'text' ], mw.messages.get( 'multiple-square-brackets-and-ampersand' ), 'Square bracket message is not processed' ); + assert.equal( mw.message( 'multiple-square-brackets-and-ampersand' ).escaped(), 'Visit the [[Project:Community portal|community portal]] & [[Project:Help desk|help desk]]', 'Escaped format works correctly for square bracket message' ); + assert.htmlEqual( mw.message( 'multiple-square-brackets-and-ampersand' ).parse(), 'Visit the ' + + '<a title="Project:Community portal" href="/wiki/Project:Community_portal">community portal</a>' + + ' & <a title="Project:Help desk" href="/wiki/Project:Help_desk">help desk</a>', 'Internal links work with parse' ); + + assertMultipleFormats( [ 'mediawiki-test-version-entrypoints-index-php' ], [ 'plain', 'text', 'escaped' ], mw.messages.get( 'mediawiki-test-version-entrypoints-index-php' ), 'External link markup is unprocessed' ); + assert.htmlEqual( mw.message( 'mediawiki-test-version-entrypoints-index-php' ).parse(), '<a href="https://www.mediawiki.org/wiki/Manual:index.php">index.php</a>', 'External link works correctly in parse mode' ); + + assertMultipleFormats( [ 'external-link-replace', 'http://example.org/?x=y&z' ], [ 'plain', 'text' ], 'Foo [http://example.org/?x=y&z bar]', 'Parameters are substituted but external link is not processed' ); + assert.equal( mw.message( 'external-link-replace', 'http://example.org/?x=y&z' ).escaped(), 'Foo [http://example.org/?x=y&z bar]', 'In escaped mode, parameters are substituted and ampersand is escaped, but external link is not processed' ); + assert.htmlEqual( mw.message( 'external-link-replace', 'http://example.org/?x=y&z' ).parse(), 'Foo <a href="http://example.org/?x=y&z">bar</a>', 'External link with replacement works in parse mode without double-escaping' ); + + hello.parse(); + assert.equal( hello.format, 'parse', 'Message.parse correctly updated the "format" property' ); + + hello.plain(); + assert.equal( hello.format, 'plain', 'Message.plain correctly updated the "format" property' ); + + hello.text(); + assert.equal( hello.format, 'text', 'Message.text correctly updated the "format" property' ); + + assert.strictEqual( hello.exists(), true, 'Message.exists returns true for existing messages' ); + + goodbye = mw.message( 'goodbye' ); + assert.strictEqual( goodbye.exists(), false, 'Message.exists returns false for nonexistent messages' ); + + assertMultipleFormats( [ 'good<>bye' ], [ 'plain', 'text', 'parse', 'escaped' ], '⧼good<>bye⧽', 'Message.toString returns ⧼key⧽ if key does not exist' ); + + assert.ok( mw.messages.set( 'plural-test-msg', 'There {{PLURAL:$1|is|are}} $1 {{PLURAL:$1|result|results}}' ), 'mw.messages.set: Register' ); + assertMultipleFormats( [ 'plural-test-msg', 6 ], [ 'text', 'parse', 'escaped' ], 'There are 6 results', 'plural get resolved' ); + assert.equal( mw.message( 'plural-test-msg', 6 ).plain(), 'There {{PLURAL:6|is|are}} 6 {{PLURAL:6|result|results}}', 'Parameter is substituted but plural is not resolved in plain' ); + + assert.ok( mw.messages.set( 'plural-test-msg-explicit', 'There {{plural:$1|is one car|are $1 cars|0=are no cars|12=are a dozen cars}}' ), 'mw.messages.set: Register message with explicit plural forms' ); + assertMultipleFormats( [ 'plural-test-msg-explicit', 12 ], [ 'text', 'parse', 'escaped' ], 'There are a dozen cars', 'explicit plural get resolved' ); + + assert.ok( mw.messages.set( 'plural-test-msg-explicit-beginning', 'Basket has {{plural:$1|0=no eggs|12=a dozen eggs|6=half a dozen eggs|one egg|$1 eggs}}' ), 'mw.messages.set: Register message with explicit plural forms' ); + assertMultipleFormats( [ 'plural-test-msg-explicit-beginning', 1 ], [ 'text', 'parse', 'escaped' ], 'Basket has one egg', 'explicit plural given at beginning get resolved for singular' ); + assertMultipleFormats( [ 'plural-test-msg-explicit-beginning', 4 ], [ 'text', 'parse', 'escaped' ], 'Basket has 4 eggs', 'explicit plural given at beginning get resolved for plural' ); + assertMultipleFormats( [ 'plural-test-msg-explicit-beginning', 6 ], [ 'text', 'parse', 'escaped' ], 'Basket has half a dozen eggs', 'explicit plural given at beginning get resolved for 6' ); + assertMultipleFormats( [ 'plural-test-msg-explicit-beginning', 0 ], [ 'text', 'parse', 'escaped' ], 'Basket has no eggs', 'explicit plural given at beginning get resolved for 0' ); + + assertMultipleFormats( [ 'mediawiki-test-pagetriage-del-talk-page-notify-summary' ], [ 'plain', 'text' ], mw.messages.get( 'mediawiki-test-pagetriage-del-talk-page-notify-summary' ), 'Double square brackets with no parameters unchanged' ); + + assertMultipleFormats( [ 'mediawiki-test-pagetriage-del-talk-page-notify-summary', specialCharactersPageName ], [ 'plain', 'text' ], 'Notifying author of deletion nomination for [[' + specialCharactersPageName + ']]', 'Double square brackets with one parameter' ); + + assert.equal( mw.message( 'mediawiki-test-pagetriage-del-talk-page-notify-summary', specialCharactersPageName ).escaped(), 'Notifying author of deletion nomination for [[' + mw.html.escape( specialCharactersPageName ) + ']]', 'Double square brackets with one parameter, when escaped' ); + + assert.ok( mw.messages.set( 'mediawiki-test-categorytree-collapse-bullet', '[<b>−</b>]' ), 'mw.messages.set: Register' ); + assert.equal( mw.message( 'mediawiki-test-categorytree-collapse-bullet' ).plain(), mw.messages.get( 'mediawiki-test-categorytree-collapse-bullet' ), 'Single square brackets unchanged in plain mode' ); + + assert.ok( mw.messages.set( 'mediawiki-test-wikieditor-toolbar-help-content-signature-result', '<a href=\'#\' title=\'{{#special:mypage}}\'>Username</a> (<a href=\'#\' title=\'{{#special:mytalk}}\'>talk</a>)' ), 'mw.messages.set: Register' ); + assert.equal( mw.message( 'mediawiki-test-wikieditor-toolbar-help-content-signature-result' ).plain(), mw.messages.get( 'mediawiki-test-wikieditor-toolbar-help-content-signature-result' ), 'HTML message with curly braces is not changed in plain mode' ); + + assertMultipleFormats( [ 'gender-plural-msg', 'male', 1 ], [ 'text', 'parse', 'escaped' ], 'he is awesome', 'Gender and plural are resolved' ); + assert.equal( mw.message( 'gender-plural-msg', 'male', 1 ).plain(), '{{GENDER:male|he|she|they}} {{PLURAL:1|is|are}} awesome', 'Parameters are substituted, but gender and plural are not resolved in plain mode' ); + + assert.equal( mw.message( 'grammar-msg' ).plain(), mw.messages.get( 'grammar-msg' ), 'Grammar is not resolved in plain mode' ); + assertMultipleFormats( [ 'grammar-msg' ], [ 'text', 'parse' ], 'Przeszukaj ' + siteName, 'Grammar is resolved' ); + assert.equal( mw.message( 'grammar-msg' ).escaped(), 'Przeszukaj ' + siteName, 'Grammar is resolved in escaped mode' ); + + assertMultipleFormats( [ 'formatnum-msg', '987654321.654321' ], [ 'text', 'parse', 'escaped' ], '987,654,321.654', 'formatnum is resolved' ); + assert.equal( mw.message( 'formatnum-msg' ).plain(), mw.messages.get( 'formatnum-msg' ), 'formatnum is not resolved in plain mode' ); + + assertMultipleFormats( [ 'int-msg' ], [ 'text', 'parse', 'escaped' ], 'Some Other Message', 'int is resolved' ); + assert.equal( mw.message( 'int-msg' ).plain(), mw.messages.get( 'int-msg' ), 'int is not resolved in plain mode' ); + + assert.ok( mw.messages.set( 'mediawiki-italics-msg', '<i>Very</i> important' ), 'mw.messages.set: Register' ); + assertMultipleFormats( [ 'mediawiki-italics-msg' ], [ 'plain', 'text', 'parse' ], mw.messages.get( 'mediawiki-italics-msg' ), 'Simple italics unchanged' ); + assert.htmlEqual( + mw.message( 'mediawiki-italics-msg' ).escaped(), + '<i>Very</i> important', + 'Italics are escaped in escaped mode' + ); + + assert.ok( mw.messages.set( 'mediawiki-italics-with-link', 'An <i>italicized [[link|wiki-link]]</i>' ), 'mw.messages.set: Register' ); + assertMultipleFormats( [ 'mediawiki-italics-with-link' ], [ 'plain', 'text' ], mw.messages.get( 'mediawiki-italics-with-link' ), 'Italics with link unchanged' ); + assert.htmlEqual( + mw.message( 'mediawiki-italics-with-link' ).escaped(), + 'An <i>italicized [[link|wiki-link]]</i>', + 'Italics and link unchanged except for escaping in escaped mode' + ); + assert.htmlEqual( + mw.message( 'mediawiki-italics-with-link' ).parse(), + 'An <i>italicized <a title="link" href="' + mw.util.getUrl( 'link' ) + '">wiki-link</i>', + 'Italics with link inside in parse mode' + ); + + assert.ok( mw.messages.set( 'mediawiki-script-msg', '<script >alert( "Who put this script here?" );</script>' ), 'mw.messages.set: Register' ); + assertMultipleFormats( [ 'mediawiki-script-msg' ], [ 'plain', 'text' ], mw.messages.get( 'mediawiki-script-msg' ), 'Script unchanged' ); + assert.htmlEqual( + mw.message( 'mediawiki-script-msg' ).escaped(), + '<script >alert( "Who put this script here?" );</script>', + 'Script escaped when using escaped format' + ); + assert.htmlEqual( + mw.message( 'mediawiki-script-msg' ).parse(), + '<script >alert( "Who put this script here?" );</script>', + 'Script escaped when using parse format' + ); + + } ); + + QUnit.test( 'mw.msg', function ( assert ) { + assert.ok( mw.messages.set( 'hello', 'Hello <b>awesome</b> world' ), 'mw.messages.set: Register' ); + assert.equal( mw.msg( 'hello' ), 'Hello <b>awesome</b> world', 'Gets message with default options (existing message)' ); + assert.equal( mw.msg( 'goodbye' ), '⧼goodbye⧽', 'Gets message with default options (nonexistent message)' ); + + assert.ok( mw.messages.set( 'plural-item', 'Found $1 {{PLURAL:$1|item|items}}' ), 'mw.messages.set: Register' ); + assert.equal( mw.msg( 'plural-item', 5 ), 'Found 5 items', 'Apply plural for count 5' ); + assert.equal( mw.msg( 'plural-item', 0 ), 'Found 0 items', 'Apply plural for count 0' ); + assert.equal( mw.msg( 'plural-item', 1 ), 'Found 1 item', 'Apply plural for count 1' ); + + assert.equal( mw.msg( 'mediawiki-test-pagetriage-del-talk-page-notify-summary', specialCharactersPageName ), 'Notifying author of deletion nomination for [[' + specialCharactersPageName + ']]', 'Double square brackets in mw.msg one parameter' ); + + assert.equal( mw.msg( 'gender-plural-msg', 'male', 1 ), 'he is awesome', 'Gender test for male, plural count 1' ); + assert.equal( mw.msg( 'gender-plural-msg', 'female', '1' ), 'she is awesome', 'Gender test for female, plural count 1' ); + assert.equal( mw.msg( 'gender-plural-msg', 'unknown', 10 ), 'they are awesome', 'Gender test for neutral, plural count 10' ); + + assert.equal( mw.msg( 'grammar-msg' ), 'Przeszukaj ' + siteName, 'Grammar is resolved' ); + + assert.equal( mw.msg( 'formatnum-msg', '987654321.654321' ), '987,654,321.654', 'formatnum is resolved' ); + + assert.equal( mw.msg( 'int-msg' ), 'Some Other Message', 'int is resolved' ); + } ); + + QUnit.test( 'mw.hook', function ( assert ) { + var hook, add, fire, chars, callback; + + mw.hook( 'test.hook.unfired' ).add( function () { + assert.ok( false, 'Unfired hook' ); + } ); + + mw.hook( 'test.hook.basic' ).add( function () { + assert.ok( true, 'Basic callback' ); + } ); + mw.hook( 'test.hook.basic' ).fire(); + + mw.hook( 'hasOwnProperty' ).add( function () { + assert.ok( true, 'hook with name of predefined method' ); + } ); + mw.hook( 'hasOwnProperty' ).fire(); + + mw.hook( 'test.hook.data' ).add( function ( data1, data2 ) { + assert.equal( data1, 'example', 'Fire with data (string param)' ); + assert.deepEqual( data2, [ 'two' ], 'Fire with data (array param)' ); + } ); + mw.hook( 'test.hook.data' ).fire( 'example', [ 'two' ] ); + + hook = mw.hook( 'test.hook.chainable' ); + assert.strictEqual( hook.add(), hook, 'hook.add is chainable' ); + assert.strictEqual( hook.remove(), hook, 'hook.remove is chainable' ); + assert.strictEqual( hook.fire(), hook, 'hook.fire is chainable' ); + + hook = mw.hook( 'test.hook.detach' ); + add = hook.add; + fire = hook.fire; + add( function ( x, y ) { + assert.deepEqual( [ x, y ], [ 'x', 'y' ], 'Detached (contextless) with data' ); + } ); + fire( 'x', 'y' ); + + mw.hook( 'test.hook.fireBefore' ).fire().add( function () { + assert.ok( true, 'Invoke handler right away if it was fired before' ); + } ); + + mw.hook( 'test.hook.fireTwiceBefore' ).fire().fire().add( function () { + assert.ok( true, 'Invoke handler right away if it was fired before (only last one)' ); + } ); + + chars = []; + + mw.hook( 'test.hook.many' ) + .add( function ( chr ) { + chars.push( chr ); + } ) + .fire( 'x' ).fire( 'y' ).fire( 'z' ) + .add( function ( chr ) { + assert.equal( chr, 'z', 'Adding callback later invokes right away with last data' ); + } ); + + assert.deepEqual( chars, [ 'x', 'y', 'z' ], 'Multiple callbacks with multiple fires' ); + + chars = []; + callback = function ( chr ) { + chars.push( chr ); + }; + + mw.hook( 'test.hook.variadic' ) + .add( + callback, + callback, + function ( chr ) { + chars.push( chr ); + }, + callback + ) + .fire( 'x' ) + .remove( + function () { + 'not-added'; + }, + callback + ) + .fire( 'y' ) + .remove( callback ) + .fire( 'z' ); + + assert.deepEqual( + chars, + [ 'x', 'x', 'x', 'x', 'y', 'z' ], + '"add" and "remove" support variadic arguments. ' + + '"add" does not filter unique. ' + + '"remove" removes all equal by reference. ' + + '"remove" is silent if the function is not found' + ); + } ); + +}( mediaWiki ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.toc.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.toc.test.js new file mode 100644 index 00000000..6a1b83cf --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.toc.test.js @@ -0,0 +1,39 @@ +( function ( mw, $ ) { + QUnit.module( 'mediawiki.toc', QUnit.newMwEnvironment( { + setup: function () { + // Prevent live cookies from interferring with the test + this.stub( $, 'cookie' ).returns( null ); + } + } ) ); + + QUnit.test( 'toggleToc', function ( assert ) { + var tocHtml, $toc, $toggleLink, $tocList; + + assert.strictEqual( $( '.toc' ).length, 0, 'There is no table of contents on the page at the beginning' ); + + tocHtml = '<div id="toc" class="toc">' + + '<div class="toctitle" lang="en" dir="ltr">' + + '<h2>Contents</h2>' + + '</div>' + + '<ul><li></li></ul>' + + '</div>'; + $toc = $( tocHtml ); + $( '#qunit-fixture' ).append( $toc ); + mw.hook( 'wikipage.content' ).fire( $( '#qunit-fixture' ) ); + + $tocList = $toc.find( 'ul:first' ); + $toggleLink = $toc.find( '.togglelink' ); + + assert.strictEqual( $toggleLink.length, 1, 'Toggle link is added to the table of contents' ); + + assert.strictEqual( $tocList.is( ':hidden' ), false, 'The table of contents is now visible' ); + + $toggleLink.click(); + return $tocList.promise().then( function () { + assert.strictEqual( $tocList.is( ':hidden' ), true, 'The table of contents is now hidden' ); + + $toggleLink.click(); + return $tocList.promise(); + } ); + } ); +}( mediaWiki, jQuery ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.track.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.track.test.js new file mode 100644 index 00000000..6c27c5ba --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.track.test.js @@ -0,0 +1,60 @@ +( function ( mw ) { + QUnit.module( 'mediawiki.track' ); + + QUnit.test( 'track', function ( assert ) { + var sequence = []; + mw.trackSubscribe( 'simple', function ( topic, data ) { + sequence.push( [ topic, data ] ); + } ); + mw.track( 'simple', { key: 1 } ); + mw.track( 'simple', { key: 2 } ); + + assert.deepEqual( sequence, [ + [ 'simple', { key: 1 } ], + [ 'simple', { key: 2 } ] + ], 'Events after subscribing' ); + } ); + + QUnit.test( 'trackSubscribe', function ( assert ) { + var now, + sequence = []; + mw.track( 'before', { key: 1 } ); + mw.track( 'before', { key: 2 } ); + mw.trackSubscribe( 'before', function ( topic, data ) { + sequence.push( [ topic, data ] ); + } ); + mw.track( 'before', { key: 3 } ); + + assert.deepEqual( sequence, [ + [ 'before', { key: 1 } ], + [ 'before', { key: 2 } ], + [ 'before', { key: 3 } ] + ], 'Replay events from before subscribing' ); + + now = mw.now(); + mw.track( 'context', { key: 0 } ); + mw.trackSubscribe( 'context', function ( topic, data ) { + assert.strictEqual( this.topic, topic, 'thisValue has topic' ); + assert.strictEqual( this.data, data, 'thisValue has data' ); + assert.assertTrue( this.timeStamp >= now, 'thisValue has sane timestamp' ); + } ); + } ); + + QUnit.test( 'trackUnsubscribe', function ( assert ) { + var sequence = []; + function unsubber( topic, data ) { + sequence.push( [ topic, data ] ); + } + + mw.track( 'unsub', { key: 1 } ); + mw.trackSubscribe( 'unsub', unsubber ); + mw.track( 'unsub', { key: 2 } ); + mw.trackUnsubscribe( unsubber ); + mw.track( 'unsub', { key: 3 } ); + + assert.deepEqual( sequence, [ + [ 'unsub', { key: 1 } ], + [ 'unsub', { key: 2 } ] + ], 'Stop when unsubscribing' ); + } ); +}( mediaWiki ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.user.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.user.test.js new file mode 100644 index 00000000..814a2075 --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.user.test.js @@ -0,0 +1,115 @@ +( function ( mw, $ ) { + QUnit.module( 'mediawiki.user', QUnit.newMwEnvironment( { + setup: function () { + this.server = this.sandbox.useFakeServer(); + this.crypto = window.crypto; + this.msCrypto = window.msCrypto; + }, + teardown: function () { + if ( this.crypto ) { + window.crypto = this.crypto; + } + if ( this.msCrypto ) { + window.msCrypto = this.msCrypto; + } + } + } ) ); + + QUnit.test( 'options', function ( assert ) { + assert.ok( mw.user.options instanceof mw.Map, 'options instance of mw.Map' ); + } ); + + QUnit.test( 'getters (anonymous)', function ( assert ) { + // Forge an anonymous user + mw.config.set( 'wgUserName', null ); + mw.config.set( 'wgUserId', null ); + + assert.strictEqual( mw.user.getName(), null, 'getName()' ); + assert.strictEqual( mw.user.isAnon(), true, 'isAnon()' ); + assert.strictEqual( mw.user.getId(), 0, 'getId()' ); + } ); + + QUnit.test( 'getters (logged-in)', function ( assert ) { + mw.config.set( 'wgUserName', 'John' ); + mw.config.set( 'wgUserId', 123 ); + + assert.equal( mw.user.getName(), 'John', 'getName()' ); + assert.strictEqual( mw.user.isAnon(), false, 'isAnon()' ); + assert.strictEqual( mw.user.getId(), 123, 'getId()' ); + + assert.equal( mw.user.id(), 'John', 'user.id()' ); + } ); + + QUnit.test( 'getUserInfo', function ( assert ) { + mw.config.set( 'wgUserGroups', [ '*', 'user' ] ); + + mw.user.getGroups( function ( groups ) { + assert.deepEqual( groups, [ '*', 'user' ], 'Result' ); + } ); + + mw.user.getRights( function ( rights ) { + assert.deepEqual( rights, [ 'read', 'edit', 'createtalk' ], 'Result (callback)' ); + } ); + + mw.user.getRights().done( function ( rights ) { + assert.deepEqual( rights, [ 'read', 'edit', 'createtalk' ], 'Result (promise)' ); + } ); + + this.server.respondWith( /meta=userinfo/, function ( request ) { + request.respond( 200, { 'Content-Type': 'application/json' }, + '{ "query": { "userinfo": { "groups": [ "unused" ], "rights": [ "read", "edit", "createtalk" ] } } }' + ); + } ); + + this.server.respond(); + } ); + + QUnit.test( 'generateRandomSessionId', function ( assert ) { + var result, result2; + + result = mw.user.generateRandomSessionId(); + assert.equal( typeof result, 'string', 'type' ); + assert.equal( $.trim( result ), result, 'no whitespace at beginning or end' ); + assert.equal( result.length, 16, 'size' ); + + result2 = mw.user.generateRandomSessionId(); + assert.notEqual( result, result2, 'different when called multiple times' ); + + } ); + + QUnit.test( 'generateRandomSessionId (fallback)', function ( assert ) { + var result, result2; + + // Pretend crypto API is not there to test the Math.random fallback + if ( window.crypto ) { + window.crypto = undefined; + } + if ( window.msCrypto ) { + window.msCrypto = undefined; + } + + result = mw.user.generateRandomSessionId(); + assert.equal( typeof result, 'string', 'type' ); + assert.equal( $.trim( result ), result, 'no whitespace at beginning or end' ); + assert.equal( result.length, 16, 'size' ); + + result2 = mw.user.generateRandomSessionId(); + assert.notEqual( result, result2, 'different when called multiple times' ); + } ); + + QUnit.test( 'stickyRandomId', function ( assert ) { + var result = mw.user.stickyRandomId(), + result2 = mw.user.stickyRandomId(); + assert.equal( typeof result, 'string', 'type' ); + assert.strictEqual( /^[a-f0-9]{16}$/.test( result ), true, '16 HEX symbols string' ); + assert.equal( result2, result, 'sticky' ); + } ); + + QUnit.test( 'sessionId', function ( assert ) { + var result = mw.user.sessionId(), + result2 = mw.user.sessionId(); + assert.equal( typeof result, 'string', 'type' ); + assert.equal( $.trim( result ), result, 'no leading or trailing whitespace' ); + assert.equal( result2, result, 'retained' ); + } ); +}( mediaWiki, jQuery ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.util.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.util.test.js new file mode 100644 index 00000000..b8464e99 --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.util.test.js @@ -0,0 +1,465 @@ +( function ( mw, $ ) { + var util = require( 'mediawiki.util' ), + // Based on IPTest.php > testisIPv4 + IPV4_CASES = [ + [ false, false, 'Boolean false is not an IP' ], + [ false, true, 'Boolean true is not an IP' ], + [ false, '', 'Empty string is not an IP' ], + [ false, 'abc', '"abc" is not an IP' ], + [ false, ':', 'Colon is not an IP' ], + [ false, '124.24.52', 'IPv4 not enough quads' ], + [ false, '24.324.52.13', 'IPv4 out of range' ], + [ false, '.24.52.13', 'IPv4 starts with period' ], + + [ true, '124.24.52.13', '124.24.52.134 is a valid IP' ], + [ true, '1.24.52.13', '1.24.52.13 is a valid IP' ], + [ false, '74.24.52.13/20', 'IPv4 ranges are not recognized as valid IPs' ] + ], + + // Based on IPTest.php > testisIPv6 + IPV6_CASES = [ + [ false, ':fc:100::', 'IPv6 starting with lone ":"' ], + [ false, 'fc:100:::', 'IPv6 ending with a ":::"' ], + [ false, 'fc:300', 'IPv6 with only 2 words' ], + [ false, 'fc:100:300', 'IPv6 with only 3 words' ], + + [ false, 'fc:100:a:d:1:e:ac:0::', 'IPv6 with 8 words ending with "::"' ], + [ false, 'fc:100:a:d:1:e:ac:0:1::', 'IPv6 with 9 words ending with "::"' ], + + [ false, ':::' ], + [ false, '::0:', 'IPv6 ending in a lone ":"' ], + + [ true, '::', 'IPv6 zero address' ], + + [ false, '::fc:100:a:d:1:e:ac:0', 'IPv6 with "::" and 8 words' ], + [ false, '::fc:100:a:d:1:e:ac:0:1', 'IPv6 with 9 words' ], + + [ false, ':fc::100', 'IPv6 starting with lone ":"' ], + [ false, 'fc::100:', 'IPv6 ending with lone ":"' ], + [ false, 'fc:::100', 'IPv6 with ":::" in the middle' ], + + [ true, 'fc::100', 'IPv6 with "::" and 2 words' ], + [ true, 'fc::100:a', 'IPv6 with "::" and 3 words' ], + [ true, 'fc::100:a:d', 'IPv6 with "::" and 4 words' ], + [ true, 'fc::100:a:d:1', 'IPv6 with "::" and 5 words' ], + [ true, 'fc::100:a:d:1:e', 'IPv6 with "::" and 6 words' ], + [ true, 'fc::100:a:d:1:e:ac', 'IPv6 with "::" and 7 words' ], + [ true, '2001::df', 'IPv6 with "::" and 2 words' ], + [ true, '2001:5c0:1400:a::df', 'IPv6 with "::" and 5 words' ], + [ true, '2001:5c0:1400:a::df:2', 'IPv6 with "::" and 6 words' ], + + [ false, 'fc::100:a:d:1:e:ac:0', 'IPv6 with "::" and 8 words' ], + [ false, 'fc::100:a:d:1:e:ac:0:1', 'IPv6 with 9 words' ] + ]; + + Array.prototype.push.apply( IPV6_CASES, + [ + 'fc:100::', + 'fc:100:a::', + 'fc:100:a:d::', + 'fc:100:a:d:1::', + 'fc:100:a:d:1:e::', + 'fc:100:a:d:1:e:ac::', + '::0', + '::fc', + '::fc:100', + '::fc:100:a', + '::fc:100:a:d', + '::fc:100:a:d:1', + '::fc:100:a:d:1:e', + '::fc:100:a:d:1:e:ac', + 'fc:100:a:d:1:e:ac:0' + ].map( function ( el ) { + return [ true, el, el + ' is a valid IP' ]; + } ) + ); + + QUnit.module( 'mediawiki.util', QUnit.newMwEnvironment( { + setup: function () { + $.fn.updateTooltipAccessKeys.setTestMode( true ); + }, + teardown: function () { + $.fn.updateTooltipAccessKeys.setTestMode( false ); + }, + messages: { + // Used by accessKeyLabel in test for addPortletLink + brackets: '[$1]', + 'word-separator': ' ' + } + } ) ); + + QUnit.test( 'rawurlencode', function ( assert ) { + assert.equal( util.rawurlencode( 'Test:A & B/Here' ), 'Test%3AA%20%26%20B%2FHere' ); + } ); + + QUnit.test( 'escapeId', function ( assert ) { + mw.config.set( 'wgFragmentMode', [ 'legacy' ] ); + $.each( { + '+': '.2B', + '&': '.26', + '=': '.3D', + ':': ':', + ';': '.3B', + '@': '.40', + $: '.24', + '-_.': '-_.', + '!': '.21', + '*': '.2A', + '/': '.2F', + '[]': '.5B.5D', + '<>': '.3C.3E', + '\'': '.27', + '§': '.C2.A7', + 'Test:A & B/Here': 'Test:A_.26_B.2FHere', + 'A&B&C&amp;D&amp;amp;E': 'A.26B.26amp.3BC.26amp.3Bamp.3BD.26amp.3Bamp.3Bamp.3BE' + }, function ( input, output ) { + assert.equal( util.escapeId( input ), output ); + } ); + } ); + + QUnit.test( 'escapeIdForAttribute', function ( assert ) { + // Test cases are kept in sync with SanitizerTest.php + var text = 'foo тест_#%!\'()[]:<>', + legacyEncoded = 'foo_.D1.82.D0.B5.D1.81.D1.82_.23.25.21.27.28.29.5B.5D:.3C.3E', + html5Encoded = 'foo_тест_#%!\'()[]:<>', + html5Experimental = 'foo_тест_!_()[]:<>', + // Settings: this is $wgFragmentMode + legacy = [ 'legacy' ], + legacyNew = [ 'legacy', 'html5' ], + newLegacy = [ 'html5', 'legacy' ], + allNew = [ 'html5' ], + experimentalLegacy = [ 'html5-legacy', 'legacy' ], + newExperimental = [ 'html5', 'html5-legacy' ]; + + // Test cases are kept in sync with SanitizerTest.php + [ + // Pure legacy: how MW worked before 2017 + [ legacy, text, legacyEncoded ], + // Transition to a new world: legacy links with HTML5 fallback + [ legacyNew, text, legacyEncoded ], + // New world: HTML5 links, legacy fallbacks + [ newLegacy, text, html5Encoded ], + // Distant future: no legacy fallbacks + [ allNew, text, html5Encoded ], + // Someone flipped $wgExperimentalHtmlIds on + [ experimentalLegacy, text, html5Experimental ], + // Migration from $wgExperimentalHtmlIds to modern HTML5 + [ newExperimental, text, html5Encoded ] + ].forEach( function ( testCase ) { + mw.config.set( 'wgFragmentMode', testCase[ 0 ] ); + + assert.equal( util.escapeIdForAttribute( testCase[ 1 ] ), testCase[ 2 ] ); + } ); + } ); + + QUnit.test( 'escapeIdForLink', function ( assert ) { + // Test cases are kept in sync with SanitizerTest.php + var text = 'foo тест_#%!\'()[]:<>', + legacyEncoded = 'foo_.D1.82.D0.B5.D1.81.D1.82_.23.25.21.27.28.29.5B.5D:.3C.3E', + html5Encoded = 'foo_тест_#%!\'()[]:<>', + html5Experimental = 'foo_тест_!_()[]:<>', + // Settings: this is wgFragmentMode + legacy = [ 'legacy' ], + legacyNew = [ 'legacy', 'html5' ], + newLegacy = [ 'html5', 'legacy' ], + allNew = [ 'html5' ], + experimentalLegacy = [ 'html5-legacy', 'legacy' ], + newExperimental = [ 'html5', 'html5-legacy' ]; + + [ + // Pure legacy: how MW worked before 2017 + [ legacy, text, legacyEncoded ], + // Transition to a new world: legacy links with HTML5 fallback + [ legacyNew, text, legacyEncoded ], + // New world: HTML5 links, legacy fallbacks + [ newLegacy, text, html5Encoded ], + // Distant future: no legacy fallbacks + [ allNew, text, html5Encoded ], + // Someone flipped wgExperimentalHtmlIds on + [ experimentalLegacy, text, html5Experimental ], + // Migration from wgExperimentalHtmlIds to modern HTML5 + [ newExperimental, text, html5Encoded ] + ].forEach( function ( testCase ) { + mw.config.set( 'wgFragmentMode', testCase[ 0 ] ); + + assert.equal( util.escapeIdForLink( testCase[ 1 ] ), testCase[ 2 ] ); + } ); + } ); + + QUnit.test( 'wikiUrlencode', function ( assert ) { + assert.equal( util.wikiUrlencode( 'Test:A & B/Here' ), 'Test:A_%26_B/Here' ); + // See also wfUrlencodeTest.php#provideURLS + $.each( { + '+': '%2B', + '&': '%26', + '=': '%3D', + ':': ':', + ';@$-_.!*': ';@$-_.!*', + '/': '/', + '~': '~', + '[]': '%5B%5D', + '<>': '%3C%3E', + '\'': '%27' + }, function ( input, output ) { + assert.equal( util.wikiUrlencode( input ), output ); + } ); + } ); + + QUnit.test( 'getUrl', function ( assert ) { + var href; + mw.config.set( { + wgScript: '/w/index.php', + wgArticlePath: '/wiki/$1', + wgPageName: 'Foobar' + } ); + + href = util.getUrl( 'Sandbox' ); + assert.equal( href, '/wiki/Sandbox', 'simple title' ); + + href = util.getUrl( 'Foo:Sandbox? 5+5=10! (test)/sub ' ); + assert.equal( href, '/wiki/Foo:Sandbox%3F_5%2B5%3D10!_(test)/sub_', 'complex title' ); + + // T149767 + href = util.getUrl( 'My$$test$$$$$title' ); + assert.equal( href, '/wiki/My$$test$$$$$title', 'title with multiple consecutive dollar signs' ); + + href = util.getUrl(); + assert.equal( href, '/wiki/Foobar', 'default title' ); + + href = util.getUrl( null, { action: 'edit' } ); + assert.equal( href, '/w/index.php?title=Foobar&action=edit', 'default title with query string' ); + + href = util.getUrl( 'Sandbox', { action: 'edit' } ); + assert.equal( href, '/w/index.php?title=Sandbox&action=edit', 'simple title with query string' ); + + // Test fragments + href = util.getUrl( 'Foo:Sandbox#Fragment', { action: 'edit' } ); + assert.equal( href, '/w/index.php?title=Foo:Sandbox&action=edit#Fragment', 'namespaced title with query string and fragment' ); + + href = util.getUrl( 'Sandbox#', { action: 'edit' } ); + assert.equal( href, '/w/index.php?title=Sandbox&action=edit', 'title with query string and empty fragment' ); + + href = util.getUrl( 'Sandbox', {} ); + assert.equal( href, '/wiki/Sandbox', 'title with empty query string' ); + + href = util.getUrl( '#Fragment' ); + assert.equal( href, '/wiki/#Fragment', 'empty title with fragment' ); + + href = util.getUrl( '#Fragment', { action: 'edit' } ); + assert.equal( href, '/w/index.php?action=edit#Fragment', 'empty title with query string and fragment' ); + + mw.config.set( 'wgFragmentMode', [ 'legacy' ] ); + href = util.getUrl( 'Foo:Sandbox \xC4#Fragment \xC4', { action: 'edit' } ); + assert.equal( href, '/w/index.php?title=Foo:Sandbox_%C3%84&action=edit#Fragment_.C3.84', 'title with query string, fragment, and special characters' ); + + mw.config.set( 'wgFragmentMode', [ 'html5' ] ); + href = util.getUrl( 'Foo:Sandbox \xC4#Fragment \xC4', { action: 'edit' } ); + assert.equal( href, '/w/index.php?title=Foo:Sandbox_%C3%84&action=edit#Fragment_Ä', 'title with query string, fragment, and special characters' ); + + href = util.getUrl( 'Foo:%23#Fragment', { action: 'edit' } ); + assert.equal( href, '/w/index.php?title=Foo:%2523&action=edit#Fragment', 'title containing %23 (#), fragment, and a query string' ); + + mw.config.set( 'wgFragmentMode', [ 'legacy' ] ); + href = util.getUrl( '#+&=:;@$-_.!*/[]<>\'§', { action: 'edit' } ); + assert.equal( href, '/w/index.php?action=edit#.2B.26.3D:.3B.40.24-_..21.2A.2F.5B.5D.3C.3E.27.C2.A7', 'fragment with various characters' ); + + mw.config.set( 'wgFragmentMode', [ 'html5' ] ); + href = util.getUrl( '#+&=:;@$-_.!*/[]<>\'§', { action: 'edit' } ); + assert.equal( href, '/w/index.php?action=edit#+&=:;@$-_.!*/[]<>\'§', 'fragment with various characters' ); + } ); + + QUnit.test( 'wikiScript', function ( assert ) { + mw.config.set( { + // customized wgScript for T41103 + wgScript: '/w/i.php', + // customized wgLoadScript for T41103 + wgLoadScript: '/w/l.php', + wgScriptPath: '/w' + } ); + + assert.equal( util.wikiScript(), mw.config.get( 'wgScript' ), + 'wikiScript() returns wgScript' + ); + assert.equal( util.wikiScript( 'index' ), mw.config.get( 'wgScript' ), + 'wikiScript( index ) returns wgScript' + ); + assert.equal( util.wikiScript( 'load' ), mw.config.get( 'wgLoadScript' ), + 'wikiScript( load ) returns wgLoadScript' + ); + assert.equal( util.wikiScript( 'api' ), '/w/api.php', 'API path' ); + } ); + + QUnit.test( 'addCSS', function ( assert ) { + var $el, style; + $el = $( '<div>' ).attr( 'id', 'mw-addcsstest' ).appendTo( '#qunit-fixture' ); + + style = util.addCSS( '#mw-addcsstest { visibility: hidden; }' ); + assert.equal( typeof style, 'object', 'addCSS returned an object' ); + assert.strictEqual( style.disabled, false, 'property "disabled" is available and set to false' ); + + assert.equal( $el.css( 'visibility' ), 'hidden', 'Added style properties are in effect' ); + + // Clean up + $( style.ownerNode ).remove(); + } ); + + QUnit.test( 'getParamValue', function ( assert ) { + var url; + + url = 'http://example.org/?foo=wrong&foo=right#&foo=bad'; + assert.equal( util.getParamValue( 'foo', url ), 'right', 'Use latest one, ignore hash' ); + assert.strictEqual( util.getParamValue( 'bar', url ), null, 'Return null when not found' ); + + url = 'http://example.org/#&foo=bad'; + assert.strictEqual( util.getParamValue( 'foo', url ), null, 'Ignore hash if param is not in querystring but in hash (T29427)' ); + + url = 'example.org?' + $.param( { TEST: 'a b+c' } ); + assert.strictEqual( util.getParamValue( 'TEST', url ), 'a b+c', 'T32441: getParamValue must understand "+" encoding of space' ); + + url = 'example.org?' + $.param( { TEST: 'a b+c d' } ); // check for sloppy code from r95332 :) + assert.strictEqual( util.getParamValue( 'TEST', url ), 'a b+c d', 'T32441: getParamValue must understand "+" encoding of space (multiple spaces)' ); + } ); + + QUnit.test( '$content', function ( assert ) { + assert.ok( util.$content instanceof jQuery, 'mw.util.$content instance of jQuery' ); + assert.strictEqual( util.$content.length, 1, 'mw.util.$content must have length of 1' ); + } ); + + /** + * Portlet names are prefixed with 'p-test' to avoid conflict with core + * when running the test suite under a wiki page. + * Previously, test elements where invisible to the selector since only + * one element can have a given id. + */ + QUnit.test( 'addPortletLink', function ( assert ) { + var pTestTb, pCustom, vectorTabs, tbRL, cuQuux, $cuQuux, tbMW, $tbMW, tbRLDM, caFoo, + addedAfter, tbRLDMnonexistentid, tbRLDMemptyjquery; + + pTestTb = + '<div class="portlet" id="p-test-tb">' + + '<h3>Toolbox</h3>' + + '<ul class="body"></ul>' + + '</div>'; + pCustom = + '<div class="portlet" id="p-test-custom">' + + '<h3>Views</h3>' + + '<ul class="body">' + + '<li id="c-foo"><a href="#">Foo</a></li>' + + '<li id="c-barmenu">' + + '<ul>' + + '<li id="c-bar-baz"><a href="#">Baz</a></a>' + + '</ul>' + + '</li>' + + '</ul>' + + '</div>'; + vectorTabs = + '<div id="p-test-views" class="vectorTabs">' + + '<h3>Views</h3>' + + '<ul></ul>' + + '</div>'; + + $( '#qunit-fixture' ).append( pTestTb, pCustom, vectorTabs ); + + tbRL = util.addPortletLink( 'p-test-tb', '//mediawiki.org/wiki/ResourceLoader', + 'ResourceLoader', 't-rl', 'More info about ResourceLoader on MediaWiki.org ', 'l' + ); + + assert.ok( tbRL && tbRL.nodeType, 'addPortletLink returns a DOM Node' ); + + tbMW = util.addPortletLink( 'p-test-tb', '//mediawiki.org/', + 'MediaWiki.org', 't-mworg', 'Go to MediaWiki.org', 'm', tbRL ); + $tbMW = $( tbMW ); + + assert.propEqual( + $tbMW.getAttrs(), + { + id: 't-mworg' + }, + 'Validate attributes of created element' + ); + + assert.propEqual( + $tbMW.find( 'a' ).getAttrs(), + { + href: '//mediawiki.org/', + title: 'Go to MediaWiki.org [test-m]', + accesskey: 'm' + }, + 'Validate attributes of anchor tag in created element' + ); + + assert.equal( $tbMW.closest( '.portlet' ).attr( 'id' ), 'p-test-tb', 'Link was inserted within correct portlet' ); + assert.strictEqual( $tbMW.next()[ 0 ], tbRL, 'Link is in the correct position (nextnode as Node object)' ); + + cuQuux = util.addPortletLink( 'p-test-custom', '#', 'Quux', null, 'Example [shift-x]', 'q' ); + $cuQuux = $( cuQuux ); + + assert.equal( $cuQuux.find( 'a' ).attr( 'title' ), 'Example [test-q]', 'Existing accesskey is stripped and updated' ); + + assert.equal( + $( '#p-test-custom #c-barmenu ul li' ).length, + 1, + 'addPortletLink did not add the item to all <ul> elements in the portlet (T37082)' + ); + + tbRLDM = util.addPortletLink( 'p-test-tb', '//mediawiki.org/wiki/RL/DM', + 'Default modules', 't-rldm', 'List of all default modules ', 'd', '#t-rl' ); + + assert.strictEqual( $( tbRLDM ).next()[ 0 ], tbRL, 'Link is in the correct position (CSS selector as nextnode)' ); + + caFoo = util.addPortletLink( 'p-test-views', '#', 'Foo' ); + + assert.strictEqual( $tbMW.find( 'span' ).length, 0, 'No <span> element should be added for porlets without vectorTabs class.' ); + assert.strictEqual( $( caFoo ).find( 'span' ).length, 1, 'A <span> element should be added for porlets with vectorTabs class.' ); + + addedAfter = util.addPortletLink( 'p-test-tb', '#', 'After foo', 'post-foo', 'After foo', null, $( tbRL ) ); + assert.strictEqual( $( addedAfter ).next()[ 0 ], tbRL, 'Link is in the correct position (jQuery object as nextnode)' ); + + // test case - nonexistent id as next node + tbRLDMnonexistentid = util.addPortletLink( 'p-test-tb', '//mediawiki.org/wiki/RL/DM', + 'Default modules', 't-rldm-nonexistent', 'List of all default modules ', 'd', '#t-rl-nonexistent' ); + + assert.equal( tbRLDMnonexistentid, $( '#p-test-tb li:last' )[ 0 ], 'Fallback to adding at the end (nextnode non-matching CSS selector)' ); + + // test case - empty jquery object as next node + tbRLDMemptyjquery = util.addPortletLink( 'p-test-tb', '//mediawiki.org/wiki/RL/DM', + 'Default modules', 't-rldm-empty-jquery', 'List of all default modules ', 'd', $( '#t-rl-nonexistent' ) ); + + assert.equal( tbRLDMemptyjquery, $( '#p-test-tb li:last' )[ 0 ], 'Fallback to adding at the end (nextnode as empty jQuery object)' ); + } ); + + QUnit.test( 'validateEmail', function ( assert ) { + assert.strictEqual( util.validateEmail( '' ), null, 'Should return null for empty string ' ); + assert.strictEqual( util.validateEmail( 'user@localhost' ), true, 'Return true for a valid e-mail address' ); + + // testEmailWithCommasAreInvalids + assert.strictEqual( util.validateEmail( 'user,foo@example.org' ), false, 'Emails with commas are invalid' ); + assert.strictEqual( util.validateEmail( 'userfoo@ex,ample.org' ), false, 'Emails with commas are invalid' ); + + // testEmailWithHyphens + assert.strictEqual( util.validateEmail( 'user-foo@example.org' ), true, 'Emails may contain a hyphen' ); + assert.strictEqual( util.validateEmail( 'userfoo@ex-ample.org' ), true, 'Emails may contain a hyphen' ); + } ); + + QUnit.test( 'isIPv6Address', function ( assert ) { + IPV6_CASES.forEach( function ( ipCase ) { + assert.strictEqual( util.isIPv6Address( ipCase[ 1 ] ), ipCase[ 0 ], ipCase[ 2 ] ); + } ); + } ); + + QUnit.test( 'isIPv4Address', function ( assert ) { + IPV4_CASES.forEach( function ( ipCase ) { + assert.strictEqual( util.isIPv4Address( ipCase[ 1 ] ), ipCase[ 0 ], ipCase[ 2 ] ); + } ); + } ); + + QUnit.test( 'isIPAddress', function ( assert ) { + IPV4_CASES.forEach( function ( ipCase ) { + assert.strictEqual( util.isIPv4Address( ipCase[ 1 ] ), ipCase[ 0 ], ipCase[ 2 ] ); + } ); + + IPV6_CASES.forEach( function ( ipCase ) { + assert.strictEqual( util.isIPv6Address( ipCase[ 1 ] ), ipCase[ 0 ], ipCase[ 2 ] ); + } ); + } ); +}( mediaWiki, jQuery ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.viewport.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.viewport.test.js new file mode 100644 index 00000000..98641662 --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.viewport.test.js @@ -0,0 +1,112 @@ +( function ( mw, $ ) { + + // Simulate square element with 20px long edges placed at (20, 20) on the page + var + DEFAULT_VIEWPORT = { + top: 0, + left: 0, + right: 100, + bottom: 100 + }; + + QUnit.module( 'mediawiki.viewport', QUnit.newMwEnvironment( { + setup: function () { + this.el = $( '<div />' ) + .appendTo( '#qunit-fixture' ) + .width( 20 ) + .height( 20 ) + .offset( { + top: 20, + left: 20 + } ) + .get( 0 ); + this.sandbox.stub( mw.viewport, 'makeViewportFromWindow' ) + .returns( DEFAULT_VIEWPORT ); + } + } ) ); + + QUnit.test( 'isElementInViewport', function ( assert ) { + var viewport = $.extend( {}, DEFAULT_VIEWPORT ); + assert.ok( mw.viewport.isElementInViewport( this.el, viewport ), + 'It should return true when the element is fully enclosed in the viewport' ); + + viewport.right = 20; + viewport.bottom = 20; + assert.ok( mw.viewport.isElementInViewport( this.el, viewport ), + 'It should return true when only the top-left of the element is within the viewport' ); + + viewport.top = 40; + viewport.left = 40; + viewport.right = 50; + viewport.bottom = 50; + assert.ok( mw.viewport.isElementInViewport( this.el, viewport ), + 'It should return true when only the bottom-right is within the viewport' ); + + viewport.top = 30; + viewport.left = 30; + viewport.right = 35; + viewport.bottom = 35; + assert.ok( mw.viewport.isElementInViewport( this.el, viewport ), + 'It should return true when the element encapsulates the viewport' ); + + viewport.top = 0; + viewport.left = 0; + viewport.right = 19; + viewport.bottom = 19; + assert.notOk( mw.viewport.isElementInViewport( this.el, viewport ), + 'It should return false when the element is not within the viewport' ); + + assert.ok( mw.viewport.isElementInViewport( this.el ), + 'It should default to the window object if no viewport is given' ); + } ); + + QUnit.test( 'isElementInViewport with scrolled page', function ( assert ) { + var viewport = { + top: 2000, + left: 0, + right: 1000, + bottom: 2500 + }, + el = $( '<div />' ) + .appendTo( '#qunit-fixture' ) + .width( 20 ) + .height( 20 ) + .offset( { + top: 2300, + left: 20 + } ) + .get( 0 ); + window.scrollTo( viewport.left, viewport.top ); + assert.ok( mw.viewport.isElementInViewport( el, viewport ), + 'It should return true when the element is fully enclosed in the ' + + 'viewport even when the page is scrolled down' ); + window.scrollTo( 0, 0 ); + } ); + + QUnit.test( 'isElementCloseToViewport', function ( assert ) { + var + viewport = { + top: 90, + left: 90, + right: 100, + bottom: 100 + }, + distantElement = $( '<div />' ) + .appendTo( '#qunit-fixture' ) + .width( 20 ) + .height( 20 ) + .offset( { + top: 220, + left: 20 + } ) + .get( 0 ); + + assert.ok( mw.viewport.isElementCloseToViewport( this.el, 60, viewport ), + 'It should return true when the element is within the given threshold away' ); + assert.notOk( mw.viewport.isElementCloseToViewport( this.el, 20, viewport ), + 'It should return false when the element is further than the given threshold away' ); + assert.notOk( mw.viewport.isElementCloseToViewport( distantElement ), + 'It should default to a threshold of 50px and the window\'s viewport' ); + } ); + +}( mediaWiki, jQuery ) ); diff --git a/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.visibleTimeout.test.js b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.visibleTimeout.test.js new file mode 100644 index 00000000..7f8819de --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/mediawiki/mediawiki.visibleTimeout.test.js @@ -0,0 +1,115 @@ +( function ( mw ) { + + QUnit.module( 'mediawiki.visibleTimeout', QUnit.newMwEnvironment( { + setup: function () { + // Document with just enough stuff to make the tests work. + var listeners = []; + this.mockDocument = { + hidden: false, + addEventListener: function ( type, listener ) { + if ( type === 'visibilitychange' ) { + listeners.push( listener ); + } + }, + removeEventListener: function ( type, listener ) { + var i; + if ( type === 'visibilitychange' ) { + i = listeners.indexOf( listener ); + if ( i >= 0 ) { + listeners.splice( i, 1 ); + } + } + }, + // Helper function to swap visibility and run listeners + toggleVisibility: function () { + var i; + this.hidden = !this.hidden; + for ( i = 0; i < listeners.length; i++ ) { + listeners[ i ](); + } + } + }; + this.visibleTimeout = require( 'mediawiki.visibleTimeout' ); + this.visibleTimeout.setDocument( this.mockDocument ); + + this.sandbox.useFakeTimers(); + // mw.now() doesn't respect the fake clock injected by useFakeTimers + this.stub( mw, 'now', ( function () { + return this.sandbox.clock.now; + } ).bind( this ) ); + } + } ) ); + + QUnit.test( 'basic usage', function ( assert ) { + var called = 0; + + this.visibleTimeout.set( function () { + called++; + }, 0 ); + assert.strictEqual( called, 0 ); + this.sandbox.clock.tick( 1 ); + assert.strictEqual( called, 1 ); + + this.sandbox.clock.tick( 100 ); + assert.strictEqual( called, 1 ); + + this.visibleTimeout.set( function () { + called++; + }, 10 ); + this.sandbox.clock.tick( 10 ); + assert.strictEqual( called, 2 ); + } ); + + QUnit.test( 'can cancel timeout', function ( assert ) { + var called = 0, + timeout = this.visibleTimeout.set( function () { + called++; + }, 0 ); + + this.visibleTimeout.clear( timeout ); + this.sandbox.clock.tick( 10 ); + assert.strictEqual( called, 0 ); + + timeout = this.visibleTimeout.set( function () { + called++; + }, 100 ); + this.sandbox.clock.tick( 50 ); + assert.strictEqual( called, 0 ); + this.visibleTimeout.clear( timeout ); + this.sandbox.clock.tick( 100 ); + assert.strictEqual( called, 0 ); + } ); + + QUnit.test( 'start hidden and become visible', function ( assert ) { + var called = 0; + + this.mockDocument.hidden = true; + this.visibleTimeout.set( function () { + called++; + }, 0 ); + this.sandbox.clock.tick( 10 ); + assert.strictEqual( called, 0 ); + + this.mockDocument.toggleVisibility(); + this.sandbox.clock.tick( 10 ); + assert.strictEqual( called, 1 ); + } ); + + QUnit.test( 'timeout is cumulative', function ( assert ) { + var called = 0; + + this.visibleTimeout.set( function () { + called++; + }, 100 ); + this.sandbox.clock.tick( 50 ); + assert.strictEqual( called, 0 ); + + this.mockDocument.toggleVisibility(); + this.sandbox.clock.tick( 1000 ); + assert.strictEqual( called, 0 ); + + this.mockDocument.toggleVisibility(); + this.sandbox.clock.tick( 50 ); + assert.strictEqual( called, 1 ); + } ); +}( mediaWiki ) ); diff --git a/www/wiki/tests/qunit/suites/resources/startup.test.js b/www/wiki/tests/qunit/suites/resources/startup.test.js new file mode 100644 index 00000000..6a704b5a --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/startup.test.js @@ -0,0 +1,160 @@ +/* global isCompatible: true */ +( function () { + var testcases = { + tested: [ + /* Grade A */ + + // Chrome + 'Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_7; en-US) AppleWebKit/534.16 (KHTML, like Gecko) Chrome/10.0.648.205 Safari/534.16', + // Firefox 4+ + 'Mozilla/5.0 (Windows NT 6.1.1; rv:5.0) Gecko/20100101 Firefox/5.0', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.6; rv:9.0) Gecko/20100101 Firefox/9.0', + 'Mozilla/5.0 (Macintosh; I; Intel Mac OS X 11_7_9; de-LI; rv:1.9b4) Gecko/2012010317 Firefox/10.0a4', + 'Mozilla/5.0 (X11; Linux i686; rv:10.0) Gecko/20100101 Firefox/10.0', + 'Mozilla/5.0 (Windows NT 6.1; rv:12.0) Gecko/20120403211507 Firefox/12.0', + 'Mozilla/5.0 (Windows NT 6.2; Win64; x64; rv:16.0.1) Gecko/20121011 Firefox/16.0.1', + // Kindle Fire + 'Mozilla/5.0 (Linux; U; Android 2.3.4; en-us; Kindle Fire Build/GINGERBREAD) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Safari/533.1', + // Safari 5.0+ + 'Mozilla/5.0 (Macintosh; I; Intel Mac OS X 10_6_7; ru-ru) AppleWebKit/534.31+ (KHTML, like Gecko) Version/5.0.5 Safari/533.21.1', + // Opera 15+ (Chromium-based) + 'Mozilla/5.0 (Windows NT 6.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/28.0.1500.95 Safari/537.36 OPR/15.0.1147.153', + 'Mozilla/5.0 (Windows NT 5.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/29.0.1547.57 Safari/537.36 OPR/16.0.1196.62', + 'Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/36.0.1985.125 Safari/537.36 OPR/23.0.1522.75', + // Internet Explorer 11 + 'Mozilla/5.0 (Windows NT 6.3; Trident/7.0; rv:11.0) like Gecko', + // Edge + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.246', + // Edge Mobile + 'Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 640 XL LTE) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Mobile Safari/537.36 Edge/12.10166', + // BlackBerry 6+ + 'Mozilla/5.0 (BlackBerry; U; BlackBerry 9300; en) AppleWebKit/534.8+ (KHTML, like Gecko) Version/6.0.0.570 Mobile Safari/534.8+', + 'Mozilla/5.0 (BlackBerry; U; BlackBerry 9900; en) AppleWebKit/534.11+ (KHTML, like Gecko) Version/7.1.0.346 Mobile Safari/534.11+', + 'Mozilla/5.0 (BB10; Touch) AppleWebKit/537.3+ (KHTML, like Gecko) Version/10.0.9.386 Mobile Safari/537.3+', + // Open WebOS 1.4+ (HP Veer 4G) + 'Mozilla/5.0 (webOS/2.1.2; U; en-US) AppleWebKit/532.2 (KHTML, like Gecko) Version/1.0 Safari/532.2 P160UNA/1.0', + // Firefox Mobile + 'Mozilla/5.0 (Mobile; rv:14.0) Gecko/14.0 Firefox/14.0', + // iOS + 'Mozilla/5.0 (ipod: U;CPU iPhone OS 2_2 like Mac OS X: es_es) AppleWebKit/525.18.1 (KHTML, like Gecko) Version/3.0 Mobile/3B48b Safari/419.3', + 'Mozilla/5.0 (iPhone; U; CPU like Mac OS X; en) AppleWebKit/420.1 (KHTML, like Gecko) Version/3.0 Mobile/3B48b Safari/419.3', + // Android + 'Mozilla/5.0 (Linux; U; Android 2.1; en-us; Nexus One Build/ERD62) AppleWebKit/530.17 (KHTML, like Gecko) Version/4.0 Mobile Safari/530.17', + // UC Mini (speed mode off) + 'Mozilla/5.0 (Linux; U; Android 6.0.1; en-US; Nexus_5 Build/MMB29S) AppleWebKit/528.5+ (KHTML, like Gecko) Version/3.1.2 Mobile Safari/525.20.1 UCBrowser/10.7.6.805 Mobile', + + /* Grade C */ + + // Internet Explorer < 10 + 'Mozilla/2.0 (compatible; MSIE 3.03; Windows 3.1)', + 'Mozilla/4.0 (compatible; MSIE 4.01; Windows 95)', + 'Mozilla/4.0 (compatible; MSIE 5.0; Windows 98;)', + 'Mozilla/4.0 (compatible; MSIE 5.5; Windows NT)', + 'Mozilla/5.0 (compatible; MSIE 6.0; Windows NT 5.1)', + 'Mozilla/5.0 (compatible; MSIE 7.0; Windows NT 6.0; en-US)', + 'Mozilla/5.0 (compatible; MSIE 8.0; Windows NT 5.2; Trident/4.0; Media Center PC 4.0; SLCC1; .NET CLR 3.0.04320)', + 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 7.1; Trident/5.0)', + // Firefox < 4 + 'Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.0.2) Gecko/20060308 Firefox/1.5.0.2', + 'Mozilla/5.0 (X11; U; Linux i686; nl; rv:1.8.1.1) Gecko/20070311 Firefox/2.0.0.1', + 'Mozilla/5.0 (Windows; U; Windows NT 6.1; ru; rv:1.9.2.3) Gecko/20100401 Firefox/3.6.3', + // Opera < 15 (Presto-based) + 'Mozilla/5.0 (Windows NT 5.0; U) Opera 7.54 [en]', + 'Opera/7.54 (Windows NT 5.0; U) [en]', + 'Mozilla/5.0 (Windows NT 5.1; U; en) Opera 8.0', + 'Opera/8.0 (X11; Linux i686; U; cs)', + 'Opera/9.00 (X11; Linux i686; U; de)', + 'Opera/9.62 (X11; Linux i686; U; en) Presto/2.1.1', + 'Opera/9.80 (Windows NT 6.1; U; en) Presto/2.2.15 Version/10.00', + 'Opera/9.80 (Windows NT 6.1; U; ru) Presto/2.8.131 Version/11.10', + 'Opera/9.80 (Windows NT 6.1; WOW64; U; pt) Presto/2.10.229 Version/11.62', + 'Opera/9.80 (Windows NT 6.1; U; es-ES) Presto/2.9.181 Version/12.00', + 'Opera/9.80 (Windows NT 5.1) Presto/2.12.388 Version/12.17', + // BlackBerry < 6 + 'BlackBerry9300/5.0.0.716 Profile/MIDP-2.1 Configuration/CLDC-1.1 VendorID/133', + 'BlackBerry7250/4.0.0 Profile/MIDP-2.0 Configuration/CLDC-1.1', + + /* Grade X */ + + // Gecko + 'Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.8.0.7) Gecko/20060928 (Debian|Debian-1.8.0.7-1) Epiphany/2.14', + 'Mozilla/5.0 (X11; U; Linux i686 (x86_64); en-US; rv:1.8.1.6) Gecko/20070817 IceWeasel/2.0.0.6-g2', + // KHTML + 'Mozilla/5.0 (compatible; Konqueror/3.5; Linux) KHTML/3.5.4 (like Gecko)', + 'Mozilla/5.0 (compatible; Konqueror/4.3; Linux) KHTML/4.3.5 (like Gecko)', + // Text browsers + 'Links (2.1pre33; Darwin 8.11.0 Power Macintosh; x)', + 'Links (6.9; Unix 6.9-astral sparc; 80x25)', + 'Lynx/2.8.6rel.4 libwww-FM/2.14 SSL-MM/1.4.1 OpenSSL/0.9.8g', + 'w3m/0.5.1', + // Bots + 'Googlebot/2.1 (+http://www.google.com/bot.html)', + 'Mozilla/5.0 (compatible; googlebot/2.1; +http://www.google.com/bot.html)', + 'Mozilla/5.0 (compatible; YandexBot/3.0)', + // Scripts + 'curl/7.21.4 (universal-apple-darwin11.0) libcurl/7.21.4 OpenSSL/0.9.8r zlib/1.2.5', + 'Wget/1.9', + 'Wget/1.10.1 (Red Hat modified)', + // Unknown + 'I\'m an unknown browser', + 'I\'m an unknown Glass browser', + // Empty + '' + ], + blacklisted: [ + /* Grade C */ + + // Internet Explorer 10 + 'Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; WOW64; Trident/6.0)', + // IE Mobile 10 + 'Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0; Trident/6.0; IEMobile/10.0; ARM; Touch; HTC; Windows Phone 8X by HTC)', + // PlayStation + 'Mozilla/5.0 (PLAYSTATION 3; 1.10)', + 'Mozilla/5.0 (PLAYSTATION 3; 3.55)', + 'Mozilla/5.0 (PLAYSTATION 3 4.21) AppleWebKit/531.22.8 (KHTML, like Gecko)', + 'Mozilla/5.0 (PlayStation 4 1.70) AppleWebKit/536.26 (KHTML, like Gecko)', + // Open WebOS < 1.5 (Palm Pre, Palm Pixi) + 'Mozilla/5.0 (webOS/1.0; U; en-US) AppleWebKit/525.27.1 (KHTML, like Gecko) Version/1.0 Safari/525.27.1 Pre/1.0', + 'Mozilla/5.0 (webOS/1.4.0; U; en-US) AppleWebKit/532.2 (KHTML, like Gecko) Version/1.0 Safari/532.2 Pixi/1.1 ', + // SymbianOS + 'NokiaN95_8GB-3;Mozilla/5.0 SymbianOS/9.2;U;Series60/3.1 NokiaN95_8GB-3/11.2.011 Profile/MIDP-2.0 Configuration/CLDC-1.1 AppleWebKit/413 (KHTML, like Gecko)', + 'Nokia7610/2.0 (5.0509.0) SymbianOS/7.0s Series60/2.1 Profile/MIDP-2.0 Configuration/CLDC-1.0 ', + 'Mozilla/5.0 (SymbianOS/9.1; U; [en]; SymbianOS/91 Series60/3.0) AppleWebKit/413 (KHTML, like Gecko) Safari/413', + 'Mozilla/5.0 (SymbianOS/9.3; Series60/3.2 NokiaE52-2/091.003; Profile/MIDP-2.1 Configuration/CLDC-1.1 ) AppleWebKit/533.4 (KHTML, like Gecko) NokiaBrowser/7.3.1.34 Mobile Safari/533.4', + // NetFront + 'Mozilla/4.0 (compatible; Linux 2.6.10) NetFront/3.3 Kindle/1.0 (screen 600x800)', + 'Mozilla/4.0 (compatible; Linux 2.6.22) NetFront/3.4 Kindle/2.0 (screen 824x1200; rotate)', + 'Mozilla/4.08 (Windows; Mobile Content Viewer/1.0) NetFront/3.2', + // Opera Mini + 'Opera/9.80 (J2ME/MIDP; Opera Mini/3.1.10423/22.387; U; en) Presto/2.5.25 Version/10.54', + 'Opera/9.50 (J2ME/MIDP; Opera Mini/4.0.10031/298; U; en)', + 'Opera/9.80 (J2ME/MIDP; Opera Mini/6.24093/26.1305; U; en) Presto/2.8.119 Version/10.54', + 'Opera/9.80 (Android; Opera Mini/7.29530/27.1407; U; en) Presto/2.8.119 Version/11.10', + // Ovi Browser + 'Mozilla/5.0 (Series40; NokiaX3-02/05.60; Profile/MIDP-2.1 Configuration/CLDC-1.1) Gecko/20100401 S40OviBrowser/3.2.0.0.6', + 'Mozilla/5.0 (Series40; Nokia305/05.92; Profile/MIDP-2.1 Configuration/CLDC-1.1) Gecko/20100401 S40OviBrowser/3.7.0.0.11', + // Google Glass + 'Mozilla/5.0 (Linux; U; Android 4.0.4; en-us; Glass 1 Build/IMM76L; XE11) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', + // MeeGo + 'Mozilla/5.0 (MeeGo; NokiaN9) AppleWebKit/534.13 (KHTML, like Gecko) NokiaBrowser/8.5.0 Mobile Safari/534.13', + // UC Mini (speed mode on) + 'Mozilla/5.0 (X11; U; Linux i686; zh-CN; r:1.2.3.4) Gecko/', + // Google Web Light proxy + 'Mozilla/5.0 (Linux; Android 4.2.1; en-us; Nexus 5 Build/JOP40D) AppleWebKit/535.19 (KHTML, like Gecko; googleweblight) Chrome/38.0.1025.166 Mobile Safari/535.19' + ] + }; + + QUnit.module( 'startup', QUnit.newMwEnvironment() ); + + QUnit.test( 'isCompatible( featureTestable )', function ( assert ) { + testcases.tested.forEach( function ( ua ) { + assert.strictEqual( isCompatible( ua ), true, ua ); + } ); + } ); + + QUnit.test( 'isCompatible( blacklisted )', function ( assert ) { + testcases.blacklisted.forEach( function ( ua ) { + assert.strictEqual( isCompatible( ua ), false, ua ); + } ); + } ); +}() ); diff --git a/www/wiki/tests/qunit/suites/resources/test.sinonjs/index.js b/www/wiki/tests/qunit/suites/resources/test.sinonjs/index.js new file mode 100644 index 00000000..b1be9d18 --- /dev/null +++ b/www/wiki/tests/qunit/suites/resources/test.sinonjs/index.js @@ -0,0 +1,3 @@ +// Hack: Disable 'module.exports' from ResourceLoader +// (Otherwise Sinon assumes context as Node.js instead of a browser) +module.exports = null; |