packages/requirements/tests/Unit/Config/Markdown/CitationTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Config\Markdown;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\DataProvider;
9use PHPUnit\Framework\Attributes\Small;
10use PHPUnit\Framework\Attributes\UsesClass;
11use PHPUnit\Framework\TestCase;
12use Requirements\Config\Markdown\Citation;
13use Requirements\Config\Markdown\LocalPath;
14use Requirements\Input\InvalidInputException;
15use Requirements\Model\Source;
16use Requirements\Source\TextFragment;
17use Requirements\Source\Unit;
18
19#[CoversClass(Citation::class)]
20#[UsesClass(LocalPath::class)]
21#[UsesClass(Source::class)]
22#[UsesClass(TextFragment::class)]
23#[UsesClass(Unit::class)]
24#[Small]
25final class CitationTest extends TestCase
26{
27 #[DataProvider('providerSelectors')]
28 public function testSelectorDerivesTheSelectorFromTheLink(string $uri, string $format, string $file, string $url, ?string $selector, string $expected): void
29 {
30 $citation = new Citation(new Source('manual', $uri, $format, 'main p'), $file, '/project');
31 self::assertSame($expected, $citation->selector($url, 'Names shall start with a letter.', $selector));
32 }
33
34 /**
35 * @return array<string, array{string, string, string, string, ?string, string}>
36 */
37 public static function providerSelectors(): array
38 {
39 return [
40 'element ID' => ['source.html', 'html', '/project/definition.md', 'source.html#a', null, '#a'],
41 'element ID from a subdirectory' => ['source.html', 'html', '/project/definitions/names.md', '../source.html#a', null, '#a'],
42 'element ID with the source in a subdirectory' => ['docs/source.html', 'html', '/project/definition.md', 'docs/./source.html#a', null, '#a'],
43 'source URI with a fragment' => ['source.html#main', 'html', '/project/definition.md', 'source.html#a', null, '#a'],
44 'percent-encoded resource' => ['my source.html', 'html', '/project/definition.md', 'my%20source.html#a', null, '#a'],
45 'percent-encoded element ID' => ['source.html', 'html', '/project/definition.md', 'source.html#%61', null, '#a'],
46 'XML element ID' => ['rfc.xml', 'xml', '/project/definition.md', 'rfc.xml#rules', null, '#rules'],
47 'IETF element ID' => ['rfc.xml', 'ietf', '/project/definition.md', 'rfc.xml#rules', null, '#rules'],
48 'Markdown element ID' => ['rules.md', 'markdown', '/project/definition.md', 'rules.md#rules', null, '#rules'],
49 'remote source' => ['https://example.org/spec.html', 'html', '/project/definition.md', 'https://example.org/spec.html#a', null, '#a'],
50 'remote source with an uppercase scheme' => ['HTTPS://example.org/spec.html', 'html', '/project/definition.md', 'HTTPS://example.org/spec.html#a', null, '#a'],
51 'Text Fragment' => ['source.html', 'html', '/project/definition.md', 'source.html#:~:text=Names%20shall%20start%20with%20a%20letter.', null, '#:~:text=Names%20shall%20start%20with%20a%20letter.'],
52 'Text Fragment with a selector comment' => ['source.html', 'html', '/project/definition.md', 'source.html#:~:text=Names%20shall%20start%20with%20a%20letter.', 'main > p', 'main > p'],
53 'selector comment agreeing with the anchor' => ['source.html', 'html', '/project/definition.md', 'source.html#a', '#a', '#a'],
54 'selector comment without an anchor' => ['source.html', 'html', '/project/definition.md', 'source.html', '#a', '#a'],
55 'CSS selector comment with an anchor' => ['source.html', 'html', '/project/definition.md', 'source.html#b', 'main p', 'main p'],
56 'selector comment for a JSON source' => ['source.json', 'json', '/project/definition.md', 'source.json', '$.rules[0].text', '$.rules[0].text'],
57 ];
58 }
59
60 #[DataProvider('providerRejectedCitations')]
61 public function testSelectorRejectsCitationsThatDoNotIdentifyTheUnit(string $uri, string $format, string $url, ?string $selector, string $message): void
62 {
63 $citation = new Citation(new Source('manual', $uri, $format, 'main p'), '/project/definition.md', '/project');
64 $this->expectException(InvalidInputException::class);
65 $this->expectExceptionMessage($message);
66 $citation->selector($url, 'Names shall start with a letter.', $selector);
67 }
68
69 /**
70 * @return array<string, array{string, string, string, ?string, string}>
71 */
72 public static function providerRejectedCitations(): array
73 {
74 return [
75 'other resource' => ['source.html', 'html', 'different.html#a', null, 'An evidence citation must link to this definition\'s source resource.'],
76 'fragment only' => ['source.html', 'html', '#a', null, 'An evidence citation must link to this definition\'s source resource.'],
77 'resource in another directory' => ['source.html', 'html', '../source.html#a', null, 'An evidence citation must link to this definition\'s source resource.'],
78 'other remote resource' => ['https://example.org/spec.html', 'html', 'https://example.org/other.html#a', null, 'An evidence citation must link to this definition\'s source resource.'],
79 'local link to a remote source' => ['https://example.org/spec.html', 'html', 'spec.html#a', null, 'An evidence citation must link to this definition\'s source resource.'],
80 'wrong Text Fragment text' => ['source.html', 'html', 'source.html#:~:text=Different%20text.', null, 'An exact Text Fragment citation must identify the complete quoted HTML unit.'],
81 'Text Fragment outside HTML' => ['rules.md', 'markdown', 'rules.md#:~:text=Names%20shall%20start%20with%20a%20letter.', null, 'An exact Text Fragment citation must identify the complete quoted HTML unit.'],
82 'Text Fragment range' => ['source.html', 'html', 'source.html#:~:text=Names,letter.', null, 'Use one exact Text Fragment'],
83 'anchor disagreeing with the selector comment' => ['source.html', 'html', 'source.html#b', '#a', 'The citation anchor disagrees with the evidence selector.'],
84 'no anchor' => ['source.html', 'html', 'source.html', null, 'Supply a selector comment, an element-ID citation, or an exact Text Fragment citation.'],
85 'CSS anchor' => ['source.html', 'html', 'source.html#main%20p', null, 'Supply a selector comment, an element-ID citation, or an exact Text Fragment citation.'],
86 'element ID for a JSON source' => ['source.json', 'json', 'source.json#a', null, 'Supply a selector comment, an element-ID citation, or an exact Text Fragment citation.'],
87 'element ID for a text source' => ['source.txt', 'text', 'source.txt#a', null, 'Supply a selector comment, an element-ID citation, or an exact Text Fragment citation.'],
88 ];
89 }
90
91 #[DataProvider('providerValidSelectors')]
92 public function testValidateAcceptsSelectorsThatIdentifyTheQuote(string $format, string $selector): void
93 {
94 (new Citation(new Source('manual', 'source', $format, 'main p'), '/project/definition.md', '/project'))->validate($selector, "Names shall start\nwith a letter.");
95 $this->addToAssertionCount(1);
96 }
97
98 /**
99 * @return array<string, array{string, string}>
100 */
101 public static function providerValidSelectors(): array
102 {
103 return [
104 'element ID' => ['html', '#a'],
105 'CSS selector' => ['html', 'main p'],
106 'element ID outside HTML' => ['markdown', '#a'],
107 'exact Text Fragment' => ['html', '#:~:text=Names%20shall%20start%20with%20a%20letter.'],
108 ];
109 }
110
111 #[DataProvider('providerInvalidSelectors')]
112 public function testValidateRejectsATextFragmentThatSelectsOtherText(string $format, string $selector): void
113 {
114 $citation = new Citation(new Source('manual', 'source', $format, 'main p'), '/project/definition.md', '/project');
115 $this->expectException(InvalidInputException::class);
116 $this->expectExceptionMessage('An exact Text Fragment selector must identify the complete quoted HTML unit.');
117 $citation->validate($selector, 'Names shall start with a letter.');
118 }
119
120 /**
121 * @return array<string, array{string, string}>
122 */
123 public static function providerInvalidSelectors(): array
124 {
125 return [
126 'other text' => ['html', '#:~:text=Different%20text.'],
127 'part of the text' => ['html', '#:~:text=Names%20shall%20start'],
128 'outside HTML' => ['markdown', '#:~:text=Names%20shall%20start%20with%20a%20letter.'],
129 ];
130 }
131
132 #[DataProvider('providerUrls')]
133 public function testUrlCreatesTheCitationLink(string $uri, string $format, string $file, string $selector, string $expected): void
134 {
135 $citation = new Citation(new Source('manual', $uri, $format, 'main p'), $file, '/project');
136 self::assertSame($expected, $citation->url($selector, "Names shall start\nwith a letter."));
137 }
138
139 /**
140 * @return array<string, array{string, string, string, string, string}>
141 */
142 public static function providerUrls(): array
143 {
144 return [
145 'element ID' => ['source.html', 'html', '/project/definition.md', '#a', 'source.html#a'],
146 'element ID from a subdirectory' => ['source.html', 'html', '/project/definitions/names.md', '#a', '../source.html#a'],
147 'element ID from a nested subdirectory' => ['source.html', 'html', '/project/a/b/names.md', '#a', '../../source.html#a'],
148 'source in a subdirectory' => ['docs/source.html', 'html', '/project/definition.md', '#a', 'docs/source.html#a'],
149 'source in a sibling directory' => ['docs/source.html', 'html', '/project/definitions/names.md', '#a', '../docs/source.html#a'],
150 'source URI with a fragment' => ['source.html#main', 'html', '/project/definition.md', '#a', 'source.html#a'],
151 'source name with a space' => ['my source.html', 'html', '/project/definition.md', '#a', 'my%20source.html#a'],
152 'remote source' => ['https://example.org/spec.html#main', 'html', '/project/definition.md', '#a', 'https://example.org/spec.html#a'],
153 'Text Fragment' => ['source.html', 'html', '/project/definition.md', '#:~:text=Names', 'source.html#:~:text=Names'],
154 'CSS selector' => ['source.html', 'html', '/project/definition.md', 'main > p:first-child', 'source.html#:~:text=Names%20shall%20start%20with%20a%20letter.'],
155 'XML element ID' => ['rfc.xml', 'xml', '/project/definition.md', '#rules', 'rfc.xml#rules'],
156 'IETF element ID' => ['rfc.xml', 'ietf', '/project/definition.md', '#rules', 'rfc.xml#rules'],
157 'Markdown element ID' => ['rules.md', 'markdown', '/project/definition.md', '#rules', 'rules.md#rules'],
158 'Markdown heading selector' => ['rules.md', 'markdown', '/project/definition.md', 'h1', 'rules.md'],
159 'JSON element ID' => ['source.json', 'json', '/project/definition.md', '#a', 'source.json'],
160 'Text Fragment outside HTML' => ['rules.md', 'markdown', '/project/definition.md', '#:~:text=Names', 'rules.md'],
161 ];
162 }
163
164 #[DataProvider('providerIds')]
165 public function testIsIdTellsWhetherASelectorIsOneElementId(string $selector, bool $expected): void
166 {
167 self::assertSame($expected, Citation::isId($selector));
168 }
169
170 /**
171 * @return array<string, array{string, bool}>
172 */
173 public static function providerIds(): array
174 {
175 return [
176 'letter' => ['#a', true],
177 'hyphen, underscore and digits' => ['#_a-b_1', true],
178 'no hash' => ['a', false],
179 'leading digit' => ['#1a', false],
180 'space' => ['#a b', false],
181 'two IDs' => ['#a#b', false],
182 'trailing line break' => ["#a\n", false],
183 'leading text' => ['p#a', false],
184 'hash only' => ['#', false],
185 ];
186 }
187}
188