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