1<?php 2 3use MediaWiki\MediaWikiServices; 4 5/** 6 * @group Search 7 * @group Database 8 */ 9class SearchEnginePrefixTest extends MediaWikiLangTestCase { 10 /** 11 * @var SearchEngine 12 */ 13 private $search; 14 15 public function addDBDataOnce() { 16 if ( !$this->isWikitextNS( NS_MAIN ) ) { 17 // tests are skipped if NS_MAIN is not wikitext 18 return; 19 } 20 21 $this->insertPage( 'Sandbox' ); 22 $this->insertPage( 'Bar' ); 23 $this->insertPage( 'Example' ); 24 $this->insertPage( 'Example Bar' ); 25 $this->insertPage( 'Example Foo' ); 26 $this->insertPage( 'Example Foo/Bar' ); 27 $this->insertPage( 'Example/Baz' ); 28 $this->insertPage( 'Sample' ); 29 $this->insertPage( 'Sample Ban' ); 30 $this->insertPage( 'Sample Eat' ); 31 $this->insertPage( 'Sample Who' ); 32 $this->insertPage( 'Sample Zoo' ); 33 $this->insertPage( 'Redirect test', '#REDIRECT [[Redirect Test]]' ); 34 $this->insertPage( 'Redirect Test' ); 35 $this->insertPage( 'Redirect Test Worse Result' ); 36 $this->insertPage( 'Redirect test2', '#REDIRECT [[Redirect Test2]]' ); 37 $this->insertPage( 'Redirect TEST2', '#REDIRECT [[Redirect Test2]]' ); 38 $this->insertPage( 'Redirect Test2' ); 39 $this->insertPage( 'Redirect Test2 Worse Result' ); 40 41 $this->insertPage( 'Talk:Sandbox' ); 42 $this->insertPage( 'Talk:Example' ); 43 44 $this->insertPage( 'User:Example' ); 45 $this->insertPage( 'Barcelona' ); 46 $this->insertPage( 'Barbara' ); 47 $this->insertPage( 'External' ); 48 } 49 50 protected function setUp() : void { 51 parent::setUp(); 52 53 if ( !$this->isWikitextNS( NS_MAIN ) ) { 54 $this->markTestSkipped( 'Main namespace does not support wikitext.' ); 55 } 56 57 // Avoid special pages from extensions interferring with the tests 58 $this->setMwGlobals( [ 59 'wgSpecialPages' => [], 60 'wgHooks' => [], 61 ] ); 62 63 $this->search = MediaWikiServices::getInstance()->newSearchEngine(); 64 $this->search->setNamespaces( [] ); 65 } 66 67 protected function searchProvision( array $results = null ) { 68 if ( $results === null ) { 69 $this->setMwGlobals( 'wgHooks', [] ); 70 } else { 71 $this->setMwGlobals( 'wgHooks', [ 72 'PrefixSearchBackend' => [ 73 static function ( $namespaces, $search, $limit, &$srchres ) use ( $results ) { 74 $srchres = $results; 75 return false; 76 } 77 ], 78 ] ); 79 } 80 } 81 82 public static function provideSearch() { 83 return [ 84 [ [ 85 'Empty string', 86 'query' => '', 87 'results' => [], 88 ] ], 89 [ [ 90 'Main namespace with title prefix', 91 'query' => 'Sa', 92 'results' => [ 93 'Sample', 94 'Sample Ban', 95 'Sample Eat', 96 ], 97 // Third result when testing offset 98 'offsetresult' => [ 99 'Sample Who', 100 ], 101 ] ], 102 [ [ 103 'Talk namespace prefix', 104 'query' => 'Talk:', 105 'results' => [ 106 'Talk:Example', 107 'Talk:Sandbox', 108 ], 109 ] ], 110 [ [ 111 'User namespace prefix', 112 'query' => 'User:', 113 'results' => [ 114 'User:Example', 115 ], 116 ] ], 117 [ [ 118 'Special namespace prefix', 119 'query' => 'Special:', 120 'results' => [ 121 'Special:ActiveUsers', 122 'Special:AllMessages', 123 'Special:AllMyUploads', 124 ], 125 // Third result when testing offset 126 'offsetresult' => [ 127 'Special:AllPages', 128 ], 129 ] ], 130 [ [ 131 'Special namespace with prefix', 132 'query' => 'Special:Un', 133 'results' => [ 134 'Special:Unblock', 135 'Special:UncategorizedCategories', 136 'Special:UncategorizedFiles', 137 ], 138 // Third result when testing offset 139 'offsetresult' => [ 140 'Special:UncategorizedPages', 141 ], 142 ] ], 143 [ [ 144 'Special page name', 145 'query' => 'Special:EditWatchlist', 146 'results' => [ 147 'Special:EditWatchlist', 148 ], 149 ] ], 150 [ [ 151 'Special page subpages', 152 'query' => 'Special:EditWatchlist/', 153 'results' => [ 154 'Special:EditWatchlist/clear', 155 'Special:EditWatchlist/raw', 156 ], 157 ] ], 158 [ [ 159 'Special page subpages with prefix', 160 'query' => 'Special:EditWatchlist/cl', 161 'results' => [ 162 'Special:EditWatchlist/clear', 163 ], 164 ] ], 165 ]; 166 } 167 168 /** 169 * @dataProvider provideSearch 170 * @covers SearchEngine::defaultPrefixSearch 171 */ 172 public function testSearch( array $case ) { 173 $this->search->setLimitOffset( 3 ); 174 $results = $this->search->defaultPrefixSearch( $case['query'] ); 175 $results = array_map( static function ( Title $t ) { 176 return $t->getPrefixedText(); 177 }, $results ); 178 179 $this->assertEquals( 180 $case['results'], 181 $results, 182 $case[0] 183 ); 184 } 185 186 /** 187 * @dataProvider provideSearch 188 * @covers SearchEngine::defaultPrefixSearch 189 */ 190 public function testSearchWithOffset( array $case ) { 191 $this->search->setLimitOffset( 3, 1 ); 192 $results = $this->search->defaultPrefixSearch( $case['query'] ); 193 $results = array_map( static function ( Title $t ) { 194 return $t->getPrefixedText(); 195 }, $results ); 196 197 // We don't expect the first result when offsetting 198 array_shift( $case['results'] ); 199 // And sometimes we expect a different last result 200 $expected = isset( $case['offsetresult'] ) ? 201 array_merge( $case['results'], $case['offsetresult'] ) : 202 $case['results']; 203 204 $this->assertEquals( 205 $expected, 206 $results, 207 $case[0] 208 ); 209 } 210 211 public static function provideSearchBackend() { 212 return [ 213 [ [ 214 'Simple case', 215 'provision' => [ 216 'Bar', 217 'Barcelona', 218 'Barbara', 219 ], 220 'query' => 'Bar', 221 'results' => [ 222 'Bar', 223 'Barcelona', 224 'Barbara', 225 ], 226 ] ], 227 [ [ 228 'Exact match not in first result should be moved to the first result (T72958)', 229 'provision' => [ 230 'Barcelona', 231 'Bar', 232 'Barbara', 233 ], 234 'query' => 'Bar', 235 'results' => [ 236 'Bar', 237 'Barcelona', 238 'Barbara', 239 ], 240 ] ], 241 [ [ 242 'Exact match missing from results should be added as first result (T72958)', 243 'provision' => [ 244 'Barcelona', 245 'Barbara', 246 'Bart', 247 ], 248 'query' => 'Bar', 249 'results' => [ 250 'Bar', 251 'Barcelona', 252 'Barbara', 253 ], 254 ] ], 255 [ [ 256 'Exact match missing and not existing pages should be dropped', 257 'provision' => [ 258 'Exile', 259 'Exist', 260 'External', 261 ], 262 'query' => 'Ex', 263 'results' => [ 264 'External', 265 ], 266 ] ], 267 [ [ 268 "Exact match shouldn't override already found match if " . 269 "exact is redirect and found isn't", 270 'provision' => [ 271 // Target of the exact match is low in the list 272 'Redirect Test Worse Result', 273 'Redirect Test', 274 ], 275 'query' => 'redirect test', 276 'results' => [ 277 // Redirect target is pulled up and exact match isn't added 278 'Redirect Test', 279 'Redirect Test Worse Result', 280 ], 281 ] ], 282 [ [ 283 "Exact match shouldn't override already found match if " . 284 "both exact match and found match are redirect", 285 'provision' => [ 286 // Another redirect to the same target as the exact match 287 // is low in the list 288 'Redirect Test2 Worse Result', 289 'Redirect test2', 290 ], 291 'query' => 'redirect TEST2', 292 'results' => [ 293 // Found redirect is pulled to the top and exact match isn't 294 // added 295 'Redirect test2', 296 'Redirect Test2 Worse Result', 297 ], 298 ] ], 299 [ [ 300 "Exact match should override any already found matches that " . 301 "are redirects to it", 302 'provision' => [ 303 // Another redirect to the same target as the exact match 304 // is low in the list 305 'Redirect Test Worse Result', 306 'Redirect test', 307 ], 308 'query' => 'Redirect Test', 309 'results' => [ 310 // Found redirect is pulled to the top and exact match isn't 311 // added 312 'Redirect Test', 313 'Redirect Test Worse Result', 314 'Redirect test', 315 ], 316 ] ], 317 [ [ 318 "Extra results must not be returned", 319 'provision' => [ 320 'Example', 321 'Example Bar', 322 'Example Foo', 323 'Example Foo/Bar' 324 ], 325 'query' => 'foo', 326 'results' => [ 327 'Example', 328 'Example Bar', 329 'Example Foo', 330 ], 331 ] ], 332 ]; 333 } 334 335 /** 336 * @dataProvider provideSearchBackend 337 * @covers PrefixSearch::searchBackend 338 */ 339 public function testSearchBackend( array $case ) { 340 $search = $this->mockSearchWithResults( $case['provision'] ); 341 $results = $search->completionSearch( $case['query'] ); 342 343 $results = $results->map( static function ( SearchSuggestion $s ) { 344 return $s->getText(); 345 } ); 346 347 $this->assertEquals( 348 $case['results'], 349 $results, 350 $case[0] 351 ); 352 } 353 354 public function paginationProvider() { 355 $res = [ 'Example', 'Example Bar', 'Example Foo', 'Example Foo/Bar' ]; 356 return [ 357 'With less than requested results no pagination' => [ 358 false, array_slice( $res, 0, 2 ), 359 ], 360 'With same as requested results no pagination' => [ 361 false, array_slice( $res, 0, 3 ), 362 ], 363 'With extra result returned offer pagination' => [ 364 true, $res, 365 ], 366 ]; 367 } 368 369 /** 370 * @dataProvider paginationProvider 371 * @covers SearchSuggestionSet::hasMoreResults 372 */ 373 public function testPagination( $hasMoreResults, $provision ) { 374 $search = $this->mockSearchWithResults( $provision ); 375 $results = $search->completionSearch( 'irrelevant' ); 376 377 $this->assertEquals( $hasMoreResults, $results->hasMoreResults() ); 378 } 379 380 private function mockSearchWithResults( $titleStrings, $limit = 3 ) { 381 $search = $stub = $this->getMockBuilder( SearchEngine::class ) 382 ->setMethods( [ 'completionSearchBackend' ] )->getMock(); 383 384 $return = SearchSuggestionSet::fromStrings( $titleStrings ); 385 386 $search->expects( $this->any() ) 387 ->method( 'completionSearchBackend' ) 388 ->will( $this->returnValue( $return ) ); 389 390 $search->setLimitOffset( $limit ); 391 return $search; 392 } 393} 394