mediawiki-skins-Vector/tests/phpunit/integration/VectorHooksTest.php
Fomafix b35f956bca PHPUnit: Use FQCN with leading backslash in @covers annotation
https://docs.phpunit.de/en/11.4/annotations.html#covers recommends:

Please note that this annotation requires a fully-qualified class name
(FQCN). To make this more obvious to the reader, it is recommended to
use a leading backslash (even if this not required for the annotation
to work correctly).

Change-Id: I58d381d8a5b378d0086bdce0717f42e885ea5b87
2024-11-19 19:34:44 +00:00

728 lines
18 KiB
PHP

<?php
/*
* @file
* @ingroup skins
*/
namespace MediaWiki\Skins\Vector\Tests\Integration;
use MediaWiki\Config\HashConfig;
use MediaWiki\Context\RequestContext;
use MediaWiki\Request\FauxRequest;
use MediaWiki\ResourceLoader\Context;
use MediaWiki\Skins\Vector\Constants;
use MediaWiki\Skins\Vector\FeatureManagement\Requirements\LimitedWidthContentRequirement;
use MediaWiki\Skins\Vector\Hooks;
use MediaWiki\Skins\Vector\SkinVector22;
use MediaWiki\Skins\Vector\SkinVectorLegacy;
use MediaWiki\Title\Title;
use MediaWiki\User\Options\UserOptionsManager;
use MediaWiki\User\User;
use MediaWikiIntegrationTestCase;
use ReflectionMethod;
use RuntimeException;
/**
* Integration tests for Vector Hooks.
*
* @group Vector
* @coversDefaultClass \MediaWiki\Skins\Vector\Hooks
*/
class VectorHooksTest extends MediaWikiIntegrationTestCase {
/**
* @param bool $excludeMainPage
* @param array $excludeNamespaces
* @param array $include
* @param array $querystring
* @return array
*/
private static function makeMaxWidthConfig(
$excludeMainPage,
$excludeNamespaces = [],
$include = [],
$querystring = []
) {
return [
'exclude' => [
'mainpage' => $excludeMainPage,
'namespaces' => $excludeNamespaces,
'querystring' => $querystring,
],
'include' => $include
];
}
public static function provideGetActiveABTest() {
return [
[
[
'VectorWebABTestEnrollment' => [],
],
[]
],
[
[
'VectorWebABTestEnrollment' => [
'name' => 'vector.sticky_header',
'enabled' => true,
'buckets' => [
'unsampled' => [
'samplingRate' => 1,
],
'control' => [
'samplingRate' => 0
],
'stickyHeaderEnabled' => [
'samplingRate' => 0
],
'stickyHeaderDisabled' => [
'samplingRate' => 0
],
],
],
],
[
'name' => 'vector.sticky_header',
'enabled' => true,
'buckets' => [
'unsampled' => [
'samplingRate' => 1,
],
'control' => [
'samplingRate' => 0
],
'stickyHeaderEnabled' => [
'samplingRate' => 0
],
'stickyHeaderDisabled' => [
'samplingRate' => 0
],
],
]
],
];
}
public static function provideGetActiveABTestWithExceptions() {
return [
# Bad experiment (no buckets)
[
[
'VectorSearchApiUrl' => 'https://en.wikipedia.org/w/rest.php',
'VectorWebABTestEnrollment' => [
'name' => 'vector.sticky_header',
'enabled' => true,
],
]
],
# Bad experiment (no unsampled bucket)
[
[
'VectorSearchApiUrl' => 'https://en.wikipedia.org/w/rest.php',
'VectorWebABTestEnrollment' => [
'name' => 'vector.sticky_header',
'enabled' => true,
'buckets' => [
'a' => [
'samplingRate' => 0
],
]
],
]
],
# Bad experiment (wrong format)
[
[
'VectorSearchApiUrl' => 'https://en.wikipedia.org/w/rest.php',
'VectorWebABTestEnrollment' => [
'name' => 'vector.sticky_header',
'enabled' => true,
'buckets' => [
'unsampled' => 1,
]
],
]
],
# Bad experiment (samplingRate defined as string)
[
[
'VectorSearchApiUrl' => 'https://en.wikipedia.org/w/rest.php',
'VectorWebABTestEnrollment' => [
'name' => 'vector.sticky_header',
'enabled' => true,
'buckets' => [
'unsampled' => [
'samplingRate' => '1'
],
]
],
]
],
];
}
/**
* @covers ::shouldDisableMaxWidth
*/
public static function providerShouldDisableMaxWidth() {
$excludeTalkFooConfig = self::makeMaxWidthConfig(
false,
[ NS_TALK ],
[ 'Talk:Foo' ],
[]
);
return [
[
'No options, nothing disables max width',
[],
Title::makeTitle( NS_MAIN, 'Foo' ),
[],
false
],
[
'Main page disables max width if exclude.mainpage set',
self::makeMaxWidthConfig( true ),
Title::newMainPage(),
[],
true
],
[
'Namespaces can be excluded',
self::makeMaxWidthConfig( false, [ NS_CATEGORY ] ),
Title::makeTitle( NS_CATEGORY, 'Category' ),
[],
true
],
[
'Namespaces are included if not excluded',
self::makeMaxWidthConfig( false, [ NS_CATEGORY ] ),
Title::makeTitle( NS_SPECIAL, 'SpecialPages' ),
[],
false
],
[
'More than one namespace can be included',
self::makeMaxWidthConfig( false, [ NS_CATEGORY, NS_SPECIAL ] ),
Title::makeTitle( NS_SPECIAL, 'Specialpages' ),
[],
true
],
[
'Can be disabled on history page',
self::makeMaxWidthConfig(
false,
[
/* no namespaces excluded */
],
[
/* no includes */
],
[ 'action' => 'history' ]
),
Title::makeTitle( NS_MAIN, 'History page' ),
[ 'action' => 'history' ],
true
],
[
'Can be disabled with a regex match.',
self::makeMaxWidthConfig(
false,
[
/* no namespaces excluded */
],
[
/* no includes */
],
[ 'foo' => '[0-9]+' ]
),
Title::makeTitle( NS_MAIN, 'Test' ),
[ 'foo' => '1234' ],
true
],
[
'Can be disabled with a regex match, also for falsy 0.',
self::makeMaxWidthConfig(
false,
[
/* no namespaces excluded */
],
[
/* no includes */
],
[ 'foo' => '[0-9]+' ]
),
Title::makeTitle( NS_MAIN, 'Test' ),
[ 'foo' => '0' ],
true
],
[
'Can be disabled with a non-regex wildcard (for backwards compatibility).',
self::makeMaxWidthConfig(
false,
[
/* no namespaces excluded */
],
[
/* no includes */
],
[ 'foo' => '*' ]
),
Title::makeTitle( NS_MAIN, 'Test' ),
[ 'foo' => 'bar' ],
true
],
[
'Include can override exclusions',
self::makeMaxWidthConfig(
false,
[ NS_CATEGORY, NS_SPECIAL ],
[ 'Special:Specialpages' ],
[ 'action' => 'history' ]
),
Title::makeTitle( NS_SPECIAL, 'Specialpages' ),
[ 'action' => 'history' ],
true
],
[
'Max width can be disabled on talk pages',
$excludeTalkFooConfig,
Title::makeTitle( NS_TALK, 'A talk page' ),
[],
true
],
[
'includes can be used to override any page in a disabled namespace',
$excludeTalkFooConfig,
Title::makeTitle( NS_TALK, 'Foo' ),
[],
false
],
[
'Excludes/includes are based on root title so should apply to subpages',
$excludeTalkFooConfig,
Title::makeTitle( NS_TALK, 'Foo/subpage' ),
[],
false
]
];
}
/**
* @todo move into MediaWiki\Skins\Vector\FeatureManagement\Requirements\LimitedWidthContentRequirement
* test in future.
* @covers \MediaWiki\Skins\Vector\FeatureManagement\Requirements\LimitedWidthContentRequirement::isMet
* @dataProvider providerShouldDisableMaxWidth
*/
public function testShouldDisableMaxWidth(
$msg,
$options,
$title,
$requestValues,
$shouldDisableMaxWidth
) {
$requirement = new LimitedWidthContentRequirement(
new HashConfig( [ 'VectorMaxWidthOptions' => $options ] ),
new FauxRequest( $requestValues ),
$title
);
$this->assertSame(
!$shouldDisableMaxWidth,
$requirement->isMet(),
$msg
);
}
/**
* @covers ::getActiveABTest
* @dataProvider provideGetActiveABTest
*/
public function testGetActiveABTest( $configData, $expected ) {
$config = new HashConfig( $configData );
$vectorConfig = Hooks::getActiveABTest(
$this->createMock( Context::class ),
$config
);
$this->assertSame(
$expected,
$vectorConfig
);
}
/**
* @covers ::getActiveABTest
* @dataProvider provideGetActiveABTestWithExceptions
*/
public function testGetActiveABTestWithExceptions( $configData ) {
$config = new HashConfig( $configData );
$this->expectException( RuntimeException::class );
Hooks::getActiveABTest(
$this->createMock( Context::class ),
$config
);
}
/**
* @covers ::onLocalUserCreated
*/
public function testOnLocalUserCreatedLegacy() {
$config = new HashConfig( [
'VectorDefaultSkinVersionForNewAccounts' => Constants::SKIN_VERSION_LEGACY,
] );
$user = $this->createMock( User::class );
$userOptionsManager = $this->createMock( UserOptionsManager::class );
$userOptionsManager->expects( $this->once() )
->method( 'setOption' )
->with( $user, 'skin', Constants::SKIN_NAME_LEGACY );
// NOTE: Using $this->getServiceContainer()->getHookContainer()->run( ... )
// will instead call Echo's legacy hook as that is already registered which
// will break this test. Use Vector's hook handler instead.
( new Hooks(
$config,
$userOptionsManager
) )->onLocalUserCreated( $user, false );
}
/**
* @covers ::onLocalUserCreated
*/
public function testOnLocalUserCreatedLatest() {
$config = new HashConfig( [
'VectorDefaultSkinVersionForNewAccounts' => Constants::SKIN_VERSION_LATEST,
] );
$user = $this->createMock( User::class );
$userOptionsManager = $this->createMock( UserOptionsManager::class );
$userOptionsManager->expects( $this->once() )
->method( 'setOption' )
->with( $user, 'skin', Constants::SKIN_NAME_MODERN );
// NOTE: Using $this->getServiceContainer()->getHookContainer()->run( ... )
// will instead call Echo's legacy hook as that is already registered which
// will break this test. Use Vector's hook handler instead.
( new Hooks(
$config,
$userOptionsManager
) )->onLocalUserCreated( $user, false );
}
/**
* @covers ::onSkinTemplateNavigation
*/
public function testOnSkinTemplateNavigation() {
$this->overrideConfigValue( 'VectorUseIconWatch', true );
$skin = new SkinVector22(
$this->getServiceContainer()->getLanguageConverterFactory(),
$this->getServiceContainer()->get( 'Vector.FeatureManagerFactory' ),
[ 'name' => 'vector' ]
);
$skin->getContext()->setTitle( Title::newFromText( 'Foo' ) );
$contentNavWatch = [
'associated-pages' => [],
'views' => [],
'actions' => [
'watch' => [ 'class' => [ 'watch' ], 'icon' => 'star' ],
]
];
$contentNavUnWatch = [
'associated-pages' => [],
'views' => [],
'actions' => [
'move' => [ 'class' => [ 'move' ] ],
'unwatch' => [ 'icon' => 'unStar' ],
],
];
Hooks::onSkinTemplateNavigation( $skin, $contentNavUnWatch );
Hooks::onSkinTemplateNavigation( $skin, $contentNavWatch );
$this->assertContains(
'icon', $contentNavWatch['views']['watch']['class'],
'Watch list items require an "icon" class'
);
$this->assertContains(
'icon', $contentNavUnWatch['views']['unwatch']['class'],
'Unwatch list items require an "icon" class'
);
$this->assertNotContains(
'icon', $contentNavUnWatch['actions']['move']['class'],
'List item other than watch or unwatch should not have an "icon" class'
);
}
/**
* @covers ::updateUserLinksItems
*/
public function testUpdateUserLinksItems() {
$vector2022Skin = new SkinVector22(
$this->getServiceContainer()->getLanguageConverterFactory(),
$this->getServiceContainer()->get( 'Vector.FeatureManagerFactory' ),
[ 'name' => 'vector-2022' ]
);
$contentNav = [
'associated-pages' => [],
'views' => [],
'user-page' => [
'userpage' => [ 'class' => [], 'icon' => 'userpage' ],
],
'user-menu' => [
'login' => [ 'class' => [], 'icon' => 'login' ],
]
];
$vectorLegacySkin = new SkinVectorLegacy(
$this->getServiceContainer()->getLanguageConverterFactory(),
[ 'name' => 'vector' ]
);
$contentNavLegacy = [
'associated-pages' => [],
'views' => [],
'user-page' => [
'userpage' => [ 'class' => [], 'icon' => 'userpage' ],
]
];
Hooks::onSkinTemplateNavigation( $vector2022Skin, $contentNav );
$this->assertFalse( isset( $contentNav['user-page']['login'] ),
'updateUserLinksDropdownItems is called when not legacy'
);
Hooks::onSkinTemplateNavigation( $vectorLegacySkin, $contentNavLegacy );
$this->assertFalse( isset( $contentNavLegacy['user-page'] ),
'user-page is unset for legacy vector'
);
}
/**
* @covers ::updateUserLinksDropdownItems
*/
public function testUpdateUserLinksDropdownItems() {
$updateUserLinksDropdownItems = new ReflectionMethod(
Hooks::class,
'updateUserLinksDropdownItems'
);
$updateUserLinksDropdownItems->setAccessible( true );
// Anon users
$skin = new SkinVector22(
$this->getServiceContainer()->getLanguageConverterFactory(),
$this->getServiceContainer()->get( 'Vector.FeatureManagerFactory' ),
[ 'name' => 'vector-2022' ]
);
$contentAnon = [
'user-menu' => [
'anonuserpage' => [ 'class' => [], 'icon' => 'anonuserpage' ],
'createaccount' => [ 'class' => [], 'icon' => 'createaccount' ],
'login' => [ 'class' => [], 'icon' => 'login' ],
'anontalk' => [ 'class' => [], 'icon' => 'anontalk' ],
'anoncontribs' => [ 'class' => [], 'icon' => 'anoncontribs' ],
],
];
$updateUserLinksDropdownItems->invokeArgs( null, [ $skin, &$contentAnon ] );
$this->assertTrue(
count( $contentAnon['user-menu'] ) === 2 &&
isset( $contentAnon['user-menu']['createaccount'] ) &&
isset( $contentAnon['user-menu']['login'] ),
'Anon user page, anon talk, anon contribs links are removed from user-menu'
);
$this->assertTrue(
count( $contentAnon['user-menu'] ) === 2 &&
isset( $contentAnon['user-menu-anon-editor']['anontalk'] ) &&
isset( $contentAnon['user-menu-anon-editor']['anoncontribs'] ),
'Anon talk, anon contribs links are moved to user-menu-anon-editor'
);
// Registered user
$registeredUser = $this->createMock( User::class );
$registeredUser->method( 'isRegistered' )->willReturn( true );
$context = new RequestContext();
$context->setUser( $registeredUser );
$skin->setContext( $context );
$contentRegistered = [
'user-menu' => [
'userpage' => [ 'class' => [], 'icon' => 'userpage' ],
'watchlist' => [ 'class' => [], 'icon' => 'watchlist' ],
'logout' => [ 'class' => [], 'icon' => 'logout' ],
],
];
$updateUserLinksDropdownItems->invokeArgs( null, [ $skin, &$contentRegistered ] );
$this->assertContains( 'user-links-collapsible-item', $contentRegistered['user-menu']['userpage']['class'],
'User page link in user links dropdown requires collapsible class'
);
$this->assertEquals(
'<span class="vector-icon mw-ui-icon-userpage mw-ui-icon-wikimedia-userpage"></span>',
$contentRegistered['user-menu']['userpage']['link-html'],
'User page link in user links dropdown has link-html'
);
$this->assertContains( 'user-links-collapsible-item', $contentRegistered['user-menu']['watchlist']['class'],
'Watchlist link in user links dropdown requires collapsible class'
);
$this->assertEquals(
'<span class="vector-icon mw-ui-icon-watchlist mw-ui-icon-wikimedia-watchlist"></span>',
$contentRegistered['user-menu']['watchlist']['link-html'],
'Watchlist link in user links dropdown has link-html'
);
$this->assertFalse( isset( $contentRegistered['user-menu']['logout'] ),
'Logout link in user links dropdown is not set'
);
$this->assertTrue( isset( $contentRegistered['user-menu-logout']['logout'] ),
'Logout link in user links dropdown is not set'
);
}
public static function provideAppendClassToItem() {
return [
// Add array class to array
[
[],
[ 'array1', 'array2' ],
[ 'array1', 'array2' ],
],
// Add string class to array
[
[],
'array1 array2',
[ 'array1 array2' ],
],
// Add array class to string
[
'',
[ 'array1', 'array2' ],
'array1 array2',
],
// Add string class to string
[
'',
'array1 array2',
'array1 array2',
],
// Add string class to undefined
[
null,
'array1 array2',
'array1 array2',
],
// Add array class to undefined
[
null,
[ 'array1', 'array2' ],
[ 'array1', 'array2' ],
],
];
}
/**
* @covers ::appendClassToItem
* @dataProvider provideAppendClassToItem
*/
public function testAppendClassToItem(
$item,
$classes,
$expected
) {
$appendClassToItem = new ReflectionMethod(
Hooks::class,
'appendClassToItem'
);
$appendClassToItem->setAccessible( true );
$appendClassToItem->invokeArgs( null, [ &$item, $classes ] );
$this->assertEquals( $expected, $item );
}
public static function provideUpdateItemData() {
return [
// Removes extra attributes
[
[ 'class' => [], 'icon' => '', 'button' => false, 'text-hidden' => false, 'collapsible' => false ],
'link-class',
'link-html',
[ 'class' => [] ],
],
// Adds icon html
[
[ 'class' => [], 'icon' => 'userpage' ],
'link-class',
'link-html',
[
'class' => [],
'link-html' =>
'<span class="vector-icon mw-ui-icon-userpage mw-ui-icon-wikimedia-userpage"></span>'
],
],
// Adds collapsible class
[
[ 'class' => [], 'collapsible' => true ],
'link-class',
'link-html',
[ 'class' => [ 'user-links-collapsible-item' ] ],
],
// Adds button classes
[
[ 'class' => [], 'button' => true ],
'link-class',
'link-html',
[ 'class' => [], 'link-class' => [
'cdx-button',
'cdx-button--fake-button',
'cdx-button--fake-button--enabled',
'cdx-button--weight-quiet'
] ],
],
// Adds text hidden classes
[
[ 'class' => [], 'button' => true, 'text-hidden' => true, 'icon' => 'userpage' ],
'link-class',
'link-html',
[
'class' => [],
'link-class' => [
'cdx-button',
'cdx-button--fake-button',
'cdx-button--fake-button--enabled',
'cdx-button--weight-quiet',
'cdx-button--icon-only'
],
'link-html' =>
'<span class="vector-icon mw-ui-icon-userpage mw-ui-icon-wikimedia-userpage"></span>'
],
],
// Adds button and icon classes
[
[ 'class' => [], 'button' => true, 'icon' => 'userpage' ],
'class',
'link-html',
[ 'class' => [
'cdx-button',
'cdx-button--fake-button',
'cdx-button--fake-button--enabled',
'cdx-button--weight-quiet'
], 'link-html' =>
'<span class="vector-icon mw-ui-icon-userpage mw-ui-icon-wikimedia-userpage"></span>'
],
]
];
}
/**
* @covers ::updateItemData
* @dataProvider provideUpdateItemData
*/
public function testUpdateItemData(
array $item,
string $buttonClassProp,
string $iconHtmlProp,
array $expected
) {
$updateItemData = new ReflectionMethod(
Hooks::class,
'updateItemData'
);
$updateItemData->setAccessible( true );
$data = $updateItemData->invokeArgs( null, [ $item, $buttonClassProp, $iconHtmlProp ] );
$this->assertArraySubmapSame( $expected, $data );
}
}