Merge "docs: Fix typo"
[mediawiki.git] / tests / phpunit / includes / search / SearchEngineTest.php
blob9782f47068056601e424debedb22bc554e76eba1
1 <?php
3 use MediaWiki\Content\WikitextContent;
4 use MediaWiki\MainConfigNames;
6 /**
7 * @group Search
8 * @group Database
10 * @covers \SearchEngine<extended>
11 * @note Coverage will only ever show one of on of the Search* classes
13 class SearchEngineTest extends MediaWikiLangTestCase {
15 protected SearchEngine $search;
17 /**
18 * Checks for database type & version.
19 * Will skip current test if DB does not support search.
21 protected function setUp(): void {
22 parent::setUp();
24 // Search tests require MySQL or SQLite with FTS
25 $dbType = $this->getDb()->getType();
26 $dbSupported = ( $dbType === 'mysql' )
27 || ( $dbType === 'sqlite' && $this->getDb()->getFulltextSearchModule() == 'FTS3' );
29 if ( !$dbSupported ) {
30 $this->markTestSkipped( "MySQL or SQLite with FTS3 only" );
32 $dbProvider = $this->getServiceContainer()->getConnectionProvider();
34 $searchType = SearchEngineFactory::getSearchEngineClass( $dbProvider );
35 $this->overrideConfigValues( [
36 MainConfigNames::SearchType => $searchType,
37 MainConfigNames::CapitalLinks => true,
38 MainConfigNames::CapitalLinkOverrides => [
39 NS_CATEGORY => false // for testCompletionSearchMustRespectCapitalLinkOverrides
41 ] );
43 $this->search = new $searchType( $dbProvider );
44 $this->search->setHookContainer( $this->getServiceContainer()->getHookContainer() );
47 protected function tearDown(): void {
48 unset( $this->search );
50 parent::tearDown();
53 public function addDBDataOnce() {
54 if ( !$this->isWikitextNS( NS_MAIN ) ) {
55 // @todo cover the case of non-wikitext content in the main namespace
56 return;
59 // Reset the search type back to default - some extensions may have
60 // overridden it.
61 $this->overrideConfigValues( [
62 MainConfigNames::SearchType => null,
63 MainConfigNames::CapitalLinks => true,
64 MainConfigNames::CapitalLinkOverrides => [
65 NS_CATEGORY => false // for testCompletionSearchMustRespectCapitalLinkOverrides
67 ] );
69 $this->insertPage( 'Not_Main_Page', 'This is not a main page' );
70 $this->insertPage(
71 'Talk:Not_Main_Page',
72 'This is not a talk page to the main page, see [[smithee]]'
74 $this->insertPage( 'Smithee', 'A smithee is one who smiths. See also [[Alan Smithee]]' );
75 $this->insertPage( 'Talk:Smithee', 'This article sucks.' );
76 $this->insertPage( 'Unrelated_page', 'Nothing in this page is about the S word.' );
77 $this->insertPage( 'Another_page', 'This page also is unrelated.' );
78 $this->insertPage( 'Help:Help', 'Help me!' );
79 $this->insertPage( 'Thppt', 'Blah blah' );
80 $this->insertPage( 'Alan_Smithee', 'yum' );
81 $this->insertPage( 'Pages', 'are\'food' );
82 $this->insertPage( 'HalfOneUp', 'AZ' );
83 $this->insertPage( 'FullOneUp', 'AZ' );
84 $this->insertPage( 'HalfTwoLow', 'az' );
85 $this->insertPage( 'FullTwoLow', 'az' );
86 $this->insertPage( 'HalfNumbers', '1234567890' );
87 $this->insertPage( 'FullNumbers', '1234567890' );
88 $this->insertPage( 'DomainName', 'example.com' );
89 $this->insertPage( 'DomainName', 'example.com' );
90 $this->insertPage( 'Category:search is not Search', '' );
91 $this->insertPage( 'Category:Search is not search', '' );
92 $this->insertPage( 'Talk:1', 'Did you know titles can be numbers?' );
95 protected function fetchIds( $results ) {
96 if ( !$this->isWikitextNS( NS_MAIN ) ) {
97 $this->markTestIncomplete( __CLASS__ . " does no yet support non-wikitext content "
98 . "in the main namespace" );
100 $this->assertIsObject( $results );
102 $matches = [];
103 foreach ( $results as $row ) {
104 $matches[] = $row->getTitle()->getPrefixedText();
106 $results->free();
107 # Search is not guaranteed to return results in a certain order;
108 # sort them numerically so we will compare simply that we received
109 # the expected matches.
110 sort( $matches );
112 return $matches;
115 public function testFullWidth() {
116 // T303046
117 $this->markTestSkippedIfDbType( 'sqlite' );
118 $this->assertEquals(
119 [ 'FullOneUp', 'FullTwoLow', 'HalfOneUp', 'HalfTwoLow' ],
120 $this->fetchIds( $this->search->searchText( 'AZ' ) ),
121 "Search for normalized from Half-width Upper" );
122 $this->assertEquals(
123 [ 'FullOneUp', 'FullTwoLow', 'HalfOneUp', 'HalfTwoLow' ],
124 $this->fetchIds( $this->search->searchText( 'az' ) ),
125 "Search for normalized from Half-width Lower" );
126 $this->assertEquals(
127 [ 'FullOneUp', 'FullTwoLow', 'HalfOneUp', 'HalfTwoLow' ],
128 $this->fetchIds( $this->search->searchText( 'AZ' ) ),
129 "Search for normalized from Full-width Upper" );
130 $this->assertEquals(
131 [ 'FullOneUp', 'FullTwoLow', 'HalfOneUp', 'HalfTwoLow' ],
132 $this->fetchIds( $this->search->searchText( 'az' ) ),
133 "Search for normalized from Full-width Lower" );
136 public function testTextSearch() {
137 // T303046
138 $this->markTestSkippedIfDbType( 'sqlite' );
139 $this->assertEquals(
140 [ 'Smithee' ],
141 $this->fetchIds( $this->search->searchText( 'smithee' ) ),
142 "Plain search" );
145 public function testWildcardSearch() {
146 // T303046
147 $this->markTestSkippedIfDbType( 'sqlite' );
148 $res = $this->search->searchText( 'smith*' );
149 $this->assertEquals(
150 [ 'Smithee' ],
151 $this->fetchIds( $res ),
152 "Search with wildcards" );
154 $res = $this->search->searchText( 'smithson*' );
155 $this->assertEquals(
157 $this->fetchIds( $res ),
158 "Search with wildcards must not find unrelated articles" );
160 $res = $this->search->searchText( 'smith* smithee' );
161 $this->assertEquals(
162 [ 'Smithee' ],
163 $this->fetchIds( $res ),
164 "Search with wildcards can be combined with simple terms" );
166 $res = $this->search->searchText( 'smith* "one who smiths"' );
167 $this->assertEquals(
168 [ 'Smithee' ],
169 $this->fetchIds( $res ),
170 "Search with wildcards can be combined with phrase search" );
173 public function testPhraseSearch() {
174 // T303046
175 $this->markTestSkippedIfDbType( 'sqlite' );
176 $res = $this->search->searchText( '"smithee is one who smiths"' );
177 $this->assertEquals(
178 [ 'Smithee' ],
179 $this->fetchIds( $res ),
180 "Search a phrase" );
182 $res = $this->search->searchText( '"smithee is who smiths"' );
183 $this->assertEquals(
185 $this->fetchIds( $res ),
186 "Phrase search is not sloppy, search terms must be adjacent" );
188 $res = $this->search->searchText( '"is smithee one who smiths"' );
189 $this->assertEquals(
191 $this->fetchIds( $res ),
192 "Phrase search is ordered" );
195 public function testPhraseSearchHighlight() {
196 // T303046
197 $this->markTestSkippedIfDbType( 'sqlite' );
198 $phrase = "smithee is one who smiths";
199 $res = $this->search->searchText( "\"$phrase\"" );
200 $match = $res->getIterator()->current();
201 $snippet = 'A <span class="searchmatch">' . $phrase . '</span>';
202 $this->assertStringStartsWith( $snippet,
203 $match->getTextSnippet(),
204 "Highlight a phrase search" );
207 public function testTextPowerSearch() {
208 // T303046
209 $this->markTestSkippedIfDbType( 'sqlite' );
210 $this->search->setNamespaces( [ 0, 1, 4 ] );
211 $this->assertEquals(
213 'Smithee',
214 'Talk:Not Main Page',
216 $this->fetchIds( $this->search->searchText( 'smithee' ) ),
217 "Power search" );
220 public function testTitleSearch() {
221 // T303046
222 $this->markTestSkippedIfDbType( 'sqlite' );
223 $this->assertEquals(
225 'Alan Smithee',
226 'Smithee',
228 $this->fetchIds( $this->search->searchTitle( 'smithee' ) ),
229 "Title search" );
232 public function testTextTitlePowerSearch() {
233 // T303046
234 $this->markTestSkippedIfDbType( 'sqlite' );
235 $this->search->setNamespaces( [ 0, 1, 4 ] );
236 $this->assertEquals(
238 'Alan Smithee',
239 'Smithee',
240 'Talk:Smithee',
242 $this->fetchIds( $this->search->searchTitle( 'smithee' ) ),
243 "Title power search" );
246 public static function provideCompletionSearchMustRespectCapitalLinkOverrides() {
247 return [
248 'Searching for "smithee" finds Smithee on NS_MAIN' => [
249 'smithee',
250 'Smithee',
251 [ NS_MAIN ],
253 'Searching for "search is" will finds "search is not Search" on NS_CATEGORY' => [
254 'search is',
255 'Category:search is not Search',
256 [ NS_CATEGORY ],
258 'Searching for "Search is" will finds "search is not Search" on NS_CATEGORY' => [
259 'Search is',
260 'Category:Search is not search',
261 [ NS_CATEGORY ],
263 'Copy-pasted wikilinks with invalid characters will still find the page' => [
264 '[[smithee]]',
265 'Smithee',
266 [ NS_MAIN ],
268 'Numeric title works (T365565)' => [
269 '1',
270 'Talk:1',
271 [ NS_TALK ],
277 * Test that the search query is not munged using wrong CapitalLinks setup
278 * (in other test that the default search backend can benefit from wgCapitalLinksOverride)
279 * Guard against regressions like T208255
280 * @dataProvider provideCompletionSearchMustRespectCapitalLinkOverrides
281 * @covers \SearchEngine::completionSearch
282 * @covers \PrefixSearch::defaultSearchBackend
283 * @param string $search
284 * @param string $expectedSuggestion
285 * @param int[] $namespaces
287 public function testCompletionSearchMustRespectCapitalLinkOverrides(
288 $search,
289 $expectedSuggestion,
290 array $namespaces
292 $this->search->setNamespaces( $namespaces );
293 $results = $this->search->completionSearch( $search );
294 $this->assertSame( 1, $results->getSize() );
295 $this->assertEquals( $expectedSuggestion, $results->getSuggestions()[0]->getText() );
299 * @covers \SearchEngine::getSearchIndexFields
301 public function testSearchIndexFields() {
303 * @var SearchEngine $mockEngine
305 $mockEngine = $this->getMockBuilder( SearchEngine::class )
306 ->onlyMethods( [ 'makeSearchFieldMapping' ] )->getMock();
308 $mockFieldBuilder = function ( $name, $type ) {
309 $mockField =
310 $this->getMockBuilder( SearchIndexFieldDefinition::class )->setConstructorArgs( [
311 $name,
312 $type,
313 ] )->getMock();
315 $mockField->method( 'getMapping' )->willReturn( [
316 'testData' => 'test',
317 'name' => $name,
318 'type' => $type,
319 ] );
321 $mockField->method( 'merge' )->willReturnSelf();
323 return $mockField;
326 $mockEngine->expects( $this->atLeastOnce() )
327 ->method( 'makeSearchFieldMapping' )
328 ->willReturnCallback( $mockFieldBuilder );
330 // Not using mock since PHPUnit mocks do not work properly with references in params
331 $this->setTemporaryHook( 'SearchIndexFields',
332 static function ( &$fields, SearchEngine $engine ) use ( $mockFieldBuilder ) {
333 $fields['testField'] =
334 $mockFieldBuilder( "testField", SearchIndexField::INDEX_TYPE_TEXT );
335 return true;
336 } );
337 $mockEngine->setHookContainer( $this->getServiceContainer()->getHookContainer() );
339 $fields = $mockEngine->getSearchIndexFields();
340 $this->assertArrayHasKey( 'language', $fields );
341 $this->assertArrayHasKey( 'category', $fields );
342 $this->assertInstanceOf( SearchIndexField::class, $fields['testField'] );
344 $mapping = $fields['testField']->getMapping( $mockEngine );
345 $this->assertArrayHasKey( 'testData', $mapping );
346 $this->assertEquals( 'test', $mapping['testData'] );
349 public function hookSearchIndexFields( $mockFieldBuilder, &$fields, SearchEngine $engine ) {
350 $fields['testField'] = $mockFieldBuilder( "testField", SearchIndexField::INDEX_TYPE_TEXT );
351 return true;
354 public function testAugmentorSearch() {
355 // T303046
356 $this->markTestSkippedIfDbType( 'sqlite' );
358 $this->search->setHookContainer(
359 $this->createHookContainer( [ 'SearchResultsAugment' => [ $this, 'addAugmentors' ] ] )
362 $this->search->setNamespaces( [ 0, 1, 4 ] );
363 $resultSet = $this->search->searchText( 'smithee' );
364 $this->search->augmentSearchResults( $resultSet );
365 foreach ( $resultSet as $result ) {
366 $id = $result->getTitle()->getArticleID();
367 $augmentData = "Result:$id:" . $result->getTitle()->getText();
368 $augmentData2 = "Result2:$id:" . $result->getTitle()->getText();
369 $this->assertEquals( [ 'testSet' => $augmentData, 'testRow' => $augmentData2 ],
370 $result->getExtensionData() );
374 public function addAugmentors( &$setAugmentors, &$rowAugmentors ) {
375 $setAugmentor = $this->createMock( ResultSetAugmentor::class );
376 $setAugmentor->expects( $this->once() )
377 ->method( 'augmentAll' )
378 ->willReturnCallback( static function ( ISearchResultSet $resultSet ) {
379 $data = [];
380 /** @var SearchResult $result */
381 foreach ( $resultSet as $result ) {
382 $id = $result->getTitle()->getArticleID();
383 $data[$id] = "Result:$id:" . $result->getTitle()->getText();
385 return $data;
386 } );
387 $setAugmentors['testSet'] = $setAugmentor;
389 $rowAugmentor = $this->createMock( ResultAugmentor::class );
390 $rowAugmentor->expects( $this->exactly( 2 ) )
391 ->method( 'augment' )
392 ->willReturnCallback( static function ( SearchResult $result ) {
393 $id = $result->getTitle()->getArticleID();
394 return "Result2:$id:" . $result->getTitle()->getText();
395 } );
396 $rowAugmentors['testRow'] = $rowAugmentor;
399 public function testFiltersMissing() {
400 $availableResults = [];
401 $user = $this->getTestSysop()->getAuthority();
402 foreach ( range( 0, 11 ) as $i ) {
403 $title = "Search_Result_$i";
404 $availableResults[] = $title;
405 // pages not created must be filtered
406 if ( $i % 2 == 0 ) {
407 $this->editPage(
408 $title,
409 new WikitextContent( 'TestFiltersMissing content' ),
410 'TestFiltersMissing summary',
411 NS_MAIN,
412 $user
416 MockCompletionSearchEngine::addMockResults( 'foo', $availableResults );
418 $engine = new MockCompletionSearchEngine();
419 $engine->setLimitOffset( 10, 0 );
420 $engine->setHookContainer( $this->getServiceContainer()->getHookContainer() );
421 $results = $engine->completionSearch( 'foo' );
422 $this->assertEquals( 5, $results->getSize() );
423 $this->assertTrue( $results->hasMoreResults() );
425 $engine->setLimitOffset( 10, 10 );
426 $results = $engine->completionSearch( 'foo' );
427 $this->assertSame( 1, $results->getSize() );
428 $this->assertFalse( $results->hasMoreResults() );
431 public static function provideDataForParseNamespacePrefix() {
432 return [
433 'noop' => [
435 'query' => 'foo',
437 false,
439 'empty' => [
441 'query' => '',
443 false,
445 'namespace prefix' => [
447 'query' => 'help:test',
449 [ 'test', [ NS_HELP ] ],
451 'accented namespace prefix with hook' => [
453 'query' => 'hélp:test',
454 'withHook' => true,
456 [ 'test', [ NS_HELP ] ],
458 'accented namespace prefix without hook' => [
460 'query' => 'hélp:test',
461 'withHook' => false,
463 false,
465 'all with all keyword allowed' => [
467 'query' => 'all:test',
468 'withAll' => true,
470 [ 'test', null ],
472 'all with all keyword disallowed' => [
474 'query' => 'all:test',
475 'withAll' => false,
477 false,
479 'ns only' => [
481 'query' => 'help:',
483 [ '', [ NS_HELP ] ],
485 'all only' => [
487 'query' => 'all:',
488 'withAll' => true,
490 [ '', null ],
492 'all wins over namespace when first' => [
494 'query' => 'all:help:test',
495 'withAll' => true,
497 [ 'help:test', null ],
499 'ns wins over all when first' => [
501 'query' => 'help:all:test',
502 'withAll' => true,
504 [ 'all:test', [ NS_HELP ] ],
510 * @dataProvider provideDataForParseNamespacePrefix
512 public function testParseNamespacePrefix( array $params, $expected ) {
513 $this->setTemporaryHook( 'PrefixSearchExtractNamespace', static function ( &$namespaces, &$query ) {
514 if ( str_starts_with( $query, 'hélp:' ) ) {
515 $namespaces = [ NS_HELP ];
516 $query = substr( $query, strlen( 'hélp:' ) );
518 return false;
519 } );
520 $testSet = [];
521 if ( isset( $params['withAll'] ) && isset( $params['withHook'] ) ) {
522 $testSet[] = $params;
523 } elseif ( isset( $params['withAll'] ) ) {
524 $testSet[] = $params + [ 'withHook' => true ];
525 $testSet[] = $params + [ 'withHook' => false ];
526 } elseif ( isset( $params['withHook'] ) ) {
527 $testSet[] = $params + [ 'withAll' => true ];
528 $testSet[] = $params + [ 'withAll' => false ];
529 } else {
530 $testSet[] = $params + [ 'withAll' => true, 'withHook' => true ];
531 $testSet[] = $params + [ 'withAll' => true, 'withHook' => false ];
532 $testSet[] = $params + [ 'withAll' => false, 'withHook' => false ];
533 $testSet[] = $params + [ 'withAll' => true, 'withHook' => false ];
536 foreach ( $testSet as $test ) {
537 $actual = SearchEngine::parseNamespacePrefixes( $test['query'],
538 $test['withAll'], $test['withHook'] );
539 $this->assertEquals( $expected, $actual, 'with params: ' . print_r( $test, true ) );