1<?php
2
3use MediaWiki\Interwiki\ClassicInterwikiLookup;
4use MediaWiki\Interwiki\InterwikiLookup;
5use MediaWiki\Linker\LinkTarget;
6use MediaWiki\MediaWikiServices;
7use MediaWiki\Page\PageIdentity;
8use MediaWiki\Page\PageIdentityValue;
9use MediaWiki\User\UserIdentityValue;
10
11/**
12 * @group Database
13 * @group Title
14 */
15class TitleTest extends MediaWikiIntegrationTestCase {
16	protected function setUp() : void {
17		parent::setUp();
18
19		$this->setMwGlobals( [
20			'wgAllowUserJs' => false,
21			'wgDefaultLanguageVariant' => false,
22			'wgMetaNamespace' => 'Project',
23			'wgServer' => 'https://example.org',
24			'wgCanonicalServer' => 'https://example.org',
25			'wgScriptPath' => '/w',
26			'wgScript' => '/w/index.php',
27			'wgArticlePath' => '/wiki/$1',
28		] );
29		$this->setUserLang( 'en' );
30		$this->setContentLang( 'en' );
31	}
32
33	protected function tearDown() : void {
34		parent::tearDown();
35		// delete dummy pages
36		$this->getNonexistingTestPage( 'UTest1' );
37		$this->getNonexistingTestPage( 'UTest2' );
38	}
39
40	/**
41	 * @covers Title::newFromID
42	 * @covers Title::newFromIDs
43	 * @covers Title::newFromRow
44	 */
45	public function testNewFromIds() {
46		// First id
47		$existingPage1 = $this->getExistingTestPage( 'UTest1' );
48		$existingTitle1 = $existingPage1->getTitle();
49		$existingId1 = $existingTitle1->getId();
50
51		$this->assertGreaterThan( 0, $existingId1, 'Sanity: Existing test page should have a positive id' );
52
53		$newFromId1 = Title::newFromID( $existingId1 );
54		$this->assertInstanceOf( Title::class, $newFromId1, 'newFromID returns a title for an existing id' );
55		$this->assertTrue(
56			$newFromId1->equals( $existingTitle1 ),
57			'newFromID returns the correct title'
58		);
59
60		// Second id
61		$existingPage2 = $this->getExistingTestPage( 'UTest2' );
62		$existingTitle2 = $existingPage2->getTitle();
63		$existingId2 = $existingTitle2->getId();
64
65		$this->assertGreaterThan( 0, $existingId2, 'Sanity: Existing test page should have a positive id' );
66
67		$newFromId2 = Title::newFromID( $existingId2 );
68		$this->assertInstanceOf( Title::class, $newFromId2, 'newFromID returns a title for an existing id' );
69		$this->assertTrue(
70			$newFromId2->equals( $existingTitle2 ),
71			'newFromID returns the correct title'
72		);
73
74		// newFromIDs using both
75		$titles = Title::newFromIDs( [ $existingId1, $existingId2 ] );
76		$this->assertCount( 2, $titles );
77		$this->assertTrue(
78			$titles[0]->equals( $existingTitle1 ) &&
79				$titles[1]->equals( $existingTitle2 ),
80			'newFromIDs returns an array that matches the correct titles'
81		);
82
83		// newFromIds early return for an empty array of ids
84		$this->assertSame( [], Title::newFromIDs( [] ) );
85	}
86
87	/**
88	 * @covers Title::newFromID
89	 */
90	public function testNewFromMissingId() {
91		// Testing return of null for an id that does not exist
92		$maxPageId = (int)$this->db->selectField(
93			'page',
94			'max(page_id)',
95			'',
96			__METHOD__
97		);
98		$res = Title::newFromId( $maxPageId + 1 );
99		$this->assertNull( $res, 'newFromID returns null for missing ids' );
100	}
101
102	/**
103	 * @covers Title::legalChars
104	 */
105	public function testLegalChars() {
106		$titlechars = Title::legalChars();
107
108		foreach ( range( 1, 255 ) as $num ) {
109			$chr = chr( $num );
110			if ( strpos( "#[]{}<>|", $chr ) !== false || preg_match( "/[\\x00-\\x1f\\x7f]/", $chr ) ) {
111				$this->assertFalse(
112					(bool)preg_match( "/[$titlechars]/", $chr ),
113					"chr($num) = $chr is not a valid titlechar"
114				);
115			} else {
116				$this->assertTrue(
117					(bool)preg_match( "/[$titlechars]/", $chr ),
118					"chr($num) = $chr is a valid titlechar"
119				);
120			}
121		}
122	}
123
124	public static function provideValidSecureAndSplit() {
125		return [
126			[ 'Sandbox' ],
127			[ 'A "B"' ],
128			[ 'A \'B\'' ],
129			[ '.com' ],
130			[ '~' ],
131			[ '#' ],
132			[ '"' ],
133			[ '\'' ],
134			[ 'Talk:Sandbox' ],
135			[ 'Talk:Foo:Sandbox' ],
136			[ 'File:Example.svg' ],
137			[ 'File_talk:Example.svg' ],
138			[ 'Foo/.../Sandbox' ],
139			[ 'Sandbox/...' ],
140			[ 'A~~' ],
141			[ ':A' ],
142			// Length is 256 total, but only title part matters
143			[ 'Category:' . str_repeat( 'x', 248 ) ],
144			[ str_repeat( 'x', 252 ) ],
145			// interwiki prefix
146			[ 'localtestiw: #anchor' ],
147			[ 'localtestiw:' ],
148			[ 'localtestiw:foo' ],
149			[ 'localtestiw: foo # anchor' ],
150			[ 'localtestiw: Talk: Sandbox # anchor' ],
151			[ 'remotetestiw:' ],
152			[ 'remotetestiw: Talk: # anchor' ],
153			[ 'remotetestiw: #bar' ],
154			[ 'remotetestiw: Talk:' ],
155			[ 'remotetestiw: Talk: Foo' ],
156			[ 'localtestiw:remotetestiw:' ],
157			[ 'localtestiw:remotetestiw:foo' ]
158		];
159	}
160
161	public static function provideInvalidSecureAndSplit() {
162		return [
163			[ '', 'title-invalid-empty' ],
164			[ ':', 'title-invalid-empty' ],
165			[ '__  __', 'title-invalid-empty' ],
166			[ '  __  ', 'title-invalid-empty' ],
167			// Bad characters forbidden regardless of wgLegalTitleChars
168			[ 'A [ B', 'title-invalid-characters' ],
169			[ 'A ] B', 'title-invalid-characters' ],
170			[ 'A { B', 'title-invalid-characters' ],
171			[ 'A } B', 'title-invalid-characters' ],
172			[ 'A < B', 'title-invalid-characters' ],
173			[ 'A > B', 'title-invalid-characters' ],
174			[ 'A | B', 'title-invalid-characters' ],
175			[ "A \t B", 'title-invalid-characters' ],
176			[ "A \n B", 'title-invalid-characters' ],
177			// URL encoding
178			[ 'A%20B', 'title-invalid-characters' ],
179			[ 'A%23B', 'title-invalid-characters' ],
180			[ 'A%2523B', 'title-invalid-characters' ],
181			// XML/HTML character entity references
182			// Note: Commented out because they are not marked invalid by the PHP test as
183			// Title::newFromText runs Sanitizer::decodeCharReferencesAndNormalize first.
184			// 'A &eacute; B',
185			// 'A &#233; B',
186			// 'A &#x00E9; B',
187			// Subject of NS_TALK does not roundtrip to NS_MAIN
188			[ 'Talk:File:Example.svg', 'title-invalid-talk-namespace' ],
189			// Directory navigation
190			[ '.', 'title-invalid-relative' ],
191			[ '..', 'title-invalid-relative' ],
192			[ './Sandbox', 'title-invalid-relative' ],
193			[ '../Sandbox', 'title-invalid-relative' ],
194			[ 'Foo/./Sandbox', 'title-invalid-relative' ],
195			[ 'Foo/../Sandbox', 'title-invalid-relative' ],
196			[ 'Sandbox/.', 'title-invalid-relative' ],
197			[ 'Sandbox/..', 'title-invalid-relative' ],
198			// Tilde
199			[ 'A ~~~ Name', 'title-invalid-magic-tilde' ],
200			[ 'A ~~~~ Signature', 'title-invalid-magic-tilde' ],
201			[ 'A ~~~~~ Timestamp', 'title-invalid-magic-tilde' ],
202			// Length
203			[ str_repeat( 'x', 256 ), 'title-invalid-too-long' ],
204			// Namespace prefix without actual title
205			[ 'Talk:', 'title-invalid-empty' ],
206			[ 'Talk:#', 'title-invalid-empty' ],
207			[ 'Category: ', 'title-invalid-empty' ],
208			[ 'Category: #bar', 'title-invalid-empty' ],
209			// interwiki prefix
210			[ 'localtestiw: Talk: # anchor', 'title-invalid-empty' ],
211			[ 'localtestiw: Talk:', 'title-invalid-empty' ]
212		];
213	}
214
215	private function secureAndSplitGlobals() {
216		$this->setMwGlobals( [
217			'wgLocalInterwikis' => [ 'localtestiw' ],
218			'wgInterwikiCache' => ClassicInterwikiLookup::buildCdbHash( [
219				[
220					'iw_prefix' => 'localtestiw',
221					'iw_url' => 'localtestiw',
222				],
223				[
224					'iw_prefix' => 'remotetestiw',
225					'iw_url' => 'remotetestiw',
226				],
227			] ),
228		] );
229	}
230
231	/**
232	 * See also mediawiki.Title.test.js
233	 * @covers Title::secureAndSplit
234	 * @dataProvider provideValidSecureAndSplit
235	 * @note This mainly tests MediaWikiTitleCodec::parseTitle().
236	 */
237	public function testSecureAndSplitValid( $text ) {
238		$this->secureAndSplitGlobals();
239		$this->assertInstanceOf( Title::class, Title::newFromText( $text ), "Valid: $text" );
240	}
241
242	/**
243	 * See also mediawiki.Title.test.js
244	 * @covers Title::secureAndSplit
245	 * @dataProvider provideInvalidSecureAndSplit
246	 * @note This mainly tests MediaWikiTitleCodec::parseTitle().
247	 */
248	public function testSecureAndSplitInvalid( $text, $expectedErrorMessage ) {
249		$this->secureAndSplitGlobals();
250		try {
251			Title::newFromTextThrow( $text ); // should throw
252			$this->fail( "Title::newFromTextThrow should have thrown with $text" );
253		} catch ( MalformedTitleException $ex ) {
254			$this->assertEquals( $expectedErrorMessage, $ex->getErrorMessage(), "Invalid: $text" );
255		}
256	}
257
258	public static function provideConvertByteClassToUnicodeClass() {
259		return [
260			[
261				' %!"$&\'()*,\\-.\\/0-9:;=?@A-Z\\\\^_`a-z~\\x80-\\xFF+',
262				' %!"$&\'()*,\\-./0-9:;=?@A-Z\\\\\\^_`a-z~+\\u0080-\\uFFFF',
263			],
264			[
265				'QWERTYf-\\xFF+',
266				'QWERTYf-\\x7F+\\u0080-\\uFFFF',
267			],
268			[
269				'QWERTY\\x66-\\xFD+',
270				'QWERTYf-\\x7F+\\u0080-\\uFFFF',
271			],
272			[
273				'QWERTYf-y+',
274				'QWERTYf-y+',
275			],
276			[
277				'QWERTYf-\\x80+',
278				'QWERTYf-\\x7F+\\u0080-\\uFFFF',
279			],
280			[
281				'QWERTY\\x66-\\x80+\\x23',
282				'QWERTYf-\\x7F+#\\u0080-\\uFFFF',
283			],
284			[
285				'QWERTY\\x66-\\x80+\\xD3',
286				'QWERTYf-\\x7F+\\u0080-\\uFFFF',
287			],
288			[
289				'\\\\\\x99',
290				'\\\\\\u0080-\\uFFFF',
291			],
292			[
293				'-\\x99',
294				'\\-\\u0080-\\uFFFF',
295			],
296			[
297				'QWERTY\\-\\x99',
298				'QWERTY\\-\\u0080-\\uFFFF',
299			],
300			[
301				'\\\\x99',
302				'\\\\x99',
303			],
304			[
305				'A-\\x9F',
306				'A-\\x7F\\u0080-\\uFFFF',
307			],
308			[
309				'\\x66-\\x77QWERTY\\x88-\\x91FXZ',
310				'f-wQWERTYFXZ\\u0080-\\uFFFF',
311			],
312			[
313				'\\x66-\\x99QWERTY\\xAA-\\xEEFXZ',
314				'f-\\x7FQWERTYFXZ\\u0080-\\uFFFF',
315			],
316		];
317	}
318
319	/**
320	 * @dataProvider provideConvertByteClassToUnicodeClass
321	 * @covers Title::convertByteClassToUnicodeClass
322	 */
323	public function testConvertByteClassToUnicodeClass( $byteClass, $unicodeClass ) {
324		$this->assertEquals( $unicodeClass, Title::convertByteClassToUnicodeClass( $byteClass ) );
325	}
326
327	/**
328	 * @dataProvider provideSpecialNamesWithAndWithoutParameter
329	 * @covers Title::fixSpecialName
330	 */
331	public function testFixSpecialNameRetainsParameter( $text, $expectedParam ) {
332		$title = Title::newFromText( $text );
333		$fixed = $title->fixSpecialName();
334		$stuff = explode( '/', $fixed->getDBkey(), 2 );
335		if ( count( $stuff ) == 2 ) {
336			$par = $stuff[1];
337		} else {
338			$par = null;
339		}
340		$this->assertEquals(
341			$expectedParam,
342			$par,
343			"T33100 regression check: Title->fixSpecialName() should preserve parameter"
344		);
345	}
346
347	public static function provideSpecialNamesWithAndWithoutParameter() {
348		return [
349			[ 'Special:Version', null ],
350			[ 'Special:Version/', '' ],
351			[ 'Special:Version/param', 'param' ],
352		];
353	}
354
355	public function flattenErrorsArray( $errors ) {
356		$result = [];
357		foreach ( $errors as $error ) {
358			$result[] = $error[0];
359		}
360
361		return $result;
362	}
363
364	/**
365	 * @dataProvider provideGetPageViewLanguage
366	 * @covers Title::getPageViewLanguage
367	 */
368	public function testGetPageViewLanguage( $expected, $titleText, $contLang,
369		$lang, $variant, $msg = ''
370	) {
371		// Setup environnement for this test
372		$this->setMwGlobals( [
373			'wgDefaultLanguageVariant' => $variant,
374			'wgAllowUserJs' => true,
375		] );
376		$this->setUserLang( $lang );
377		$this->setContentLang( $contLang );
378
379		$title = Title::newFromText( $titleText );
380		$this->assertInstanceOf( Title::class, $title,
381			"Test must be passed a valid title text, you gave '$titleText'"
382		);
383		$this->assertEquals( $expected,
384			$title->getPageViewLanguage()->getCode(),
385			$msg
386		);
387	}
388
389	public static function provideGetPageViewLanguage() {
390		# Format:
391		# - expected
392		# - Title name
393		# - content language (expected in most cases)
394		# - wgLang (on some specific pages)
395		# - wgDefaultLanguageVariant
396		return [
397			[ 'fr', 'Help:I_need_somebody', 'fr', 'fr', false ],
398			[ 'es', 'Help:I_need_somebody', 'es', 'zh-tw', false ],
399			[ 'zh', 'Help:I_need_somebody', 'zh', 'zh-tw', false ],
400
401			[ 'es', 'Help:I_need_somebody', 'es', 'zh-tw', 'zh-cn' ],
402			[ 'es', 'MediaWiki:About', 'es', 'zh-tw', 'zh-cn' ],
403			[ 'es', 'MediaWiki:About/', 'es', 'zh-tw', 'zh-cn' ],
404			[ 'de', 'MediaWiki:About/de', 'es', 'zh-tw', 'zh-cn' ],
405			[ 'en', 'MediaWiki:Common.js', 'es', 'zh-tw', 'zh-cn' ],
406			[ 'en', 'MediaWiki:Common.css', 'es', 'zh-tw', 'zh-cn' ],
407			[ 'en', 'User:JohnDoe/Common.js', 'es', 'zh-tw', 'zh-cn' ],
408			[ 'en', 'User:JohnDoe/Monobook.css', 'es', 'zh-tw', 'zh-cn' ],
409
410			[ 'zh-cn', 'Help:I_need_somebody', 'zh', 'zh-tw', 'zh-cn' ],
411			[ 'zh', 'MediaWiki:About', 'zh', 'zh-tw', 'zh-cn' ],
412			[ 'zh', 'MediaWiki:About/', 'zh', 'zh-tw', 'zh-cn' ],
413			[ 'de', 'MediaWiki:About/de', 'zh', 'zh-tw', 'zh-cn' ],
414			[ 'zh-cn', 'MediaWiki:About/zh-cn', 'zh', 'zh-tw', 'zh-cn' ],
415			[ 'zh-tw', 'MediaWiki:About/zh-tw', 'zh', 'zh-tw', 'zh-cn' ],
416			[ 'en', 'MediaWiki:Common.js', 'zh', 'zh-tw', 'zh-cn' ],
417			[ 'en', 'MediaWiki:Common.css', 'zh', 'zh-tw', 'zh-cn' ],
418			[ 'en', 'User:JohnDoe/Common.js', 'zh', 'zh-tw', 'zh-cn' ],
419			[ 'en', 'User:JohnDoe/Monobook.css', 'zh', 'zh-tw', 'zh-cn' ],
420
421			[ 'zh-tw', 'Special:NewPages', 'es', 'zh-tw', 'zh-cn' ],
422			[ 'zh-tw', 'Special:NewPages', 'zh', 'zh-tw', 'zh-cn' ],
423
424		];
425	}
426
427	/**
428	 * @dataProvider provideBaseTitleCases
429	 * @covers Title::getBaseText
430	 */
431	public function testGetBaseText( $title, $expected ) {
432		$title = Title::newFromText( $title );
433		$this->assertSame( $expected, $title->getBaseText() );
434	}
435
436	/**
437	 * @dataProvider provideBaseTitleCases
438	 * @covers Title::getBaseTitle
439	 */
440	public function testGetBaseTitle( $title, $expected ) {
441		$title = Title::newFromText( $title );
442		$base = $title->getBaseTitle();
443		$this->assertTrue( $base->isValid() );
444		$this->assertTrue(
445			$base->equals( Title::makeTitleSafe( $title->getNamespace(), $expected ) )
446		);
447	}
448
449	public static function provideBaseTitleCases() {
450		return [
451			# Title, expected base
452			[ 'User:John_Doe', 'John Doe' ],
453			[ 'User:John_Doe/subOne/subTwo', 'John Doe/subOne' ],
454			[ 'User:Foo / Bar / Baz', 'Foo / Bar ' ],
455			[ 'User:Foo/', 'Foo' ],
456			[ 'User:Foo/Bar/', 'Foo/Bar' ],
457			[ 'User:/', '/' ],
458			[ 'User://', '/' ],
459			[ 'User:/oops/', '/oops' ],
460			[ 'User:/indeed', '/indeed' ],
461			[ 'User://indeed', '/' ],
462			[ 'User:/Ramba/Zamba/Mamba/', '/Ramba/Zamba/Mamba' ],
463			[ 'User://x//y//z//', '//x//y//z/' ],
464		];
465	}
466
467	/**
468	 * @dataProvider provideRootTitleCases
469	 * @covers Title::getRootText
470	 */
471	public function testGetRootText( $title, $expected ) {
472		$title = Title::newFromText( $title );
473		$this->assertEquals( $expected, $title->getRootText() );
474	}
475
476	/**
477	 * @dataProvider provideRootTitleCases
478	 * @covers Title::getRootTitle
479	 */
480	public function testGetRootTitle( $title, $expected ) {
481		$title = Title::newFromText( $title );
482		$root = $title->getRootTitle();
483		$this->assertTrue( $root->isValid() );
484		$this->assertTrue(
485			$root->equals( Title::makeTitleSafe( $title->getNamespace(), $expected ) )
486		);
487	}
488
489	public static function provideRootTitleCases() {
490		return [
491			# Title, expected base
492			[ 'User:John_Doe', 'John Doe' ],
493			[ 'User:John_Doe/subOne/subTwo', 'John Doe' ],
494			[ 'User:Foo / Bar / Baz', 'Foo ' ],
495			[ 'User:Foo/', 'Foo' ],
496			[ 'User:Foo/Bar/', 'Foo' ],
497			[ 'User:/', '/' ],
498			[ 'User://', '/' ],
499			[ 'User:/oops/', '/oops' ],
500			[ 'User:/Ramba/Zamba/Mamba/', '/Ramba' ],
501			[ 'User://x//y//z//', '//x' ],
502			[ 'Talk:////', '///' ],
503			[ 'Template:////', '///' ],
504			[ 'Template:Foo////', 'Foo' ],
505			[ 'Template:Foo////Bar', 'Foo' ],
506		];
507	}
508
509	/**
510	 * @todo Handle $wgNamespacesWithSubpages cases
511	 * @dataProvider provideSubpageTitleCases
512	 * @covers Title::getSubpageText
513	 */
514	public function testGetSubpageText( $title, $expected ) {
515		$title = Title::newFromText( $title );
516		$this->assertEquals( $expected, $title->getSubpageText() );
517	}
518
519	public static function provideSubpageTitleCases() {
520		return [
521			# Title, expected base
522			[ 'User:John_Doe', 'John Doe' ],
523			[ 'User:John_Doe/subOne/subTwo', 'subTwo' ],
524			[ 'User:John_Doe/subOne', 'subOne' ],
525			[ 'User:/', '/' ],
526			[ 'User://', '' ],
527			[ 'User:/oops/', '' ],
528			[ 'User:/indeed', '/indeed' ],
529			[ 'User://indeed', 'indeed' ],
530			[ 'User:/Ramba/Zamba/Mamba/', '' ],
531			[ 'User://x//y//z//', '' ],
532			[ 'Template:Foo', 'Foo' ]
533		];
534	}
535
536	public function provideSubpage() {
537		// NOTE: avoid constructing Title objects in the provider, since it may access the database.
538		return [
539			[ 'Foo', 'x', new TitleValue( NS_MAIN, 'Foo/x' ) ],
540			[ 'Foo#bar', 'x', new TitleValue( NS_MAIN, 'Foo/x' ) ],
541			[ 'User:Foo', 'x', new TitleValue( NS_USER, 'Foo/x' ) ],
542			[ 'wiki:User:Foo', 'x', new TitleValue( NS_MAIN, 'User:Foo/x', '', 'wiki' ) ],
543		];
544	}
545
546	/**
547	 * @dataProvider provideSubpage
548	 * @covers Title::getSubpage
549	 */
550	public function testSubpage( $title, $sub, LinkTarget $expected ) {
551		$interwikiLookup = $this->createMock( InterwikiLookup::class );
552		$interwikiLookup->expects( $this->any() )
553			->method( 'isValidInterwiki' )
554			->willReturnCallback(
555				static function ( $prefix ) {
556					return $prefix == 'wiki';
557				}
558			);
559
560		$this->setService( 'InterwikiLookup', $interwikiLookup );
561
562		$title = Title::newFromText( $title );
563		$expected = Title::newFromLinkTarget( $expected );
564		$actual = $title->getSubpage( $sub );
565
566		// NOTE: convert to string for comparison
567		$this->assertSame( $expected->getPrefixedText(), $actual->getPrefixedText(), 'text form' );
568		$this->assertTrue( $expected->equals( $actual ), 'Title equality' );
569	}
570
571	public function provideCastFromPageIdentity() {
572		yield [ null ];
573
574		$fake = $this->createMock( PageIdentity::class );
575		$fake->method( 'getId' )->willReturn( 7 );
576		$fake->method( 'getNamespace' )->willReturn( NS_MAIN );
577		$fake->method( 'getDBkey' )->willReturn( 'Test' );
578
579		yield [ $fake ];
580
581		$fake = $this->createMock( Title::class );
582		$fake->method( 'getId' )->willReturn( 7 );
583		$fake->method( 'getNamespace' )->willReturn( NS_MAIN );
584		$fake->method( 'getDBkey' )->willReturn( 'Test' );
585
586		yield [ $fake ];
587	}
588
589	/**
590	 * @covers Title::castFromPageIdentity
591	 * @dataProvider provideCastFromPageIdentity
592	 */
593	public function testCastFromPageIdentity( ?PageIdentity $value ) {
594		$title = Title::castFromPageIdentity( $value );
595
596		if ( $value === null ) {
597			$this->assertNull( $title );
598		} elseif ( $value instanceof Title ) {
599			$this->assertSame( $value, $title );
600		} else {
601			$this->assertSame( $value->getId(), $title->getArticleID() );
602			$this->assertSame( $value->getNamespace(), $title->getNamespace() );
603			$this->assertSame( $value->getDBkey(), $title->getDBkey() );
604		}
605	}
606
607	public static function provideNewFromTitleValue() {
608		return [
609			[ new TitleValue( NS_MAIN, 'Foo' ) ],
610			[ new TitleValue( NS_MAIN, 'Foo', 'bar' ) ],
611			[ new TitleValue( NS_USER, 'Hansi_Maier' ) ],
612		];
613	}
614
615	/**
616	 * @covers Title::newFromTitleValue
617	 * @dataProvider provideNewFromTitleValue
618	 */
619	public function testNewFromTitleValue( TitleValue $value ) {
620		$title = Title::newFromTitleValue( $value );
621
622		$dbkey = str_replace( ' ', '_', $value->getText() );
623		$this->assertEquals( $dbkey, $title->getDBkey() );
624		$this->assertEquals( $value->getNamespace(), $title->getNamespace() );
625		$this->assertEquals( $value->getFragment(), $title->getFragment() );
626	}
627
628	/**
629	 * @covers Title::newFromLinkTarget
630	 * @dataProvider provideNewFromTitleValue
631	 */
632	public function testNewFromLinkTarget( LinkTarget $value ) {
633		$title = Title::newFromLinkTarget( $value );
634
635		$dbkey = str_replace( ' ', '_', $value->getText() );
636		$this->assertEquals( $dbkey, $title->getDBkey() );
637		$this->assertEquals( $value->getNamespace(), $title->getNamespace() );
638		$this->assertEquals( $value->getFragment(), $title->getFragment() );
639	}
640
641	/**
642	 * @covers Title::newFromLinkTarget
643	 */
644	public function testNewFromLinkTarget_clone() {
645		$title = Title::newFromText( __METHOD__ );
646		$this->assertSame( $title, Title::newFromLinkTarget( $title ) );
647
648		// The Title::NEW_CLONE flag should ensure that a fresh instance is returned.
649		$clone = Title::newFromLinkTarget( $title, Title::NEW_CLONE );
650		$this->assertNotSame( $title, $clone );
651		$this->assertTrue( $clone->equals( $title ) );
652	}
653
654	public function provideCastFromLinkTarget() {
655		return array_merge( [ [ null ] ], self::provideNewFromTitleValue() );
656	}
657
658	/**
659	 * @covers Title::castFromLinkTarget
660	 * @dataProvider provideCastFromLinkTarget
661	 */
662	public function testCastFromLinkTarget( $value ) {
663		$title = Title::castFromLinkTarget( $value );
664
665		if ( $value === null ) {
666			$this->assertNull( $title );
667		} else {
668			$dbkey = str_replace( ' ', '_', $value->getText() );
669			$this->assertSame( $dbkey, $title->getDBkey() );
670			$this->assertSame( $value->getNamespace(), $title->getNamespace() );
671			$this->assertSame( $value->getFragment(), $title->getFragment() );
672		}
673	}
674
675	public static function provideGetTitleValue() {
676		return [
677			[ 'Foo' ],
678			[ 'Foo#bar' ],
679			[ 'User:Hansi_Maier' ],
680		];
681	}
682
683	/**
684	 * @covers Title::getTitleValue
685	 * @dataProvider provideGetTitleValue
686	 */
687	public function testGetTitleValue( $text ) {
688		$title = Title::newFromText( $text );
689		$value = $title->getTitleValue();
690
691		$dbkey = str_replace( ' ', '_', $value->getText() );
692		$this->assertEquals( $title->getDBkey(), $dbkey );
693		$this->assertEquals( $title->getNamespace(), $value->getNamespace() );
694		$this->assertEquals( $title->getFragment(), $value->getFragment() );
695	}
696
697	public static function provideGetFragment() {
698		return [
699			[ 'Foo', '' ],
700			[ 'Foo#bar', 'bar' ],
701			[ 'Foo#bär', 'bär' ],
702
703			// Inner whitespace is normalized
704			[ 'Foo#bar_bar', 'bar bar' ],
705			[ 'Foo#bar bar', 'bar bar' ],
706			[ 'Foo#bar   bar', 'bar bar' ],
707
708			// Leading whitespace is kept, trailing whitespace is trimmed.
709			// XXX: Is this really want we want?
710			[ 'Foo#_bar_bar_', ' bar bar' ],
711			[ 'Foo# bar bar ', ' bar bar' ],
712		];
713	}
714
715	/**
716	 * @covers Title::getFragment
717	 * @dataProvider provideGetFragment
718	 *
719	 * @param string $full
720	 * @param string $fragment
721	 */
722	public function testGetFragment( $full, $fragment ) {
723		$title = Title::newFromText( $full );
724		$this->assertEquals( $fragment, $title->getFragment() );
725	}
726
727	/**
728	 * @covers Title::isAlwaysKnown
729	 * @dataProvider provideIsAlwaysKnown
730	 * @param string $page
731	 * @param bool $isKnown
732	 */
733	public function testIsAlwaysKnown( $page, $isKnown ) {
734		$title = Title::newFromText( $page );
735		$this->assertEquals( $isKnown, $title->isAlwaysKnown() );
736	}
737
738	public static function provideIsAlwaysKnown() {
739		return [
740			[ 'Some nonexistent page', false ],
741			[ 'UTPage', false ],
742			[ '#test', true ],
743			[ 'Special:BlankPage', true ],
744			[ 'Special:SomeNonexistentSpecialPage', false ],
745			[ 'MediaWiki:Parentheses', true ],
746			[ 'MediaWiki:Some nonexistent message', false ],
747		];
748	}
749
750	/**
751	 * @covers Title::isValid
752	 * @dataProvider provideIsValid
753	 * @param Title $title
754	 * @param bool $isValid
755	 */
756	public function testIsValid( Title $title, $isValid ) {
757		$iwLookup = $this->createMock( InterwikiLookup::class );
758		$iwLookup->method( 'isValidInterwiki' )
759			->willReturn( true );
760
761		$this->setService(
762			'InterwikiLookup',
763			$iwLookup
764		);
765
766		$this->assertEquals( $isValid, $title->isValid(), $title->getFullText() );
767	}
768
769	public static function provideIsValid() {
770		return [
771			[ Title::makeTitle( NS_MAIN, '' ), false ],
772			[ Title::makeTitle( NS_MAIN, '<>' ), false ],
773			[ Title::makeTitle( NS_MAIN, '|' ), false ],
774			[ Title::makeTitle( NS_MAIN, '#' ), false ],
775			[ Title::makeTitle( NS_PROJECT, '#' ), false ],
776			[ Title::makeTitle( NS_MAIN, '', 'test' ), true ],
777			[ Title::makeTitle( NS_PROJECT, '#test' ), false ],
778			[ Title::makeTitle( NS_MAIN, '', 'test', 'wikipedia' ), true ],
779			[ Title::makeTitle( NS_MAIN, 'Test', '', 'wikipedia' ), true ],
780			[ Title::makeTitle( NS_MAIN, 'Test' ), true ],
781			[ Title::makeTitle( NS_SPECIAL, 'Test' ), true ],
782			[ Title::makeTitle( NS_MAIN, ' Test' ), false ],
783			[ Title::makeTitle( NS_MAIN, '_Test' ), false ],
784			[ Title::makeTitle( NS_MAIN, 'Test ' ), false ],
785			[ Title::makeTitle( NS_MAIN, 'Test_' ), false ],
786			[ Title::makeTitle( NS_MAIN, "Test\nthis" ), false ],
787			[ Title::makeTitle( NS_MAIN, "Test\tthis" ), false ],
788			[ Title::makeTitle( -33, 'Test' ), false ],
789			[ Title::makeTitle( 77663399, 'Test' ), false ],
790		];
791	}
792
793	/**
794	 * @covers Title::isValidRedirectTarget
795	 * @dataProvider provideIsValidRedirectTarget
796	 * @param Title $title
797	 * @param bool $isValid
798	 */
799	public function testIsValidRedirectTarget( Title $title, $isValid ) {
800		$iwLookup = $this->createMock( InterwikiLookup::class );
801		$iwLookup->method( 'isValidInterwiki' )
802			->willReturn( true );
803
804		$this->setService(
805			'InterwikiLookup',
806			$iwLookup
807		);
808
809		$this->assertEquals( $isValid, $title->isValidRedirectTarget(), $title->getFullText() );
810	}
811
812	public static function provideIsValidRedirectTarget() {
813		return [
814			[ Title::makeTitle( NS_MAIN, '' ), false ],
815			[ Title::makeTitle( NS_MAIN, '', 'test' ), false ],
816			[ Title::makeTitle( NS_MAIN, 'Foo', 'test' ), true ],
817			[ Title::makeTitle( NS_MAIN, '<>' ), false ],
818			[ Title::makeTitle( NS_MAIN, '_' ), false ],
819			[ Title::makeTitle( NS_MAIN, 'Test', '', 'acme' ), true ],
820			[ Title::makeTitle( NS_SPECIAL, 'UserLogout' ), false ],
821			[ Title::makeTitle( NS_SPECIAL, 'RecentChanges' ), true ],
822		];
823	}
824
825	/**
826	 * @covers Title::canExist
827	 * @dataProvider provideCanExist
828	 * @param Title $title
829	 * @param bool $canExist
830	 */
831	public function testCanExist( Title $title, $canExist ) {
832		$this->assertEquals( $canExist, $title->canExist(), $title->getFullText() );
833	}
834
835	public static function provideCanExist() {
836		return [
837			[ Title::makeTitle( NS_MAIN, '' ), false ],
838			[ Title::makeTitle( NS_MAIN, '<>' ), false ],
839			[ Title::makeTitle( NS_MAIN, '|' ), false ],
840			[ Title::makeTitle( NS_MAIN, '#' ), false ],
841			[ Title::makeTitle( NS_PROJECT, '#test' ), false ],
842			[ Title::makeTitle( NS_MAIN, '', 'test', 'acme' ), false ],
843			[ Title::makeTitle( NS_MAIN, 'Test' ), true ],
844			[ Title::makeTitle( NS_MAIN, ' Test' ), false ],
845			[ Title::makeTitle( NS_MAIN, '_Test' ), false ],
846			[ Title::makeTitle( NS_MAIN, 'Test ' ), false ],
847			[ Title::makeTitle( NS_MAIN, 'Test_' ), false ],
848			[ Title::makeTitle( NS_MAIN, "Test\nthis" ), false ],
849			[ Title::makeTitle( NS_MAIN, "Test\tthis" ), false ],
850			[ Title::makeTitle( -33, 'Test' ), false ],
851			[ Title::makeTitle( 77663399, 'Test' ), false ],
852
853			// Valid but can't exist
854			[ Title::makeTitle( NS_MAIN, '', 'test' ), false ],
855			[ Title::makeTitle( NS_SPECIAL, 'Test' ), false ],
856			[ Title::makeTitle( NS_MAIN, 'Test', '', 'acme' ), false ],
857		];
858	}
859
860	/**
861	 * @covers Title::isAlwaysKnown
862	 */
863	public function testIsAlwaysKnownOnInterwiki() {
864		$title = Title::makeTitle( NS_MAIN, 'Interwiki link', '', 'externalwiki' );
865		$this->assertTrue( $title->isAlwaysKnown() );
866	}
867
868	/**
869	 * @covers Title::exists
870	 */
871	public function testExists() {
872		$title = Title::makeTitle( NS_PROJECT, 'New page' );
873		$linkCache = MediaWikiServices::getInstance()->getLinkCache();
874
875		$article = new Article( $title );
876		$page = $article->getPage();
877		$page->doEditContent( new WikitextContent( 'Some [[link]]' ), 'summary' );
878
879		// Tell Title it doesn't know whether it exists
880		$title->mArticleID = -1;
881
882		// Tell the link cache it doesn't exist when it really does
883		$linkCache->clearLink( $title );
884		$linkCache->addBadLinkObj( $title );
885
886		$this->assertFalse(
887			$title->exists(),
888			'exists() should rely on link cache unless READ_LATEST is used'
889		);
890		$this->assertTrue(
891			$title->exists( Title::READ_LATEST ),
892			'exists() should re-query database when READ_LATEST is used'
893		);
894	}
895
896	/**
897	 * @covers Title::getArticleID
898	 * @covers Title::getId
899	 */
900	public function testGetArticleID() {
901		$title = Title::makeTitle( NS_PROJECT, __METHOD__ );
902		$this->assertSame( 0, $title->getArticleID() );
903		$this->assertSame( $title->getArticleID(), $title->getId() );
904
905		$article = new Article( $title );
906		$page = $article->getPage();
907		$page->doEditContent( new WikitextContent( 'Some [[link]]' ), 'summary' );
908
909		$this->assertGreaterThan( 0, $title->getArticleID() );
910		$this->assertSame( $title->getArticleID(), $title->getId() );
911	}
912
913	public function provideCanHaveTalkPage() {
914		return [
915			'User page has talk page' => [
916				Title::makeTitle( NS_USER, 'Jane' ), true
917			],
918			'Talke page has talk page' => [
919				Title::makeTitle( NS_TALK, 'Foo' ), true
920			],
921			'Special page cannot have talk page' => [
922				Title::makeTitle( NS_SPECIAL, 'Thing' ), false
923			],
924			'Virtual namespace cannot have talk page' => [
925				Title::makeTitle( NS_MEDIA, 'Kitten.jpg' ), false
926			],
927			'Relative link has no talk page' => [
928				Title::makeTitle( NS_MAIN, '', 'Kittens' ), false
929			],
930			'Interwiki link has no talk page' => [
931				Title::makeTitle( NS_MAIN, 'Kittens', '', 'acme' ), false
932			],
933		];
934	}
935
936	public function provideIsWatchable() {
937		return [
938			'User page is watchable' => [
939				Title::makeTitle( NS_USER, 'Jane' ), true
940			],
941			'Talk page is watchable' => [
942				Title::makeTitle( NS_TALK, 'Foo' ), true
943			],
944			'Special page is not watchable' => [
945				Title::makeTitle( NS_SPECIAL, 'Thing' ), false
946			],
947			'Virtual namespace is not watchable' => [
948				Title::makeTitle( NS_MEDIA, 'Kitten.jpg' ), false
949			],
950			'Relative link is not watchable' => [
951				Title::makeTitle( NS_MAIN, '', 'Kittens' ), false
952			],
953			'Interwiki link is not watchable' => [
954				Title::makeTitle( NS_MAIN, 'Kittens', '', 'acme' ), false
955			],
956			'Invalid title is not watchable' => [
957				Title::makeTitle( NS_MAIN, '<' ), false
958			]
959		];
960	}
961
962	public static function provideGetTalkPage_good() {
963		return [
964			[ Title::makeTitle( NS_MAIN, 'Test' ), Title::makeTitle( NS_TALK, 'Test' ) ],
965			[ Title::makeTitle( NS_TALK, 'Test' ), Title::makeTitle( NS_TALK, 'Test' ) ],
966		];
967	}
968
969	public static function provideGetTalkPage_bad() {
970		return [
971			[ Title::makeTitle( NS_SPECIAL, 'Test' ) ],
972			[ Title::makeTitle( NS_MEDIA, 'Test' ) ],
973		];
974	}
975
976	public static function provideGetTalkPage_broken() {
977		// These cases *should* be bad, but are not treated as bad, for backwards compatibility.
978		// See discussion on T227817.
979		return [
980			[
981				Title::makeTitle( NS_MAIN, '', 'Kittens' ),
982				Title::makeTitle( NS_TALK, '' ), // Section is lost!
983				false,
984			],
985			[
986				Title::makeTitle( NS_MAIN, 'Kittens', '', 'acme' ),
987				Title::makeTitle( NS_TALK, 'Kittens', '' ), // Interwiki prefix is lost!
988				true,
989			],
990		];
991	}
992
993	public static function provideGetSubjectPage_good() {
994		return [
995			[ Title::makeTitle( NS_TALK, 'Test' ), Title::makeTitle( NS_MAIN, 'Test' ) ],
996			[ Title::makeTitle( NS_MAIN, 'Test' ), Title::makeTitle( NS_MAIN, 'Test' ) ],
997		];
998	}
999
1000	public static function provideGetOtherPage_good() {
1001		return [
1002			[ Title::makeTitle( NS_MAIN, 'Test' ), Title::makeTitle( NS_TALK, 'Test' ) ],
1003			[ Title::makeTitle( NS_TALK, 'Test' ), Title::makeTitle( NS_MAIN, 'Test' ) ],
1004		];
1005	}
1006
1007	/**
1008	 * @dataProvider provideCanHaveTalkPage
1009	 * @covers Title::canHaveTalkPage
1010	 *
1011	 * @param Title $title
1012	 * @param bool $expected
1013	 */
1014	public function testCanHaveTalkPage( Title $title, $expected ) {
1015		$actual = $title->canHaveTalkPage();
1016		$this->assertSame( $expected, $actual, $title->getPrefixedDBkey() );
1017	}
1018
1019	/**
1020	 * @dataProvider provideIsWatchable
1021	 * @covers Title::isWatchable
1022	 *
1023	 * @param Title $title
1024	 * @param bool $expected
1025	 */
1026	public function testIsWatchable( Title $title, $expected ) {
1027		$actual = $title->isWatchable();
1028		$this->assertSame( $expected, $actual, $title->getPrefixedDBkey() );
1029	}
1030
1031	/**
1032	 * @dataProvider provideGetTalkPage_good
1033	 * @covers Title::getTalkPageIfDefined
1034	 */
1035	public function testGetTalkPage_good( Title $title, Title $expected ) {
1036		$actual = $title->getTalkPage();
1037		$this->assertTrue( $expected->equals( $actual ), $title->getPrefixedDBkey() );
1038	}
1039
1040	/**
1041	 * @dataProvider provideGetTalkPage_bad
1042	 * @covers Title::getTalkPageIfDefined
1043	 */
1044	public function testGetTalkPage_bad( Title $title ) {
1045		$this->expectException( MWException::class );
1046		$title->getTalkPage();
1047	}
1048
1049	/**
1050	 * @dataProvider provideGetTalkPage_broken
1051	 * @covers Title::getTalkPageIfDefined
1052	 */
1053	public function testGetTalkPage_broken( Title $title, Title $expected, $valid ) {
1054		$errorLevel = error_reporting( E_ERROR );
1055
1056		// NOTE: Eventually we want to throw in this case. But while there is still code that
1057		// calls this method without checking, we want to avoid fatal errors.
1058		// See discussion on T227817.
1059		$result = $title->getTalkPage();
1060		$this->assertTrue( $expected->equals( $result ) );
1061		$this->assertSame( $valid, $result->isValid() );
1062
1063		error_reporting( $errorLevel );
1064	}
1065
1066	/**
1067	 * @dataProvider provideGetTalkPage_good
1068	 * @covers Title::getTalkPageIfDefined
1069	 */
1070	public function testGetTalkPageIfDefined_good( Title $title, Title $expected ) {
1071		$actual = $title->getTalkPageIfDefined();
1072		$this->assertNotNull( $actual, $title->getPrefixedDBkey() );
1073		$this->assertTrue( $expected->equals( $actual ), $title->getPrefixedDBkey() );
1074	}
1075
1076	/**
1077	 * @dataProvider provideGetTalkPage_bad
1078	 * @covers Title::getTalkPageIfDefined
1079	 */
1080	public function testGetTalkPageIfDefined_bad( Title $title ) {
1081		$talk = $title->getTalkPageIfDefined();
1082		$this->assertNull(
1083			$talk,
1084			$title->getPrefixedDBkey()
1085		);
1086	}
1087
1088	/**
1089	 * @dataProvider provideGetSubjectPage_good
1090	 * @covers Title::getSubjectPage
1091	 */
1092	public function testGetSubjectPage_good( Title $title, Title $expected ) {
1093		$actual = $title->getSubjectPage();
1094		$this->assertTrue( $expected->equals( $actual ), $title->getPrefixedDBkey() );
1095	}
1096
1097	/**
1098	 * @dataProvider provideGetOtherPage_good
1099	 * @covers Title::getOtherPage
1100	 */
1101	public function testGetOtherPage_good( Title $title, Title $expected ) {
1102		$actual = $title->getOtherPage();
1103		$this->assertTrue( $expected->equals( $actual ), $title->getPrefixedDBkey() );
1104	}
1105
1106	/**
1107	 * @dataProvider provideGetTalkPage_bad
1108	 * @covers Title::getOtherPage
1109	 */
1110	public function testGetOtherPage_bad( Title $title ) {
1111		$this->expectException( MWException::class );
1112		$title->getOtherPage();
1113	}
1114
1115	/**
1116	 * @dataProvider provideIsMovable
1117	 * @covers Title::isMovable
1118	 *
1119	 * @param string|Title $title
1120	 * @param bool $expected
1121	 * @param callable|null $hookCallback For TitleIsMovable
1122	 */
1123	public function testIsMovable( $title, $expected, $hookCallback = null ) {
1124		if ( $hookCallback ) {
1125			$this->setTemporaryHook( 'TitleIsMovable', $hookCallback );
1126		}
1127		if ( is_string( $title ) ) {
1128			$title = Title::newFromText( $title );
1129		}
1130
1131		$this->assertSame( $expected, $title->isMovable() );
1132	}
1133
1134	public static function provideIsMovable() {
1135		return [
1136			'Simple title' => [ 'Foo', true ],
1137			// @todo Should these next two really be true?
1138			'Empty name' => [ Title::makeTitle( NS_MAIN, '' ), true ],
1139			'Invalid name' => [ Title::makeTitle( NS_MAIN, '<' ), true ],
1140			'Interwiki' => [ Title::makeTitle( NS_MAIN, 'Test', '', 'otherwiki' ), false ],
1141			'Special page' => [ 'Special:FooBar', false ],
1142			'Aborted by hook' => [ 'Hooked in place', false,
1143				static function ( Title $title, &$result ) {
1144					$result = false;
1145				}
1146			],
1147		];
1148	}
1149
1150	public function provideCreateFragmentTitle() {
1151		return [
1152			[ Title::makeTitle( NS_MAIN, 'Test' ), 'foo' ],
1153			[ Title::makeTitle( NS_TALK, 'Test', 'foo' ), '' ],
1154			[ Title::makeTitle( NS_CATEGORY, 'Test', 'foo' ), 'bar' ],
1155			[ Title::makeTitle( NS_MAIN, 'Test1', '', 'interwiki' ), 'baz' ]
1156		];
1157	}
1158
1159	/**
1160	 * @covers Title::createFragmentTarget
1161	 * @dataProvider provideCreateFragmentTitle
1162	 */
1163	public function testCreateFragmentTitle( Title $title, $fragment ) {
1164		$this->setMwGlobals( [
1165			'wgInterwikiCache' => ClassicInterwikiLookup::buildCdbHash( [
1166				[
1167					'iw_prefix' => 'interwiki',
1168					'iw_url' => 'http://example.com/',
1169					'iw_local' => 0,
1170					'iw_trans' => 0,
1171				],
1172			] ),
1173		] );
1174
1175		$fragmentTitle = $title->createFragmentTarget( $fragment );
1176
1177		$this->assertEquals( $title->getNamespace(), $fragmentTitle->getNamespace() );
1178		$this->assertEquals( $title->getText(), $fragmentTitle->getText() );
1179		$this->assertEquals( $title->getInterwiki(), $fragmentTitle->getInterwiki() );
1180		$this->assertEquals( $fragment, $fragmentTitle->getFragment() );
1181	}
1182
1183	public function provideGetPrefixedText() {
1184		return [
1185			// ns = 0
1186			[
1187				Title::makeTitle( NS_MAIN, 'Foo bar' ),
1188				'Foo bar'
1189			],
1190			// ns = 2
1191			[
1192				Title::makeTitle( NS_USER, 'Foo bar' ),
1193				'User:Foo bar'
1194			],
1195			// ns = 3
1196			[
1197				Title::makeTitle( NS_USER_TALK, 'Foo bar' ),
1198				'User talk:Foo bar'
1199			],
1200			// fragment not included
1201			[
1202				Title::makeTitle( NS_MAIN, 'Foo bar', 'fragment' ),
1203				'Foo bar'
1204			],
1205			// ns = -2
1206			[
1207				Title::makeTitle( NS_MEDIA, 'Foo bar' ),
1208				'Media:Foo bar'
1209			],
1210			// non-existent namespace
1211			[
1212				Title::makeTitle( 100777, 'Foo bar' ),
1213				'Special:Badtitle/NS100777:Foo bar'
1214			],
1215		];
1216	}
1217
1218	/**
1219	 * @covers Title::getPrefixedText
1220	 * @dataProvider provideGetPrefixedText
1221	 */
1222	public function testGetPrefixedText( Title $title, $expected ) {
1223		$this->assertEquals( $expected, $title->getPrefixedText() );
1224	}
1225
1226	public function provideGetPrefixedDBKey() {
1227		return [
1228			// ns = 0
1229			[
1230				Title::makeTitle( NS_MAIN, 'Foo_bar' ),
1231				'Foo_bar'
1232			],
1233			// ns = 2
1234			[
1235				Title::makeTitle( NS_USER, 'Foo_bar' ),
1236				'User:Foo_bar'
1237			],
1238			// ns = 3
1239			[
1240				Title::makeTitle( NS_USER_TALK, 'Foo_bar' ),
1241				'User_talk:Foo_bar'
1242			],
1243			// fragment not included
1244			[
1245				Title::makeTitle( NS_MAIN, 'Foo_bar', 'fragment' ),
1246				'Foo_bar'
1247			],
1248			// ns = -2
1249			[
1250				Title::makeTitle( NS_MEDIA, 'Foo_bar' ),
1251				'Media:Foo_bar'
1252			],
1253			// non-existent namespace
1254			[
1255				Title::makeTitle( 100777, 'Foo_bar' ),
1256				'Special:Badtitle/NS100777:Foo_bar'
1257			],
1258		];
1259	}
1260
1261	/**
1262	 * @covers Title::getPrefixedDBKey
1263	 * @dataProvider provideGetPrefixedDBKey
1264	 */
1265	public function testGetPrefixedDBKey( Title $title, $expected ) {
1266		$this->assertEquals( $expected, $title->getPrefixedDBkey() );
1267	}
1268
1269	/**
1270	 * @covers Title::getFragmentForURL
1271	 * @dataProvider provideGetFragmentForURL
1272	 *
1273	 * @param string $titleStr
1274	 * @param string $expected
1275	 */
1276	public function testGetFragmentForURL( $titleStr, $expected ) {
1277		$this->setMwGlobals( [
1278			'wgFragmentMode' => [ 'html5' ],
1279			'wgExternalInterwikiFragmentMode' => 'legacy',
1280		] );
1281		$dbw = wfGetDB( DB_MASTER );
1282		$dbw->insert( 'interwiki',
1283			[
1284				[
1285					'iw_prefix' => 'de',
1286					'iw_url' => 'http://de.wikipedia.org/wiki/',
1287					'iw_api' => 'http://de.wikipedia.org/w/api.php',
1288					'iw_wikiid' => 'dewiki',
1289					'iw_local' => 1,
1290					'iw_trans' => 0,
1291				],
1292				[
1293					'iw_prefix' => 'zz',
1294					'iw_url' => 'http://zzwiki.org/wiki/',
1295					'iw_api' => 'http://zzwiki.org/w/api.php',
1296					'iw_wikiid' => 'zzwiki',
1297					'iw_local' => 0,
1298					'iw_trans' => 0,
1299				],
1300			],
1301			__METHOD__,
1302			[ 'IGNORE' ]
1303		);
1304
1305		$title = Title::newFromText( $titleStr );
1306		self::assertEquals( $expected, $title->getFragmentForURL() );
1307
1308		$dbw->delete( 'interwiki', '*', __METHOD__ );
1309	}
1310
1311	public function provideGetFragmentForURL() {
1312		return [
1313			[ 'Foo', '' ],
1314			[ 'Foo#ümlåût', '#ümlåût' ],
1315			[ 'de:Foo#Bå®', '#Bå®' ],
1316			[ 'zz:Foo#тест', '#.D1.82.D0.B5.D1.81.D1.82' ],
1317		];
1318	}
1319
1320	/**
1321	 * @covers Title::isRawHtmlMessage
1322	 * @dataProvider provideIsRawHtmlMessage
1323	 */
1324	public function testIsRawHtmlMessage( $textForm, $expected ) {
1325		$this->setMwGlobals( 'wgRawHtmlMessages', [
1326			'foobar',
1327			'foo_bar',
1328			'foo-bar',
1329		] );
1330
1331		$title = Title::newFromText( $textForm );
1332		$this->assertSame( $expected, $title->isRawHtmlMessage() );
1333	}
1334
1335	public function provideIsRawHtmlMessage() {
1336		return [
1337			[ 'MediaWiki:Foobar', true ],
1338			[ 'MediaWiki:Foo bar', true ],
1339			[ 'MediaWiki:Foo-bar', true ],
1340			[ 'MediaWiki:foo bar', true ],
1341			[ 'MediaWiki:foo-bar', true ],
1342			[ 'MediaWiki:foobar', true ],
1343			[ 'MediaWiki:some-other-message', false ],
1344			[ 'Main Page', false ],
1345		];
1346	}
1347
1348	public function provideEquals() {
1349		yield '(newFromText) same text' => [
1350			Title::newFromText( 'Main Page' ),
1351			Title::newFromText( 'Main Page' ),
1352			true
1353		];
1354		yield '(newFromText) different text' => [
1355			Title::newFromText( 'Main Page' ),
1356			Title::newFromText( 'Not The Main Page' ),
1357			false
1358		];
1359		yield '(newFromText) different namespace, same text' => [
1360			Title::newFromText( 'Main Page' ),
1361			Title::newFromText( 'Project:Main Page' ),
1362			false
1363		];
1364		yield '(newFromText) namespace alias' => [
1365			Title::newFromText( 'File:Example.png' ),
1366			Title::newFromText( 'Image:Example.png' ),
1367			true
1368		];
1369		yield '(newFromText) same special page' => [
1370			Title::newFromText( 'Special:Version' ),
1371			Title::newFromText( 'Special:Version' ),
1372			true
1373		];
1374		yield '(newFromText) different special page' => [
1375			Title::newFromText( 'Special:Version' ),
1376			Title::newFromText( 'Special:Recentchanges' ),
1377			false
1378		];
1379		yield '(newFromText) compare special and normal page' => [
1380			Title::newFromText( 'Special:Version' ),
1381			Title::newFromText( 'Main Page' ),
1382			false
1383		];
1384		yield '(makeTitle) same text' => [
1385			Title::makeTitle( NS_MAIN, 'Foo', '', '' ),
1386			Title::makeTitle( NS_MAIN, 'Foo', '', '' ),
1387			true
1388		];
1389		yield '(makeTitle) different text' => [
1390			Title::makeTitle( NS_MAIN, 'Foo', '', '' ),
1391			Title::makeTitle( NS_MAIN, 'Bar', '', '' ),
1392			false
1393		];
1394		yield '(makeTitle) different namespace, same text' => [
1395			Title::makeTitle( NS_MAIN, 'Foo', '', '' ),
1396			Title::makeTitle( NS_TALK, 'Foo', '', '' ),
1397			false
1398		];
1399		yield '(makeTitle) same fragment' => [
1400			Title::makeTitle( NS_MAIN, 'Foo', 'Bar', '' ),
1401			Title::makeTitle( NS_MAIN, 'Foo', 'Bar', '' ),
1402			true
1403		];
1404		yield '(makeTitle) different fragment (ignored)' => [
1405			Title::makeTitle( NS_MAIN, 'Foo', 'Bar', '' ),
1406			Title::makeTitle( NS_MAIN, 'Foo', 'Baz', '' ),
1407			true
1408		];
1409		yield '(makeTitle) fragment vs no fragment (ignored)' => [
1410			Title::makeTitle( NS_MAIN, 'Foo', 'Bar', '' ),
1411			Title::makeTitle( NS_MAIN, 'Foo', '', '' ),
1412			true
1413		];
1414		yield '(makeTitle) same interwiki' => [
1415			Title::makeTitle( NS_MAIN, 'Foo', '', 'baz' ),
1416			Title::makeTitle( NS_MAIN, 'Foo', '', 'baz' ),
1417			true
1418		];
1419		yield '(makeTitle) different interwiki' => [
1420			Title::makeTitle( NS_MAIN, 'Foo', '', '' ),
1421			Title::makeTitle( NS_MAIN, 'Foo', '', 'baz' ),
1422			false
1423		];
1424
1425		// Wrong type
1426		yield '(makeTitle vs PageIdentityValue) name text' => [
1427			Title::makeTitle( NS_MAIN, 'Foo' ),
1428			new PageIdentityValue( 0, NS_MAIN, 'Foo', PageIdentity::LOCAL ),
1429			false
1430		];
1431		yield '(makeTitle vs TitleValue) name text' => [
1432			Title::makeTitle( NS_MAIN, 'Foo' ),
1433			new TitleValue( NS_MAIN, 'Foo' ),
1434			false
1435		];
1436		yield '(makeTitle vs UserIdentityValue) name text' => [
1437			Title::makeTitle( NS_MAIN, 'Foo' ),
1438			new UserIdentityValue( 7, 'Foo' ),
1439			false
1440		];
1441	}
1442
1443	/**
1444	 * @covers Title::getPreviousRevisionID
1445	 * @covers MediaWiki\Revision\RevisionStore::getRelativeRevision
1446	 */
1447	public function testGetPreviousRevisionID_deprecated() {
1448		$this->expectDeprecation();
1449		Title::makeTitle( NS_MAIN, 'Foo' )->getPreviousRevisionID( 2233 );
1450	}
1451
1452	/**
1453	 * @covers Title::getNextRevisionID
1454	 * @covers Title::getRelativeRevisionID
1455	 */
1456	public function testGetNextRevisionID_deprecated() {
1457		$this->expectDeprecation();
1458		Title::makeTitle( NS_MAIN, 'Foo' )->getNextRevisionID( 123456789 );
1459	}
1460
1461	/**
1462	 * @covers Title::equals
1463	 * @dataProvider provideEquals
1464	 */
1465	public function testEquals( Title $firstValue, $secondValue, $expectedSame ) {
1466		$this->assertSame(
1467			$expectedSame,
1468			$firstValue->equals( $secondValue )
1469		);
1470	}
1471
1472	public function provideIsSamePageAs() {
1473		$title = Title::makeTitle( 0, 'Foo' );
1474		$title->resetArticleID( 1 );
1475		yield '(PageIdentityValue) same text, title has ID 0' => [
1476			$title,
1477			new PageIdentityValue( 1, 0, 'Foo', PageIdentity::LOCAL ),
1478			true
1479		];
1480
1481		$title = Title::makeTitle( 1, 'Bar_Baz' );
1482		$title->resetArticleID( 0 );
1483		yield '(PageIdentityValue) same text, PageIdentityValue has ID 0' => [
1484			$title,
1485			new PageIdentityValue( 0, 1, 'Bar_Baz', PageIdentity::LOCAL ),
1486			true
1487		];
1488
1489		$title = Title::makeTitle( 0, 'Foo' );
1490		$title->resetArticleID( 0 );
1491		yield '(PageIdentityValue) different text, both IDs are 0' => [
1492			$title,
1493			new PageIdentityValue( 0, 0, 'Foozz', PageIdentity::LOCAL ),
1494			false
1495		];
1496
1497		$title = Title::makeTitle( 0, 'Foo' );
1498		$title->resetArticleID( 0 );
1499		yield '(PageIdentityValue) different namespace' => [
1500			$title,
1501			new PageIdentityValue( 0, 1, 'Foo', PageIdentity::LOCAL ),
1502			false
1503		];
1504
1505		$title = Title::makeTitle( 0, 'Foo', '' );
1506		$title->resetArticleID( 1 );
1507		yield '(PageIdentityValue) different wiki, different ID' => [
1508			$title,
1509			new PageIdentityValue( 1, 0, 'Foo', 'bar' ),
1510			false
1511		];
1512
1513		$title = Title::makeTitle( 0, 'Foo', '' );
1514		$title->resetArticleID( 0 );
1515		yield '(PageIdentityValue) different wiki, both IDs are 0' => [
1516			$title,
1517			new PageIdentityValue( 0, 0, 'Foo', 'bar' ),
1518			false
1519		];
1520	}
1521
1522	/**
1523	 * @covers Title::isSamePageAs
1524	 * @dataProvider provideIsSamePageAs
1525	 */
1526	public function testIsSamePageAs( Title $firstValue, $secondValue, $expectedSame ) {
1527		$this->assertSame(
1528			$expectedSame,
1529			$firstValue->isSamePageAs( $secondValue )
1530		);
1531	}
1532
1533	public function provideIsSameLinkAs() {
1534		yield 'same text' => [
1535			Title::makeTitle( 0, 'Foo' ),
1536			new TitleValue( 0, 'Foo' ),
1537			true
1538		];
1539		yield 'same namespace' => [
1540			Title::makeTitle( 1, 'Bar_Baz' ),
1541			new TitleValue( 1, 'Bar_Baz' ),
1542			true
1543		];
1544		yield 'same text, different namespace' => [
1545			Title::makeTitle( 0, 'Foo' ),
1546			new TitleValue( 1, 'Foo' ),
1547			false
1548		];
1549		yield 'different text' => [
1550			Title::makeTitle( 0, 'Foo' ),
1551			new TitleValue( 0, 'Foozz' ),
1552			false
1553		];
1554		yield 'different fragment' => [
1555			Title::makeTitle( 0, 'Foo', '' ),
1556			new TitleValue( 0, 'Foo', 'Bar' ),
1557			false
1558		];
1559		yield 'different interwiki' => [
1560			Title::makeTitle( 0, 'Foo', '', 'bar' ),
1561			new TitleValue( 0, 'Foo', '', '' ),
1562			false
1563		];
1564	}
1565
1566	/**
1567	 * @covers Title::isSameLinkAs
1568	 * @dataProvider provideIsSameLinkAs
1569	 */
1570	public function testIsSameLinkAs( Title $firstValue, $secondValue, $expectedSame ) {
1571		$this->assertSame(
1572			$expectedSame,
1573			$firstValue->isSameLinkAs( $secondValue )
1574		);
1575	}
1576
1577	/**
1578	 * @covers Title::newMainPage
1579	 */
1580	public function testNewMainPage() {
1581		$mock = $this->createMock( MessageCache::class );
1582		$mock->method( 'get' )->willReturn( 'Foresheet' );
1583		$mock->method( 'transform' )->willReturn( 'Foresheet' );
1584
1585		$this->setService( 'MessageCache', $mock );
1586
1587		$this->assertSame(
1588			'Foresheet',
1589			Title::newMainPage()->getText()
1590		);
1591	}
1592
1593	/**
1594	 * @covers Title::newMainPage
1595	 */
1596	public function testNewMainPageWithLocal() {
1597		$local = $this->createMock( MessageLocalizer::class );
1598		$local->method( 'msg' )->willReturn( new RawMessage( 'Prime Article' ) );
1599
1600		$this->assertSame(
1601			'Prime Article',
1602			Title::newMainPage( $local )->getText()
1603		);
1604	}
1605
1606	/**
1607	 * @covers Title::loadRestrictions
1608	 */
1609	public function testLoadRestrictions() {
1610		$title = Title::newFromText( 'UTPage1' );
1611		$title->loadRestrictions();
1612		$this->assertTrue( $title->areRestrictionsLoaded() );
1613		$title = $this->getExistingTestPage( 'UTest1' )->getTitle();
1614		$title->loadRestrictions();
1615		$this->assertTrue( $title->areRestrictionsLoaded() );
1616		$this->assertEquals(
1617			$title->getRestrictionExpiry( 'create' ),
1618			'infinity'
1619		);
1620		$page = $this->getNonexistingTestPage( 'UTest1' );
1621		$title = $page->getTitle();
1622		$protectExpiry = wfTimestamp( TS_MW, time() + 10000 );
1623		$cascade = 0;
1624		$page->doUpdateRestrictions(
1625			[ 'create' => 'sysop' ],
1626			[ 'create' => $protectExpiry ],
1627			$cascade,
1628			'test',
1629			$this->getTestSysop()->getUser()
1630		);
1631		$title->mRestrictionsLoaded = false;
1632		$title->loadRestrictions();
1633		$this->assertSame(
1634			$title->getRestrictionExpiry( 'create' ),
1635			$protectExpiry
1636		);
1637	}
1638
1639	public function provideRestrictionsRows() {
1640		yield [ [ (object)[
1641			'pr_id' => 1,
1642			'pr_page' => 1,
1643			'pr_type' => 'edit',
1644			'pr_level' => 'sysop',
1645			'pr_cascade' => 0,
1646			'pr_user' => null,
1647			'pr_expiry' => 'infinity'
1648		] ] ];
1649		yield [ [ (object)[
1650			'pr_id' => 1,
1651			'pr_page' => 1,
1652			'pr_type' => 'edit',
1653			'pr_level' => 'sysop',
1654			'pr_cascade' => 0,
1655			'pr_user' => null,
1656			'pr_expiry' => 'infinity'
1657		] ] ];
1658		yield [ [ (object)[
1659			'pr_id' => 1,
1660			'pr_page' => 1,
1661			'pr_type' => 'move',
1662			'pr_level' => 'sysop',
1663			'pr_cascade' => 0,
1664			'pr_user' => null,
1665			'pr_expiry' => wfTimestamp( TS_MW, time() + 10000 )
1666		] ] ];
1667	}
1668
1669	/**
1670	 * @covers Title::loadRestrictionsFromRows
1671	 * @dataProvider provideRestrictionsRows
1672	 */
1673	public function testloadRestrictionsFromRows( $rows ) {
1674		$title = $this->getExistingTestPage( 'UTest1' )->getTitle();
1675		$title->loadRestrictionsFromRows( $rows );
1676		$this->assertSame(
1677			$rows[0]->pr_level,
1678			$title->getRestrictions( $rows[0]->pr_type )[0]
1679		);
1680		$this->assertSame(
1681			$rows[0]->pr_expiry,
1682			$title->getRestrictionExpiry( $rows[0]->pr_type )
1683		);
1684	}
1685
1686	/**
1687	 * @covers Title::getRestrictions
1688	 */
1689	public function testGetRestrictions() {
1690		$title = $this->getExistingTestPage( 'UTest1' )->getTitle();
1691		$title->mRestrictions = [
1692			'a' => [ 'sysop' ],
1693			'b' => [ 'sysop' ],
1694			'c' => [ 'sysop' ]
1695		];
1696		$title->mRestrictionsLoaded = true;
1697		$this->assertArrayEquals( [ 'sysop' ], $title->getRestrictions( 'a' ) );
1698		$this->assertArrayEquals( [], $title->getRestrictions( 'error' ) );
1699		// TODO: maybe test if loadRestrictionsFromRows() is called?
1700	}
1701
1702	/**
1703	 * @covers Title::getAllRestrictions
1704	 */
1705	public function testGetAllRestrictions() {
1706		$title = $this->getExistingTestPage( 'UTest1' )->getTitle();
1707		$title->mRestrictions = [
1708			'a' => [ 'sysop' ],
1709			'b' => [ 'sysop' ],
1710			'c' => [ 'sysop' ]
1711		];
1712		$title->mRestrictionsLoaded = true;
1713		$this->assertArrayEquals(
1714			$title->mRestrictions,
1715			$title->getAllRestrictions()
1716		);
1717	}
1718
1719	/**
1720	 * @covers Title::getRestrictionExpiry
1721	 */
1722	public function testGetRestrictionExpiry() {
1723		$title = $this->getExistingTestPage( 'UTest1' )->getTitle();
1724		$reflection = new ReflectionClass( $title );
1725		$reflection_property = $reflection->getProperty( 'mRestrictionsExpiry' );
1726		$reflection_property->setAccessible( true );
1727		$reflection_property->setValue( $title, [
1728			'a' => 'infinity', 'b' => 'infinity', 'c' => 'infinity'
1729		] );
1730		$title->mRestrictionsLoaded = true;
1731		$this->assertSame( 'infinity', $title->getRestrictionExpiry( 'a' ) );
1732		$this->assertArrayEquals( [], $title->getRestrictions( 'error' ) );
1733	}
1734
1735	/**
1736	 * @covers Title::getTitleProtection
1737	 */
1738	public function testGetTitleProtection() {
1739		$title = $this->getNonexistingTestPage( 'UTest1' )->getTitle();
1740		$title->mTitleProtection = false;
1741		$this->assertFalse( $title->getTitleProtection() );
1742	}
1743
1744	/**
1745	 * @covers Title::isSemiProtected
1746	 */
1747	public function testIsSemiProtected() {
1748		$title = $this->getExistingTestPage( 'UTest1' )->getTitle();
1749		$title->mRestrictions = [
1750			'edit' => [ 'sysop' ]
1751		];
1752		$this->setMwGlobals( [
1753			'wgSemiprotectedRestrictionLevels' => [ 'autoconfirmed' ],
1754			'wgRestrictionLevels' => [ '', 'autoconfirmed', 'sysop' ]
1755		] );
1756		$this->assertFalse( $title->isSemiProtected( 'edit' ) );
1757		$title->mRestrictions = [
1758			'edit' => [ 'autoconfirmed' ]
1759		];
1760		$this->assertTrue( $title->isSemiProtected( 'edit' ) );
1761	}
1762
1763	/**
1764	 * @covers Title::deleteTitleProtection
1765	 */
1766	public function testDeleteTitleProtection() {
1767		$title = $this->getExistingTestPage( 'UTest1' )->getTitle();
1768		$this->assertFalse( $title->getTitleProtection() );
1769	}
1770
1771	/**
1772	 * @covers Title::isProtected
1773	 */
1774	public function testIsProtected() {
1775		$title = $this->getExistingTestPage( 'UTest1' )->getTitle();
1776		$this->setMwGlobals( [
1777			'wgRestrictionLevels' => [ '', 'autoconfirmed', 'sysop' ],
1778			'wgRestrictionTypes' => [ 'create', 'edit', 'move', 'upload' ]
1779		] );
1780		$title->mRestrictions = [
1781			'edit' => [ 'sysop' ]
1782		];
1783		$this->assertFalse( $title->isProtected( 'edit' ) );
1784		$title->mRestrictions = [
1785			'edit' => [ 'test' ]
1786		];
1787		$this->assertFalse( $title->isProtected( 'edit' ) );
1788	}
1789
1790	/**
1791	 * @covers Title::isNamespaceProtected
1792	 */
1793	public function testIsNamespaceProtected() {
1794		$title = $this->getExistingTestPage( 'UTest1' )->getTitle();
1795		$this->setMwGlobals( [
1796			'wgNamespaceProtection' => []
1797		] );
1798		$this->assertFalse(
1799			$title->isNamespaceProtected( $this->getTestUser()->getUser() )
1800		);
1801		$this->setMwGlobals( [
1802			'wgNamespaceProtection' => [
1803				NS_MAIN => [ 'edit-main' ]
1804			]
1805		] );
1806		$this->assertTrue(
1807			$title->isNamespaceProtected( $this->getTestUser()->getUser() )
1808		);
1809	}
1810
1811	/**
1812	 * @covers Title::isCascadeProtected
1813	 */
1814	public function testIsCascadeProtected() {
1815		$page = $this->getExistingTestPage( 'UTest1' );
1816		$title = $page->getTitle();
1817		$reflection = new ReflectionClass( $title );
1818		$reflection_property = $reflection->getProperty( 'mHasCascadingRestrictions' );
1819		$reflection_property->setAccessible( true );
1820		$reflection_property->setValue( $title, true );
1821		$this->assertTrue( $title->isCascadeProtected() );
1822		$reflection_property->setValue( $title, null );
1823		$this->assertFalse( $title->isCascadeProtected() );
1824		$reflection_property->setValue( $title, null );
1825		$cascade = 1;
1826		$anotherPage = $this->getExistingTestPage( 'UTest2' );
1827		$anotherPage->doEditContent( new WikitextContent( '{{:UTest1}}' ), 'test' );
1828		$anotherPage->doUpdateRestrictions(
1829			[ 'edit' => 'sysop' ],
1830			[],
1831			$cascade,
1832			'test',
1833			$this->getTestSysop()->getUser()
1834		);
1835		$this->assertTrue( $title->isCascadeProtected() );
1836	}
1837
1838	/**
1839	 * @covers Title::getCascadeProtectionSources
1840	 */
1841	public function testGetCascadeProtectionSources() {
1842		$page = $this->getExistingTestPage( 'UTest1' );
1843		$title = $page->getTitle();
1844
1845		$title->mCascadeSources = [];
1846		$this->assertArrayEquals(
1847			[ [], [] ],
1848			$title->getCascadeProtectionSources( true )
1849		);
1850
1851		$reflection = new ReflectionClass( $title );
1852		$reflection_property = $reflection->getProperty( 'mHasCascadingRestrictions' );
1853		$reflection_property->setAccessible( true );
1854		$reflection_property->setValue( $title, true );
1855		$this->assertArrayEquals(
1856			[ true, [] ],
1857			$title->getCascadeProtectionSources( false )
1858		);
1859
1860		$title->mCascadeSources = null;
1861		$reflection_property->setValue( $title, null );
1862		$this->assertArrayEquals(
1863			[ false, [] ],
1864			$title->getCascadeProtectionSources( false )
1865		);
1866
1867		$title->mCascadeSources = null;
1868		$reflection_property->setValue( $title, null );
1869		$this->assertArrayEquals(
1870			[ [], [] ],
1871			$title->getCascadeProtectionSources( true )
1872		);
1873
1874		// TODO: this might partially duplicate testIsCascadeProtected method above
1875
1876		$cascade = 1;
1877		$anotherPage = $this->getExistingTestPage( 'UTest2' );
1878		$anotherPage->doEditContent( new WikitextContent( '{{:UTest1}}' ), 'test' );
1879		$anotherPage->doUpdateRestrictions(
1880			[ 'edit' => 'sysop' ],
1881			[],
1882			$cascade,
1883			'test',
1884			$this->getTestSysop()->getUser()
1885		);
1886
1887		$this->assertArrayEquals(
1888			[ true, [] ],
1889			$title->getCascadeProtectionSources( false )
1890		);
1891
1892		$title->mCascadeSources = null;
1893		$result = $title->getCascadeProtectionSources( true );
1894		$this->assertArrayEquals(
1895			[ 'edit' => [ 'sysop' ] ],
1896			$result[1]
1897		);
1898		$this->assertArrayHasKey(
1899			$anotherPage->getTitle()->getArticleID(), $result[0]
1900		);
1901	}
1902
1903	/**
1904	 * @covers Title::getCdnUrls
1905	 */
1906	public function testGetCdnUrls() {
1907		$this->assertEquals(
1908			[
1909				'https://example.org/wiki/Example',
1910				'https://example.org/w/index.php?title=Example&action=history',
1911			],
1912			Title::makeTitle( NS_MAIN, 'Example' )->getCdnUrls(),
1913			'article'
1914		);
1915	}
1916
1917	/**
1918	 * @covers \MediaWiki\Page\PageStore::getSubpages
1919	 */
1920	public function testGetSubpages() {
1921		$existingPage = $this->getExistingTestPage();
1922		$title = $existingPage->getTitle();
1923
1924		$this->setMwGlobals( 'wgNamespacesWithSubpages', [ $title->getNamespace() => true ] );
1925
1926		$this->getExistingTestPage( $title->getSubpage( 'A' ) );
1927		$this->getExistingTestPage( $title->getSubpage( 'B' ) );
1928
1929		$notQuiteSubpageTitle = $title->getPrefixedDBkey() . 'X'; // no slash!
1930		$this->getExistingTestPage( $notQuiteSubpageTitle );
1931
1932		$subpages = iterator_to_array( $title->getSubpages() );
1933
1934		$this->assertCount( 2, $subpages );
1935		$this->assertCount( 1, $title->getSubpages( 1 ) );
1936	}
1937
1938	/**
1939	 * @covers \MediaWiki\Page\PageStore::getSubpages
1940	 */
1941	public function testGetSubpages_disabled() {
1942		$this->setMwGlobals( 'wgNamespacesWithSubpages', [] );
1943
1944		$existingPage = $this->getExistingTestPage();
1945		$title = $existingPage->getTitle();
1946
1947		$this->getExistingTestPage( $title->getSubpage( 'A' ) );
1948		$this->getExistingTestPage( $title->getSubpage( 'B' ) );
1949
1950		$this->assertEmpty( $title->getSubpages() );
1951	}
1952}
1953