diff options
Diffstat (limited to 'www/wiki/tests/qunit/suites/resources/mediawiki')
27 files changed, 6649 insertions, 0 deletions
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 ) ); |