packages/requirements/tests/Unit/Config/Markdown/QuotationTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Config\Markdown;
6
7use League\CommonMark\Exception\CommonMarkException;
8use League\CommonMark\Extension\CommonMark\Node\Block\BlockQuote;
9use League\CommonMark\Extension\CommonMark\Node\Block\HtmlBlock;
10use League\CommonMark\Extension\CommonMark\Node\Inline\Link;
11use PHPUnit\Framework\Attributes\CoversClass;
12use PHPUnit\Framework\Attributes\DataProvider;
13use PHPUnit\Framework\Attributes\Small;
14use PHPUnit\Framework\Attributes\UsesClass;
15use PHPUnit\Framework\TestCase;
16use Requirements\Config\Markdown\Citation;
17use Requirements\Config\Markdown\LocalPath;
18use Requirements\Config\Markdown\Nodes;
19use Requirements\Config\Markdown\Quotation;
20use Requirements\Input\InvalidInputException;
21use Requirements\Model\Source;
22use Requirements\Source\TextFragment;
23use Requirements\Source\Unit;
24use Tests\Fake\MarkdownNodes;
25
26#[CoversClass(Quotation::class)]
27#[UsesClass(Citation::class)]
28#[UsesClass(LocalPath::class)]
29#[UsesClass(Nodes::class)]
30#[UsesClass(Source::class)]
31#[UsesClass(TextFragment::class)]
32#[UsesClass(Unit::class)]
33#[Small]
34final class QuotationTest extends TestCase
35{
36    /**
37     * @throws CommonMarkException
38     */
39    public function testReadReadsASelectorCommentAndTheQuotedText(): void
40    {
41        $quote = MarkdownNodes::first("> <!-- selector: #a -->\n> Names shall start\n> with a letter.\n>\n> Names may contain *digits*.\n");
42        self::assertInstanceOf(BlockQuote::class, $quote);
43        $quotation = new Quotation();
44        $evidence = $quotation->read($quote, new Citation(new Source('manual', 'source.html', 'html', 'main p'), '/project/definition.md', '/project'));
45        self::assertEquals((object) ['selector' => '#a', 'quote' => "Names shall start\nwith a letter.\n\nNames may contain digits."], $evidence);
46        self::assertNull($quotation->link);
47    }
48
49    /**
50     * @throws CommonMarkException
51     */
52    public function testReadDerivesTheSelectorFromTheLinkInsideTheQuotation(): void
53    {
54        $quote = MarkdownNodes::first("> Names may contain digits.\n>\n> [Digits](../source.html#:~:text=Names%20may%20contain%20digits.)\n");
55        self::assertInstanceOf(BlockQuote::class, $quote);
56        $quotation = new Quotation();
57        $evidence = $quotation->read($quote, new Citation(new Source('manual', 'source.html', 'html', 'main p'), '/project/definitions/names.md', '/project'));
58        self::assertEquals((object) ['selector' => '#:~:text=Names%20may%20contain%20digits.', 'quote' => 'Names may contain digits.'], $evidence);
59        self::assertSame(['url' => '../source.html#:~:text=Names%20may%20contain%20digits.', 'label' => 'Digits'], $quotation->link);
60    }
61
62    /**
63     * @throws CommonMarkException
64     */
65    public function testReadDerivesTheSelectorFromTheAttribution(): void
66    {
67        $quote = MarkdownNodes::first('> Names shall start with a letter.');
68        $attribution = MarkdownNodes::first('[Names](source.html#a)')->firstChild();
69        self::assertInstanceOf(BlockQuote::class, $quote);
70        self::assertInstanceOf(Link::class, $attribution);
71        $quotation = new Quotation();
72        $evidence = $quotation->read($quote, new Citation(new Source('manual', 'source.html', 'html', 'main p'), '/project/definition.md', '/project'), $attribution);
73        self::assertEquals((object) ['selector' => '#a', 'quote' => 'Names shall start with a letter.'], $evidence);
74        self::assertSame(['url' => 'source.html#a', 'label' => 'Names'], $quotation->link);
75    }
76
77    /**
78     * @throws CommonMarkException
79     */
80    public function testReadKeepsASelectorCommentThatTheLinkAgreesWith(): void
81    {
82        $quote = MarkdownNodes::first("> <!-- selector: main > p:first-child -->\n> Names shall start with a letter.\n>\n> [Source](source.html#:~:text=Names%20shall%20start%20with%20a%20letter.)\n");
83        self::assertInstanceOf(BlockQuote::class, $quote);
84        $evidence = (new Quotation())->read($quote, new Citation(new Source('manual', 'source.html', 'html', 'main p'), '/project/definition.md', '/project'));
85        self::assertEquals((object) ['selector' => 'main > p:first-child', 'quote' => 'Names shall start with a letter.'], $evidence);
86    }
87
88    /**
89     * @throws CommonMarkException
90     */
91    public function testReadRejectsAQuotationWithoutADeclaredSource(): void
92    {
93        $quote = MarkdownNodes::first("> <!-- selector: #a -->\n> Names shall start with a letter.\n");
94        self::assertInstanceOf(BlockQuote::class, $quote);
95        $this->expectException(InvalidInputException::class);
96        $this->expectExceptionMessage('A quotation needs a declared source.');
97        (new Quotation())->read($quote, null);
98    }
99
100    /**
101     * @throws CommonMarkException
102     */
103    public function testReadRejectsASecondSourceCitation(): void
104    {
105        $quote = MarkdownNodes::first("> Names shall start with a letter.\n>\n> [Source](source.html#a)\n");
106        $attribution = MarkdownNodes::first('[Source](source.html#a)')->firstChild();
107        self::assertInstanceOf(BlockQuote::class, $quote);
108        self::assertInstanceOf(Link::class, $attribution);
109        $this->expectException(InvalidInputException::class);
110        $this->expectExceptionMessage('An evidence quotation can have only one source citation.');
111        (new Quotation())->read($quote, new Citation(new Source('manual', 'source.html', 'html', 'main p'), '/project/definition.md', '/project'), $attribution);
112    }
113
114    /**
115     * @throws CommonMarkException
116     */
117    #[DataProvider('providerMalformedQuotations')]
118    public function testReadRejectsMalformedQuotations(string $markdown, string $message): void
119    {
120        $quote = MarkdownNodes::first($markdown);
121        self::assertInstanceOf(BlockQuote::class, $quote);
122        $this->expectException(InvalidInputException::class);
123        $this->expectExceptionMessage($message);
124        (new Quotation())->read($quote, new Citation(new Source('manual', 'source.html', 'html', 'main p'), '/project/definition.md', '/project'));
125    }
126
127    /**
128     * @return array<string, array{string, string}>
129     */
130    public static function providerMalformedQuotations(): array
131    {
132        return [
133            'quote with no locator' => ['> Names shall start with a letter.', 'Evidence needs a selector comment or a source link identifying the quoted unit.'],
134            'blank selector comment' => ["> <!-- selector: -->\n> Names shall start with a letter.\n", 'Evidence needs a selector comment or a source link identifying the quoted unit.'],
135            'selector comment without text' => ['> <!-- selector: #a -->', 'Evidence needs quoted source text.'],
136            'link without text' => ['> [Source](source.html#a)', 'Evidence needs quoted source text.'],
137            'raw HTML after the text' => ["> Names shall start with a letter.\n>\n> <div>Names</div>\n", 'A quotation contains a selector comment, quoted prose, then an optional source link.'],
138            'list in the quotation' => ["> <!-- selector: #a -->\n> - Names shall start with a letter.\n", 'A quotation contains a selector comment, quoted prose, then an optional source link.'],
139            'arbitrary comment' => ["> <!-- unknown: value -->\n> Names shall start with a letter.\n", 'Only a selector comment is allowed at the start of an evidence quotation.'],
140            'raw HTML' => ['> <div>Names shall start with a letter.</div>', 'Only a selector comment is allowed at the start of an evidence quotation.'],
141            'link before the text' => ["> [Source](source.html#a)\n>\n> Names shall start with a letter.\n", 'Unsupported inline Markdown'],
142            'link with more text' => ["> <!-- selector: #a -->\n> Names shall start with a letter.\n>\n> [Source](source.html#a) and more\n", 'Unsupported inline Markdown'],
143            'link with an empty destination' => ["> Names shall start with a letter.\n>\n> [Source]()\n", 'Expected one Markdown link with a nonempty destination.'],
144            'wrong citation resource' => ["> <!-- selector: #a -->\n> Names shall start with a letter.\n>\n> [Source](different.html#a)\n", 'An evidence citation must link to this definition\'s source resource.'],
145            'wrong citation anchor' => ["> <!-- selector: #a -->\n> Names shall start with a letter.\n>\n> [Source](source.html#b)\n", 'The citation anchor disagrees with the evidence selector.'],
146            'wrong hidden text directive' => ["> <!-- selector: #:~:text=Different%20text. -->\n> Names shall start with a letter.\n", 'An exact Text Fragment selector must identify the complete quoted HTML unit.'],
147        ];
148    }
149
150    /**
151     * @throws CommonMarkException
152     */
153    #[DataProvider('providerAnnotations')]
154    public function testAnnotationReadsTheSelectorComment(string $markdown, string $expected): void
155    {
156        $comment = MarkdownNodes::first($markdown);
157        self::assertInstanceOf(HtmlBlock::class, $comment);
158        self::assertSame($expected, Quotation::annotation($comment));
159    }
160
161    /**
162     * @return array<string, array{string, string}>
163     */
164    public static function providerAnnotations(): array
165    {
166        return [
167            'selector' => ['<!-- selector: #a -->', '#a'],
168            'bold selector with an escaped hash' => ['<!-- **selector:** \\#c -->', '#c'],
169            'no spaces' => ['<!--selector:#a-->', '#a'],
170            'entities' => ['<!-- selector: main &gt; p:first-child -->', 'main > p:first-child'],
171            'raw greater-than sign' => ['<!-- selector: main > p:first-child -->', 'main > p:first-child'],
172            'trailing line break' => ["<!-- selector: #a -->\n", '#a'],
173            'backslash inside' => ['<!-- selector: a\\#b -->', 'a\\#b'],
174            'selector on the next line' => ["<!-- selector:\n#a -->", '#a'],
175            'blank selector' => ['<!-- selector: -->', ' '],
176        ];
177    }
178
179    /**
180     * @throws CommonMarkException
181     */
182    #[DataProvider('providerNotAnnotations')]
183    public function testAnnotationRejectsAnythingButASelectorComment(string $markdown): void
184    {
185        $comment = MarkdownNodes::first($markdown);
186        self::assertInstanceOf(HtmlBlock::class, $comment);
187        $this->expectException(InvalidInputException::class);
188        $this->expectExceptionMessage('Only a selector comment is allowed at the start of an evidence quotation.');
189        Quotation::annotation($comment);
190    }
191
192    /**
193     * @return array<string, array{string}>
194     */
195    public static function providerNotAnnotations(): array
196    {
197        return [
198            'other comment' => ['<!-- unknown: value -->'],
199            'element' => ['<div>selector: #a</div>'],
200            'nested comment end' => ['<!-- selector: a --> -->'],
201            'text before the comment' => ['<!-- x --><!-- selector: #a -->'],
202        ];
203    }
204
205    /**
206     * @param array{url: string, label: string}|null $link
207     */
208    #[DataProvider('providerRenderedQuotations')]
209    public function testRenderWritesTheQuotation(string $uri, string $format, string $selector, string $quote, ?array $link, string $expected): void
210    {
211        self::assertSame($expected, Quotation::render($selector, $quote, new Citation(new Source('manual', $uri, $format, 'main p'), '/project/definition.md', '/project'), $link));
212    }
213
214    /**
215     * @return array<string, array{string, string, string, string, array{url: string, label: string}|null, string}>
216     */
217    public static function providerRenderedQuotations(): array
218    {
219        return [
220            'element ID' => ['source.html', 'html', '#a', 'Names shall start with a letter.', null, "> Names shall start with a letter.\n\n[Source](source.html#a)"],
221            'CSS selector' => ['source.html', 'html', 'main > p:first-child', 'Names shall start with a letter.', null, "> <!-- selector: main &gt; p:first-child -->\n> Names shall start with a letter.\n\n[Source](source.html#:~:text=Names%20shall%20start%20with%20a%20letter.)"],
222            'read link' => ['source.html', 'html', '#a', 'Names shall start with a letter.', ['url' => 'source.html#a', 'label' => 'Names'], "> Names shall start with a letter.\n\n[Names](source.html#a)"],
223            'read link disagreeing with the selector' => ['source.html', 'html', 'main p', 'Names shall start with a letter.', ['url' => 'source.html#a', 'label' => 'Names'], "> <!-- selector: main p -->\n> Names shall start with a letter.\n\n[Names](source.html#a)"],
224            'read link to another resource' => ['source.html', 'html', '#a', 'Names shall start with a letter.', ['url' => 'other.html#a', 'label' => 'Names'], "> <!-- selector: #a -->\n> Names shall start with a letter.\n\n[Names](other.html#a)"],
225            'JSON selector' => ['source.json', 'json', '$.rules[0].text', 'First rule.', null, "> <!-- selector: $.rules[0].text -->\n> First rule.\n\n[Source](source.json)"],
226            'paragraphs and escapes' => ['source.html', 'html', '#a', "Names *shall*\n\n- start", null, "> Names \\*shall\\*\n> \n> \\- start\n\n[Source](source.html#a)"],
227            'label and destination escapes' => ['my source.html', 'html', '#a', 'Names.', ['url' => 'my source.html#a', 'label' => '[Names]'], "> Names.\n\n[\\[Names\\]](<my%20source.html#a>)"],
228        ];
229    }
230
231    public function testRenderWritesASelectorCommentWithoutACitation(): void
232    {
233        self::assertSame("> <!-- selector: #a -->\n> Names shall start with a letter.", Quotation::render('#a', 'Names shall start with a letter.', null, null));
234    }
235
236    public function testRenderKeepsTheReadLinkWithoutACitation(): void
237    {
238        self::assertSame("> <!-- selector: #a -->\n> Names shall start with a letter.\n\n[Names](source.html#a)", Quotation::render('#a', 'Names shall start with a letter.', null, ['url' => 'source.html#a', 'label' => 'Names']));
239    }
240
241    public function testRenderEscapesTheSelectorComment(): void
242    {
243        self::assertSame("> <!-- selector: p[title=\"a&amp;b\"] &lt;x&gt; -->\n> Names.", Quotation::render('p[title="a&b"] <x>', 'Names.', null, null));
244    }
245}
246