1<?php
2
3namespace Cite\Tests\Unit;
4
5use Cite\AnchorFormatter;
6use Cite\ErrorReporter;
7use Cite\ReferenceMessageLocalizer;
8use Cite\ReferencesFormatter;
9use Message;
10use Parser;
11use Wikimedia\TestingAccessWrapper;
12
13/**
14 * @coversDefaultClass \Cite\ReferencesFormatter
15 *
16 * @license GPL-2.0-or-later
17 */
18class ReferencesFormatterTest extends \MediaWikiUnitTestCase {
19
20	/**
21	 * @covers ::__construct
22	 * @covers ::formatReferences
23	 * @covers ::formatRefsList
24	 * @dataProvider provideFormatReferences
25	 */
26	public function testFormatReferences( array $refs, string $expectedOutput ) {
27		$mockParser = $this->createMock( Parser::class );
28		$mockParser->method( 'recursiveTagParse' )->willReturnArgument( 0 );
29
30		$mockErrorReporter = $this->createMock( ErrorReporter::class );
31		$mockErrorReporter->method( 'plain' )->willReturnCallback(
32			function ( $parser, ...$args ) {
33				return '(' . implode( '|', $args ) . ')';
34			}
35		);
36
37		$mockMessageLocalizer = $this->createMock( ReferenceMessageLocalizer::class );
38		$mockMessageLocalizer->method( 'msg' )->willReturnCallback(
39			function ( ...$args ) {
40				$msg = $this->createMock( Message::class );
41				$msg->method( 'plain' )->willReturn( '<li>(' . implode( '|', $args ) . ')</li>' );
42				return $msg;
43			}
44		);
45
46		/** @var Parser $mockParser */
47		/** @var ErrorReporter $mockErrorReporter */
48		/** @var ReferenceMessageLocalizer $mockMessageLocalizer */
49		$formatter = new ReferencesFormatter(
50			$mockErrorReporter,
51			$this->createMock( AnchorFormatter::class ),
52			$mockMessageLocalizer
53		);
54
55		$output = $formatter->formatReferences( $mockParser, $refs, true, false );
56		$this->assertSame( $expectedOutput, $output );
57	}
58
59	public function provideFormatReferences() {
60		return [
61			'Empty' => [
62				[],
63				''
64			],
65			'Minimal ref' => [
66				[
67					0 => [
68						'key' => 1,
69						'text' => 't',
70					]
71				],
72				'<div class="mw-references-wrap"><ol class="references">' . "\n" .
73					'<li>(cite_references_link_many|||<span class="reference-text">t</span>' .
74					"\n|)</li>\n</ol></div>"
75			],
76			'Ref with extends' => [
77				[
78					0 => [
79						'extends' => 'a',
80						'extendsIndex' => 1,
81						'key' => 2,
82						'number' => 10,
83						'text' => 't2',
84					],
85					1 => [
86						'number' => 11,
87						'text' => 't3',
88					],
89					'a' => [
90						'key' => 1,
91						'number' => 9,
92						'text' => 't1',
93					],
94				],
95				'<div class="mw-references-wrap"><ol class="references">' . "\n" .
96					'<li>(cite_references_link_many|||<span class="reference-text">t1</span>' . "\n" .
97					'|)<ol class="mw-extended-references"><li>(cite_references_link_many|||' .
98					'<span class="reference-text">t2</span>' . "\n|)</li>\n" .
99					"</ol></li>\n" .
100					'<li>(cite_references_link_many|||<span class="reference-text">t3</span>' .
101					"\n|)</li>\n" .
102					'</ol></div>'
103			],
104			'Subref of subref' => [
105				[
106					0 => [
107						'extends' => 'a',
108						'extendsIndex' => 1,
109						'key' => 1,
110						'number' => 1,
111						'text' => 't1',
112					],
113					'a' => [
114						'extends' => 'b',
115						'extendsIndex' => 1,
116						'key' => 2,
117						'number' => 1,
118						'text' => 't2',
119					],
120					'b' => [
121						'key' => 3,
122						'number' => 1,
123						'text' => 't3',
124					],
125				],
126				'<div class="mw-references-wrap"><ol class="references">' . "\n" .
127					'<li>(cite_references_link_many|||<span class="reference-text">t3</span>' . "\n" .
128					'|)<ol class="mw-extended-references"><li>(cite_references_link_many|||' .
129					'<span class="reference-text">t1 (cite_error_ref_nested_extends|a|b)</span>' .
130					"\n|)</li>\n" .
131					'<li>(cite_references_link_many|||<span class="reference-text">t2</span>' .
132					"\n|)</li>\n</ol></li>\n" .
133					'</ol></div>'
134			],
135			'Use columns' => [
136				array_map(
137					function ( $i ) {
138						return [ 'key' => $i, 'text' => 't' ];
139					},
140					range( 0, 10 )
141				),
142				'<div class="mw-references-wrap mw-references-columns"><ol class="references">' .
143					"\n" . '<li>(cite_references_link_many|||<span class="reference-text">t</span>' .
144					"\n|)</li>\n" .
145					'<li>(cite_references_link_many|||<span class="reference-text">t</span>' .
146					"\n|)</li>\n" .
147					'<li>(cite_references_link_many|||<span class="reference-text">t</span>' .
148					"\n|)</li>\n" .
149					'<li>(cite_references_link_many|||<span class="reference-text">t</span>' .
150					"\n|)</li>\n" .
151					'<li>(cite_references_link_many|||<span class="reference-text">t</span>' .
152					"\n|)</li>\n" .
153					'<li>(cite_references_link_many|||<span class="reference-text">t</span>' .
154					"\n|)</li>\n" .
155					'<li>(cite_references_link_many|||<span class="reference-text">t</span>' .
156					"\n|)</li>\n" .
157					'<li>(cite_references_link_many|||<span class="reference-text">t</span>' .
158					"\n|)</li>\n" .
159					'<li>(cite_references_link_many|||<span class="reference-text">t</span>' .
160					"\n|)</li>\n" .
161					'<li>(cite_references_link_many|||<span class="reference-text">t</span>' .
162					"\n|)</li>\n" .
163					'<li>(cite_references_link_many|||<span class="reference-text">t</span>' .
164					"\n|)</li>\n</ol></div>"
165			],
166		];
167	}
168
169	/**
170	 * @covers ::closeIndention
171	 * @dataProvider provideCloseIndention
172	 */
173	public function testCloseIndention( $closingLi, $expectedOutput ) {
174		/** @var ReferencesFormatter $formatter */
175		$formatter = TestingAccessWrapper::newFromObject( new ReferencesFormatter(
176			$this->createMock( ErrorReporter::class ),
177			$this->createMock( AnchorFormatter::class ),
178			$this->createMock( ReferenceMessageLocalizer::class )
179		) );
180
181		$output = $formatter->closeIndention( $closingLi );
182		$this->assertSame( $expectedOutput, $output );
183	}
184
185	public function provideCloseIndention() {
186		return [
187			'No indention' => [ false, '' ],
188			'Indention string' => [ "</li>\n", "</ol></li>\n" ],
189			'Indention without string' => [ true, '</ol>' ],
190		];
191	}
192
193	/**
194	 * @covers ::formatListItem
195	 * @dataProvider provideFormatListItem
196	 */
197	public function testFormatListItem(
198		$key,
199		array $val,
200		string $expectedOutput
201	) {
202		$mockErrorReporter = $this->createMock( ErrorReporter::class );
203		$mockErrorReporter->method( 'plain' )->willReturnCallback(
204			function ( $parser, ...$args ) {
205				return '(' . implode( '|', $args ) . ')';
206			}
207		);
208
209		$anchorFormatter = $this->createMock( AnchorFormatter::class );
210		$anchorFormatter->method( 'refKey' )->willReturnCallback(
211			function ( ...$args ) {
212				return implode( '+', $args );
213			}
214		);
215
216		$mockMessageLocalizer = $this->createMock( ReferenceMessageLocalizer::class );
217		$mockMessageLocalizer->method( 'formatNum' )->willReturnArgument( 0 );
218		$mockMessageLocalizer->method( 'localizeDigits' )->willReturnArgument( 0 );
219		$mockMessageLocalizer->method( 'msg' )->willReturnCallback(
220			function ( ...$args ) {
221				$msg = $this->createMock( Message::class );
222				$msg->method( 'plain' )->willReturn( '(' . implode( '|', $args ) . ')' );
223				return $msg;
224			}
225		);
226
227		/** @var ErrorReporter $mockErrorReporter */
228		/** @var AnchorFormatter $anchorFormatter */
229		/** @var ReferenceMessageLocalizer $mockMessageLocalizer */
230		$formatter = TestingAccessWrapper::newFromObject( new ReferencesFormatter(
231			$mockErrorReporter,
232			$anchorFormatter,
233			$mockMessageLocalizer
234		) );
235
236		/** @var ReferencesFormatter $formatter */
237		$output = $formatter->formatListItem(
238			$this->createMock( Parser::class ), $key, $val, false );
239		$this->assertSame( $expectedOutput, $output );
240	}
241
242	public function provideFormatListItem() {
243		return [
244			'Success' => [
245				1,
246				[
247					'text' => 't',
248				],
249				'(cite_references_link_many|||<span class="reference-text">t</span>' . "\n|)"
250			],
251			'With dir' => [
252				1,
253				[
254					'dir' => 'rtl',
255					'text' => 't',
256				],
257				'(cite_references_link_many|||<span class="reference-text">t</span>' .
258					"\n" . '| class="mw-cite-dir-rtl")'
259			],
260			'Incomplete follow' => [
261				1,
262				[
263					'follow' => 'f',
264					'text' => 't',
265				],
266				'(cite_references_no_link||<span class="reference-text">t</span>' . "\n)"
267			],
268			'Count zero' => [
269				1,
270				[
271					'count' => 0,
272					'key' => 5,
273					'text' => 't',
274				],
275				'(cite_references_link_one||1+5-0|<span class="reference-text">t</span>' . "\n|)"
276			],
277			'Count negative' => [
278				1,
279				[
280					'count' => -1,
281					'key' => 5,
282					'number' => 3,
283					'text' => 't',
284				],
285				'(cite_references_link_one||5+|<span class="reference-text">t</span>' . "\n|)"
286			],
287			'Count positive' => [
288				1,
289				[
290					'count' => 2,
291					'key' => 5,
292					'number' => 3,
293					'text' => 't',
294				],
295				'(cite_references_link_many||(cite_references_link_many_format|1+5-0|3.0|' .
296				'(cite_references_link_many_format_backlink_labels))' .
297				'(cite_references_link_many_sep)(cite_references_link_many_format|1+5-1|3.1|' .
298				'(cite_error_references_no_backlink_label))(cite_references_link_many_and)' .
299				'(cite_references_link_many_format|1+5-2|3.2|(cite_error_references_no_backlink_label' .
300				'))|<span class="reference-text">t</span>' . "\n|)"
301			],
302		];
303	}
304
305	/**
306	 * @covers ::referenceText
307	 * @dataProvider provideReferenceText
308	 */
309	public function testReferenceText(
310		$key,
311		?string $text,
312		bool $isSectionPreview,
313		string $expectedOutput
314	) {
315		$mockErrorReporter = $this->createMock( ErrorReporter::class );
316		$mockErrorReporter->method( 'plain' )->willReturnCallback(
317			function ( $parser, ...$args ) {
318				return '(' . implode( '|', $args ) . ')';
319			}
320		);
321
322		/** @var ErrorReporter $mockErrorReporter */
323		/** @var ReferencesFormatter $formatter */
324		$formatter = TestingAccessWrapper::newFromObject( new ReferencesFormatter(
325			$mockErrorReporter,
326			$this->createMock( AnchorFormatter::class ),
327			$this->createMock( ReferenceMessageLocalizer::class )
328		) );
329
330		$output = $formatter->referenceText(
331			$this->createMock( Parser::class ), $key, $text, $isSectionPreview );
332		$this->assertSame( $expectedOutput, $output );
333	}
334
335	public function provideReferenceText() {
336		return [
337			'No text, not preview' => [
338				1,
339				null,
340				false,
341				'(cite_error_references_no_text|1)'
342			],
343			'No text, is preview' => [
344				1,
345				null,
346				true,
347				'(cite_warning_sectionpreview_no_text|1)'
348			],
349			'Has text' => [
350				1,
351				'text',
352				true,
353				'<span class="reference-text">text</span>' . "\n"
354			],
355			'Trims text' => [
356				1,
357				"text\n\n",
358				true,
359				'<span class="reference-text">text</span>' . "\n"
360			],
361		];
362	}
363
364	/**
365	 * @covers ::referencesFormatEntryAlternateBacklinkLabel
366	 * @dataProvider provideReferencesFormatEntryAlternateBacklinkLabel
367	 */
368	public function testReferencesFormatEntryAlternateBacklinkLabel(
369		?string $expectedLabel, ?string $labelList, int $offset
370	) {
371		$mockMessage = $this->createMock( Message::class );
372		$mockMessage->method( 'exists' )->willReturn( (bool)$labelList );
373		$mockMessage->method( 'plain' )->willReturn( $labelList ?? '<missing-junk>' );
374
375		$mockMessageLocalizer = $this->createMock( ReferenceMessageLocalizer::class );
376		$mockMessageLocalizer->method( 'msg' )
377			->willReturn( $mockMessage );
378
379		$mockErrorReporter = $this->createMock( ErrorReporter::class );
380		if ( $expectedLabel === null ) {
381			$mockErrorReporter->expects( $this->once() )->method( 'plain' );
382		} else {
383			$mockErrorReporter->expects( $this->never() )->method( 'plain' );
384		}
385
386		/** @var ErrorReporter $mockErrorReporter */
387		/** @var ReferenceMessageLocalizer $mockMessageLocalizer */
388		/** @var ReferencesFormatter $formatter */
389		$formatter = TestingAccessWrapper::newFromObject( new ReferencesFormatter(
390			$mockErrorReporter,
391			$this->createMock( AnchorFormatter::class ),
392			$mockMessageLocalizer
393		) );
394
395		$label = $formatter->referencesFormatEntryAlternateBacklinkLabel(
396			$this->createMock( Parser::class ), $offset );
397		if ( $expectedLabel !== null ) {
398			$this->assertSame( $expectedLabel, $label );
399		}
400	}
401
402	public function provideReferencesFormatEntryAlternateBacklinkLabel() {
403		yield [ 'aa', 'aa ab ac', 0 ];
404		yield [ 'ab', 'aa ab ac', 1 ];
405		yield [ 'å', 'å b c', 0 ];
406		yield [ null, 'a b c', 10 ];
407	}
408
409	/**
410	 * @covers ::referencesFormatEntryNumericBacklinkLabel
411	 * @dataProvider provideReferencesFormatEntryNumericBacklinkLabel
412	 */
413	public function testReferencesFormatEntryNumericBacklinkLabel(
414		string $expectedLabel, int $base, int $offset, int $max
415	) {
416		$mockMessageLocalizer = $this->createMock( ReferenceMessageLocalizer::class );
417		$mockMessageLocalizer->method( 'formatNum' )->willReturnArgument( 0 );
418		$mockMessageLocalizer->method( 'localizeDigits' )->willReturnArgument( 0 );
419
420		/** @var ReferenceMessageLocalizer $mockMessageLocalizer */
421		/** @var ReferencesFormatter $formatter */
422		$formatter = TestingAccessWrapper::newFromObject( new ReferencesFormatter(
423			$this->createMock( ErrorReporter::class ),
424			$this->createMock( AnchorFormatter::class ),
425			$mockMessageLocalizer
426		) );
427
428		$label = $formatter->referencesFormatEntryNumericBacklinkLabel( $base, $offset, $max );
429		$this->assertSame( $expectedLabel, $label );
430	}
431
432	public function provideReferencesFormatEntryNumericBacklinkLabel() {
433		yield [ '1.2', 1, 2, 9 ];
434		yield [ '1.02', 1, 2, 99 ];
435		yield [ '1.002', 1, 2, 100 ];
436		yield [ '2.1', 2, 1, 1 ];
437	}
438
439	/**
440	 * @covers ::listToText
441	 * @dataProvider provideLists
442	 */
443	public function testListToText( array $list, $expected ) {
444		$mockMessageLocalizer = $this->createMock( ReferenceMessageLocalizer::class );
445		$mockMessageLocalizer->method( 'msg' )->willReturnCallback(
446			function ( ...$args ) {
447				$msg = $this->createMock( Message::class );
448				$msg->method( 'plain' )->willReturn( '(' . implode( '|', $args ) . ')' );
449				return $msg;
450			}
451		);
452
453		/** @var ReferenceMessageLocalizer $mockMessageLocalizer */
454		/** @var ReferencesFormatter $formatter */
455		$formatter = TestingAccessWrapper::newFromObject( new ReferencesFormatter(
456			$this->createMock( ErrorReporter::class ),
457			$this->createMock( AnchorFormatter::class ),
458			$mockMessageLocalizer
459		) );
460		$this->assertSame( $expected, $formatter->listToText( $list ) );
461	}
462
463	public function provideLists() {
464		return [
465			[
466				[],
467				''
468			],
469			[
470				// This is intentionally using numbers to test the to-string cast
471				[ 1 ],
472				'1'
473			],
474			[
475				[ 1, 2 ],
476				'1(cite_references_link_many_and)2'
477			],
478			[
479				[ 1, 2, 3 ],
480				'1(cite_references_link_many_sep)2(cite_references_link_many_and)3'
481			],
482		];
483	}
484
485}
486