Merge "Chinese Conversion Table Update 2018-3"
[lhc/web/wiklou.git] / tests / phpunit / includes / search / SearchEngineTest.php
1 <?php
2
3 /**
4 * @group Search
5 * @group Database
6 *
7 * @covers SearchEngine<extended>
8 * @note Coverage will only ever show one of on of the Search* classes
9 */
10 class SearchEngineTest extends MediaWikiLangTestCase {
11
12 /**
13 * @var SearchEngine
14 */
15 protected $search;
16
17 /**
18 * Checks for database type & version.
19 * Will skip current test if DB does not support search.
20 */
21 protected function setUp() {
22 parent::setUp();
23
24 // Search tests require MySQL or SQLite with FTS
25 $dbType = $this->db->getType();
26 $dbSupported = ( $dbType === 'mysql' )
27 || ( $dbType === 'sqlite' && $this->db->getFulltextSearchModule() == 'FTS3' );
28
29 if ( !$dbSupported ) {
30 $this->markTestSkipped( "MySQL or SQLite with FTS3 only" );
31 }
32
33 $searchType = SearchEngineFactory::getSearchEngineClass( $this->db );
34 $this->setMwGlobals( [
35 'wgSearchType' => $searchType,
36 'wgCapitalLinks' => true,
37 'wgCapitalLinkOverrides' => [
38 NS_CATEGORY => false // for testCompletionSearchMustRespectCapitalLinkOverrides
39 ]
40 ] );
41
42 $this->search = new $searchType( $this->db );
43 }
44
45 protected function tearDown() {
46 unset( $this->search );
47
48 parent::tearDown();
49 }
50
51 public function addDBDataOnce() {
52 if ( !$this->isWikitextNS( NS_MAIN ) ) {
53 // @todo cover the case of non-wikitext content in the main namespace
54 return;
55 }
56
57 // Reset the search type back to default - some extensions may have
58 // overridden it.
59 $this->setMwGlobals( [
60 'wgSearchType' => null,
61 'wgCapitalLinks' => true,
62 'wgCapitalLinkOverrides' => [
63 NS_CATEGORY => false // for testCompletionSearchMustRespectCapitalLinkOverrides
64 ]
65 ] );
66
67 $this->insertPage( 'Not_Main_Page', 'This is not a main page' );
68 $this->insertPage(
69 'Talk:Not_Main_Page',
70 'This is not a talk page to the main page, see [[smithee]]'
71 );
72 $this->insertPage( 'Smithee', 'A smithee is one who smiths. See also [[Alan Smithee]]' );
73 $this->insertPage( 'Talk:Smithee', 'This article sucks.' );
74 $this->insertPage( 'Unrelated_page', 'Nothing in this page is about the S word.' );
75 $this->insertPage( 'Another_page', 'This page also is unrelated.' );
76 $this->insertPage( 'Help:Help', 'Help me!' );
77 $this->insertPage( 'Thppt', 'Blah blah' );
78 $this->insertPage( 'Alan_Smithee', 'yum' );
79 $this->insertPage( 'Pages', 'are\'food' );
80 $this->insertPage( 'HalfOneUp', 'AZ' );
81 $this->insertPage( 'FullOneUp', 'AZ' );
82 $this->insertPage( 'HalfTwoLow', 'az' );
83 $this->insertPage( 'FullTwoLow', 'az' );
84 $this->insertPage( 'HalfNumbers', '1234567890' );
85 $this->insertPage( 'FullNumbers', '1234567890' );
86 $this->insertPage( 'DomainName', 'example.com' );
87 $this->insertPage( 'DomainName', 'example.com' );
88 $this->insertPage( 'Category:search is not Search', '' );
89 $this->insertPage( 'Category:Search is not search', '' );
90 }
91
92 protected function fetchIds( $results ) {
93 if ( !$this->isWikitextNS( NS_MAIN ) ) {
94 $this->markTestIncomplete( __CLASS__ . " does no yet support non-wikitext content "
95 . "in the main namespace" );
96 }
97 $this->assertTrue( is_object( $results ) );
98
99 $matches = [];
100 foreach ( $results as $row ) {
101 $matches[] = $row->getTitle()->getPrefixedText();
102 }
103 $results->free();
104 # Search is not guaranteed to return results in a certain order;
105 # sort them numerically so we will compare simply that we received
106 # the expected matches.
107 sort( $matches );
108
109 return $matches;
110 }
111
112 public function testFullWidth() {
113 $this->assertEquals(
114 [ 'FullOneUp', 'FullTwoLow', 'HalfOneUp', 'HalfTwoLow' ],
115 $this->fetchIds( $this->search->searchText( 'AZ' ) ),
116 "Search for normalized from Half-width Upper" );
117 $this->assertEquals(
118 [ 'FullOneUp', 'FullTwoLow', 'HalfOneUp', 'HalfTwoLow' ],
119 $this->fetchIds( $this->search->searchText( 'az' ) ),
120 "Search for normalized from Half-width Lower" );
121 $this->assertEquals(
122 [ 'FullOneUp', 'FullTwoLow', 'HalfOneUp', 'HalfTwoLow' ],
123 $this->fetchIds( $this->search->searchText( 'AZ' ) ),
124 "Search for normalized from Full-width Upper" );
125 $this->assertEquals(
126 [ 'FullOneUp', 'FullTwoLow', 'HalfOneUp', 'HalfTwoLow' ],
127 $this->fetchIds( $this->search->searchText( 'az' ) ),
128 "Search for normalized from Full-width Lower" );
129 }
130
131 public function testTextSearch() {
132 $this->assertEquals(
133 [ 'Smithee' ],
134 $this->fetchIds( $this->search->searchText( 'smithee' ) ),
135 "Plain search" );
136 }
137
138 public function testWildcardSearch() {
139 $res = $this->search->searchText( 'smith*' );
140 $this->assertEquals(
141 [ 'Smithee' ],
142 $this->fetchIds( $res ),
143 "Search with wildcards" );
144
145 $res = $this->search->searchText( 'smithson*' );
146 $this->assertEquals(
147 [],
148 $this->fetchIds( $res ),
149 "Search with wildcards must not find unrelated articles" );
150
151 $res = $this->search->searchText( 'smith* smithee' );
152 $this->assertEquals(
153 [ 'Smithee' ],
154 $this->fetchIds( $res ),
155 "Search with wildcards can be combined with simple terms" );
156
157 $res = $this->search->searchText( 'smith* "one who smiths"' );
158 $this->assertEquals(
159 [ 'Smithee' ],
160 $this->fetchIds( $res ),
161 "Search with wildcards can be combined with phrase search" );
162 }
163
164 public function testPhraseSearch() {
165 $res = $this->search->searchText( '"smithee is one who smiths"' );
166 $this->assertEquals(
167 [ 'Smithee' ],
168 $this->fetchIds( $res ),
169 "Search a phrase" );
170
171 $res = $this->search->searchText( '"smithee is who smiths"' );
172 $this->assertEquals(
173 [],
174 $this->fetchIds( $res ),
175 "Phrase search is not sloppy, search terms must be adjacent" );
176
177 $res = $this->search->searchText( '"is smithee one who smiths"' );
178 $this->assertEquals(
179 [],
180 $this->fetchIds( $res ),
181 "Phrase search is ordered" );
182 }
183
184 public function testPhraseSearchHighlight() {
185 $phrase = "smithee is one who smiths";
186 $res = $this->search->searchText( "\"$phrase\"" );
187 $match = $res->getIterator()->current();
188 $snippet = "A <span class='searchmatch'>" . $phrase . "</span>";
189 $this->assertStringStartsWith( $snippet,
190 $match->getTextSnippet( $res->termMatches() ),
191 "Highlight a phrase search" );
192 }
193
194 public function testTextPowerSearch() {
195 $this->search->setNamespaces( [ 0, 1, 4 ] );
196 $this->assertEquals(
197 [
198 'Smithee',
199 'Talk:Not Main Page',
200 ],
201 $this->fetchIds( $this->search->searchText( 'smithee' ) ),
202 "Power search" );
203 }
204
205 public function testTitleSearch() {
206 $this->assertEquals(
207 [
208 'Alan Smithee',
209 'Smithee',
210 ],
211 $this->fetchIds( $this->search->searchTitle( 'smithee' ) ),
212 "Title search" );
213 }
214
215 public function testTextTitlePowerSearch() {
216 $this->search->setNamespaces( [ 0, 1, 4 ] );
217 $this->assertEquals(
218 [
219 'Alan Smithee',
220 'Smithee',
221 'Talk:Smithee',
222 ],
223 $this->fetchIds( $this->search->searchTitle( 'smithee' ) ),
224 "Title power search" );
225 }
226
227 public function provideCompletionSearchMustRespectCapitalLinkOverrides() {
228 return [
229 'Searching for "smithee" finds Smithee on NS_MAIN' => [
230 'smithee',
231 'Smithee',
232 [ NS_MAIN ],
233 ],
234 'Searching for "search is" will finds "search is not Search" on NS_CATEGORY' => [
235 'search is',
236 'Category:search is not Search',
237 [ NS_CATEGORY ],
238 ],
239 'Searching for "Search is" will finds "search is not Search" on NS_CATEGORY' => [
240 'Search is',
241 'Category:Search is not search',
242 [ NS_CATEGORY ],
243 ],
244 ];
245 }
246
247 /**
248 * Test that the search query is not munged using wrong CapitalLinks setup
249 * (in other test that the default search backend can benefit from wgCapitalLinksOverride)
250 * Guard against regressions like T208255
251 * @dataProvider provideCompletionSearchMustRespectCapitalLinkOverrides
252 * @covers SearchEngine::completionSearch
253 * @covers PrefixSearch::defaultSearchBackend
254 * @param string $search
255 * @param string $expectedSuggestion
256 * @param int[] $namespaces
257 */
258 public function testCompletionSearchMustRespectCapitalLinkOverrides(
259 $search,
260 $expectedSuggestion,
261 array $namespaces
262 ) {
263 $this->search->setNamespaces( $namespaces );
264 $results = $this->search->completionSearch( $search );
265 $this->assertEquals( 1, $results->getSize() );
266 $this->assertEquals( $expectedSuggestion, $results->getSuggestions()[0]->getText() );
267 }
268
269 /**
270 * @covers SearchEngine::getSearchIndexFields
271 */
272 public function testSearchIndexFields() {
273 /**
274 * @var $mockEngine SearchEngine
275 */
276 $mockEngine = $this->getMockBuilder( SearchEngine::class )
277 ->setMethods( [ 'makeSearchFieldMapping' ] )->getMock();
278
279 $mockFieldBuilder = function ( $name, $type ) {
280 $mockField =
281 $this->getMockBuilder( SearchIndexFieldDefinition::class )->setConstructorArgs( [
282 $name,
283 $type
284 ] )->getMock();
285
286 $mockField->expects( $this->any() )->method( 'getMapping' )->willReturn( [
287 'testData' => 'test',
288 'name' => $name,
289 'type' => $type,
290 ] );
291
292 $mockField->expects( $this->any() )
293 ->method( 'merge' )
294 ->willReturn( $mockField );
295
296 return $mockField;
297 };
298
299 $mockEngine->expects( $this->atLeastOnce() )
300 ->method( 'makeSearchFieldMapping' )
301 ->willReturnCallback( $mockFieldBuilder );
302
303 // Not using mock since PHPUnit mocks do not work properly with references in params
304 $this->setTemporaryHook( 'SearchIndexFields',
305 function ( &$fields, SearchEngine $engine ) use ( $mockFieldBuilder ) {
306 $fields['testField'] =
307 $mockFieldBuilder( "testField", SearchIndexField::INDEX_TYPE_TEXT );
308 return true;
309 } );
310
311 $fields = $mockEngine->getSearchIndexFields();
312 $this->assertArrayHasKey( 'language', $fields );
313 $this->assertArrayHasKey( 'category', $fields );
314 $this->assertInstanceOf( SearchIndexField::class, $fields['testField'] );
315
316 $mapping = $fields['testField']->getMapping( $mockEngine );
317 $this->assertArrayHasKey( 'testData', $mapping );
318 $this->assertEquals( 'test', $mapping['testData'] );
319 }
320
321 public function hookSearchIndexFields( $mockFieldBuilder, &$fields, SearchEngine $engine ) {
322 $fields['testField'] = $mockFieldBuilder( "testField", SearchIndexField::INDEX_TYPE_TEXT );
323 return true;
324 }
325
326 public function testAugmentorSearch() {
327 $this->search->setNamespaces( [ 0, 1, 4 ] );
328 $resultSet = $this->search->searchText( 'smithee' );
329 // Not using mock since PHPUnit mocks do not work properly with references in params
330 $this->mergeMwGlobalArrayValue( 'wgHooks',
331 [ 'SearchResultsAugment' => [ [ $this, 'addAugmentors' ] ] ] );
332 $this->search->augmentSearchResults( $resultSet );
333 foreach ( $resultSet as $result ) {
334 $id = $result->getTitle()->getArticleID();
335 $augmentData = "Result:$id:" . $result->getTitle()->getText();
336 $augmentData2 = "Result2:$id:" . $result->getTitle()->getText();
337 $this->assertEquals( [ 'testSet' => $augmentData, 'testRow' => $augmentData2 ],
338 $result->getExtensionData() );
339 }
340 }
341
342 public function addAugmentors( &$setAugmentors, &$rowAugmentors ) {
343 $setAugmentor = $this->createMock( ResultSetAugmentor::class );
344 $setAugmentor->expects( $this->once() )
345 ->method( 'augmentAll' )
346 ->willReturnCallback( function ( SearchResultSet $resultSet ) {
347 $data = [];
348 foreach ( $resultSet as $result ) {
349 $id = $result->getTitle()->getArticleID();
350 $data[$id] = "Result:$id:" . $result->getTitle()->getText();
351 }
352 return $data;
353 } );
354 $setAugmentors['testSet'] = $setAugmentor;
355
356 $rowAugmentor = $this->createMock( ResultAugmentor::class );
357 $rowAugmentor->expects( $this->exactly( 2 ) )
358 ->method( 'augment' )
359 ->willReturnCallback( function ( SearchResult $result ) {
360 $id = $result->getTitle()->getArticleID();
361 return "Result2:$id:" . $result->getTitle()->getText();
362 } );
363 $rowAugmentors['testRow'] = $rowAugmentor;
364 }
365
366 public function testFiltersMissing() {
367 $availableResults = [];
368 foreach ( range( 0, 11 ) as $i ) {
369 $title = "Search_Result_$i";
370 $availableResults[] = $title;
371 // pages not created must be filtered
372 if ( $i % 2 == 0 ) {
373 $this->editSearchResultPage( $title );
374 }
375 }
376 MockCompletionSearchEngine::addMockResults( 'foo', $availableResults );
377
378 $engine = new MockCompletionSearchEngine();
379 $engine->setLimitOffset( 10, 0 );
380 $results = $engine->completionSearch( 'foo' );
381 $this->assertEquals( 5, $results->getSize() );
382 $this->assertTrue( $results->hasMoreResults() );
383
384 $engine->setLimitOffset( 10, 10 );
385 $results = $engine->completionSearch( 'foo' );
386 $this->assertEquals( 1, $results->getSize() );
387 $this->assertFalse( $results->hasMoreResults() );
388 }
389
390 private function editSearchResultPage( $title ) {
391 $page = WikiPage::factory( Title::newFromText( $title ) );
392 $page->doEditContent(
393 new WikitextContent( 'UTContent' ),
394 'UTPageSummary',
395 EDIT_NEW | EDIT_SUPPRESS_RC
396 );
397 }
398
399 public function provideDataForParseNamespacePrefix() {
400 return [
401 'noop' => [
402 [
403 'query' => 'foo',
404 ],
405 false
406 ],
407 'empty' => [
408 [
409 'query' => '',
410 ],
411 false,
412 ],
413 'namespace prefix' => [
414 [
415 'query' => 'help:test',
416 ],
417 [ 'test', [ NS_HELP ] ],
418 ],
419 'accented namespace prefix with hook' => [
420 [
421 'query' => 'hélp:test',
422 'withHook' => true,
423 ],
424 [ 'test', [ NS_HELP ] ],
425 ],
426 'accented namespace prefix without hook' => [
427 [
428 'query' => 'hélp:test',
429 'withHook' => false,
430 ],
431 false,
432 ],
433 'all with all keyword allowed' => [
434 [
435 'query' => 'all:test',
436 'withAll' => true,
437 ],
438 [ 'test', null ],
439 ],
440 'all with all keyword disallowed' => [
441 [
442 'query' => 'all:test',
443 'withAll' => false,
444 ],
445 false
446 ],
447 'ns only' => [
448 [
449 'query' => 'help:',
450 ],
451 [ '', [ NS_HELP ] ]
452 ],
453 'all only' => [
454 [
455 'query' => 'all:',
456 'withAll' => true,
457 ],
458 [ '', null ]
459 ],
460 'all wins over namespace when first' => [
461 [
462 'query' => 'all:help:test',
463 'withAll' => true,
464 ],
465 [ 'help:test', null ]
466 ],
467 'ns wins over all when first' => [
468 [
469 'query' => 'help:all:test',
470 'withAll' => true,
471 ],
472 [ 'all:test', [ NS_HELP ] ]
473 ],
474 ];
475 }
476
477 /**
478 * @dataProvider provideDataForParseNamespacePrefix
479 * @param array $params
480 * @param array|false $expected
481 * @throws FatalError
482 * @throws MWException
483 */
484 public function testParseNamespacePrefix( array $params, $expected ) {
485 $this->setTemporaryHook( 'PrefixSearchExtractNamespace', function ( &$namespaces, &$query ) {
486 if ( strpos( $query, 'hélp:' ) === 0 ) {
487 $namespaces = [ NS_HELP ];
488 $query = substr( $query, strlen( 'hélp:' ) );
489 }
490 return false;
491 } );
492 $testSet = [];
493 if ( isset( $params['withAll'] ) && isset( $params['withHook'] ) ) {
494 $testSet[] = $params;
495 } elseif ( isset( $params['withAll'] ) ) {
496 $testSet[] = $params + [ 'withHook' => true ];
497 $testSet[] = $params + [ 'withHook' => false ];
498 } elseif ( isset( $params['withHook'] ) ) {
499 $testSet[] = $params + [ 'withAll' => true ];
500 $testSet[] = $params + [ 'withAll' => false ];
501 } else {
502 $testSet[] = $params + [ 'withAll' => true, 'withHook' => true ];
503 $testSet[] = $params + [ 'withAll' => true, 'withHook' => false ];
504 $testSet[] = $params + [ 'withAll' => false, 'withHook' => false ];
505 $testSet[] = $params + [ 'withAll' => true, 'withHook' => false ];
506 }
507
508 foreach ( $testSet as $test ) {
509 $actual = SearchEngine::parseNamespacePrefixes( $test['query'],
510 $test['withAll'], $test['withHook'] );
511 $this->assertEquals( $expected, $actual, 'with params: ' . print_r( $test, true ) );
512 }
513 }
514 }