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 > 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 > 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&b\"] <x> -->\n> Names.", Quotation::render('p[title="a&b"] <x>', 'Names.', null, null));
244 }
245}
246