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				'All invalid characters, effectively empty',
91				'query' => '[',
92				'results' => [],
93			] ],
94			[ [
95				'Main namespace with title prefix',
96				'query' => 'Sa',
97				'results' => [
98					'Sample',
99					'Sample Ban',
100					'Sample Eat',
101				],
102				// Third result when testing offset
103				'offsetresult' => [
104					'Sample Who',
105				],
106			] ],
107			[ [
108				'Some invalid characters',
109				'query' => '[[Sa]]',
110				'results' => [
111					'Sample',
112					'Sample Ban',
113					'Sample Eat',
114				],
115				'offsetresult' => [ 'Sample Who' ],
116			] ],
117			[ [
118				'Talk namespace prefix',
119				'query' => 'Talk:',
120				'results' => [
121					'Talk:Example',
122					'Talk:Sandbox',
123				],
124			] ],
125			[ [
126				'User namespace prefix',
127				'query' => 'User:',
128				'results' => [
129					'User:Example',
130				],
131			] ],
132			[ [
133				'Special namespace prefix',
134				'query' => 'Special:',
135				'results' => [
136					'Special:ActiveUsers',
137					'Special:AllMessages',
138					'Special:AllMyUploads',
139				],
140				// Third result when testing offset
141				'offsetresult' => [
142					'Special:AllPages',
143				],
144			] ],
145			[ [
146				'Special namespace with prefix',
147				'query' => 'Special:Un',
148				'results' => [
149					'Special:Unblock',
150					'Special:UncategorizedCategories',
151					'Special:UncategorizedFiles',
152				],
153				// Third result when testing offset
154				'offsetresult' => [
155					'Special:UncategorizedPages',
156				],
157			] ],
158			[ [
159				'Special page name',
160				'query' => 'Special:EditWatchlist',
161				'results' => [
162					'Special:EditWatchlist',
163				],
164			] ],
165			[ [
166				'Special page subpages',
167				'query' => 'Special:EditWatchlist/',
168				'results' => [
169					'Special:EditWatchlist/clear',
170					'Special:EditWatchlist/raw',
171				],
172			] ],
173			[ [
174				'Special page subpages with prefix',
175				'query' => 'Special:EditWatchlist/cl',
176				'results' => [
177					'Special:EditWatchlist/clear',
178				],
179			] ],
180		];
181	}
182
183	/**
184	 * @dataProvider provideSearch
185	 * @covers SearchEngine::defaultPrefixSearch
186	 */
187	public function testSearch( array $case ) {
188		$this->search->setLimitOffset( 3 );
189		$results = $this->search->defaultPrefixSearch( $case['query'] );
190		$results = array_map( static function ( Title $t ) {
191			return $t->getPrefixedText();
192		}, $results );
193
194		$this->assertEquals(
195			$case['results'],
196			$results,
197			$case[0]
198		);
199	}
200
201	/**
202	 * @dataProvider provideSearch
203	 * @covers SearchEngine::defaultPrefixSearch
204	 */
205	public function testSearchWithOffset( array $case ) {
206		$this->search->setLimitOffset( 3, 1 );
207		$results = $this->search->defaultPrefixSearch( $case['query'] );
208		$results = array_map( static function ( Title $t ) {
209			return $t->getPrefixedText();
210		}, $results );
211
212		// We don't expect the first result when offsetting
213		array_shift( $case['results'] );
214		// And sometimes we expect a different last result
215		$expected = isset( $case['offsetresult'] ) ?
216			array_merge( $case['results'], $case['offsetresult'] ) :
217			$case['results'];
218
219		$this->assertEquals(
220			$expected,
221			$results,
222			$case[0]
223		);
224	}
225
226	public static function provideSearchBackend() {
227		return [
228			[ [
229				'Simple case',
230				'provision' => [
231					'Bar',
232					'Barcelona',
233					'Barbara',
234				],
235				'query' => 'Bar',
236				'results' => [
237					'Bar',
238					'Barcelona',
239					'Barbara',
240				],
241			] ],
242			[ [
243				'Exact match not in first result should be moved to the first result (T72958)',
244				'provision' => [
245					'Barcelona',
246					'Bar',
247					'Barbara',
248				],
249				'query' => 'Bar',
250				'results' => [
251					'Bar',
252					'Barcelona',
253					'Barbara',
254				],
255			] ],
256			[ [
257				'Exact match missing from results should be added as first result (T72958)',
258				'provision' => [
259					'Barcelona',
260					'Barbara',
261					'Bart',
262				],
263				'query' => 'Bar',
264				'results' => [
265					'Bar',
266					'Barcelona',
267					'Barbara',
268				],
269			] ],
270			[ [
271				'Exact match missing and not existing pages should be dropped',
272				'provision' => [
273					'Exile',
274					'Exist',
275					'External',
276				],
277				'query' => 'Ex',
278				'results' => [
279					'External',
280				],
281			] ],
282			[ [
283				"Exact match shouldn't override already found match if " .
284					"exact is redirect and found isn't",
285				'provision' => [
286					// Target of the exact match is low in the list
287					'Redirect Test Worse Result',
288					'Redirect Test',
289				],
290				'query' => 'redirect test',
291				'results' => [
292					// Redirect target is pulled up and exact match isn't added
293					'Redirect Test',
294					'Redirect Test Worse Result',
295				],
296			] ],
297			[ [
298				"Exact match shouldn't override already found match if " .
299					"both exact match and found match are redirect",
300				'provision' => [
301					// Another redirect to the same target as the exact match
302					// is low in the list
303					'Redirect Test2 Worse Result',
304					'Redirect test2',
305				],
306				'query' => 'redirect TEST2',
307				'results' => [
308					// Found redirect is pulled to the top and exact match isn't
309					// added
310					'Redirect test2',
311					'Redirect Test2 Worse Result',
312				],
313			] ],
314			[ [
315				"Exact match should override any already found matches that " .
316					"are redirects to it",
317				'provision' => [
318					// Another redirect to the same target as the exact match
319					// is low in the list
320					'Redirect Test Worse Result',
321					'Redirect test',
322				],
323				'query' => 'Redirect Test',
324				'results' => [
325					// Found redirect is pulled to the top and exact match isn't
326					// added
327					'Redirect Test',
328					'Redirect Test Worse Result',
329					'Redirect test',
330				],
331			] ],
332			[ [
333				"Extra results must not be returned",
334				'provision' => [
335					'Example',
336					'Example Bar',
337					'Example Foo',
338					'Example Foo/Bar'
339				],
340				'query' => 'foo',
341				'results' => [
342					'Example',
343					'Example Bar',
344					'Example Foo',
345				],
346			] ],
347		];
348	}
349
350	/**
351	 * @dataProvider provideSearchBackend
352	 * @covers PrefixSearch::searchBackend
353	 */
354	public function testSearchBackend( array $case ) {
355		$search = $this->mockSearchWithResults( $case['provision'] );
356		$results = $search->completionSearch( $case['query'] );
357
358		$results = $results->map( static function ( SearchSuggestion $s ) {
359			return $s->getText();
360		} );
361
362		$this->assertEquals(
363			$case['results'],
364			$results,
365			$case[0]
366		);
367	}
368
369	public function paginationProvider() {
370		$res = [ 'Example', 'Example Bar', 'Example Foo', 'Example Foo/Bar' ];
371		return [
372			'With less than requested results no pagination' => [
373				false, array_slice( $res, 0, 2 ),
374			],
375			'With same as requested results no pagination' => [
376				false, array_slice( $res, 0, 3 ),
377			],
378			'With extra result returned offer pagination' => [
379				true, $res,
380			],
381		];
382	}
383
384	/**
385	 * @dataProvider paginationProvider
386	 * @covers SearchSuggestionSet::hasMoreResults
387	 */
388	public function testPagination( $hasMoreResults, $provision ) {
389		$search = $this->mockSearchWithResults( $provision );
390		$results = $search->completionSearch( 'irrelevant' );
391
392		$this->assertEquals( $hasMoreResults, $results->hasMoreResults() );
393	}
394
395	private function mockSearchWithResults( $titleStrings, $limit = 3 ) {
396		$search = $stub = $this->getMockBuilder( SearchEngine::class )
397			->onlyMethods( [ 'completionSearchBackend' ] )->getMock();
398
399		$return = SearchSuggestionSet::fromStrings( $titleStrings );
400
401		$search->method( 'completionSearchBackend' )
402			->willReturn( $return );
403
404		$search->setLimitOffset( $limit );
405		return $search;
406	}
407}
408