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