packages/requirements/tests/Unit/Config/Markdown/ReferenceTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Config\Markdown;
6
7use JsonException;
8use PHPUnit\Framework\Attributes\CoversClass;
9use PHPUnit\Framework\Attributes\DataProvider;
10use PHPUnit\Framework\Attributes\Small;
11use PHPUnit\Framework\Attributes\UsesClass;
12use PHPUnit\Framework\TestCase;
13use Requirements\Config\DefinitionReader;
14use Requirements\Config\DocumentReader;
15use Requirements\Config\JsonSchemaFile;
16use Requirements\Config\Loader;
17use Requirements\Config\Markdown\Badges;
18use Requirements\Config\Markdown\CardReader;
19use Requirements\Config\Markdown\FieldReader;
20use Requirements\Config\Markdown\FieldSections;
21use Requirements\Config\Markdown\Frontmatter;
22use Requirements\Config\Markdown\Nodes;
23use Requirements\Config\Markdown\Profile\AllowedBlocks;
24use Requirements\Config\Markdown\Profile\BlockKind;
25use Requirements\Config\Markdown\Profile\DocumentSchema;
26use Requirements\Config\Markdown\Profile\Occurrences;
27use Requirements\Config\Markdown\Profile\SectionBlocks;
28use Requirements\Config\Markdown\Profile\TextConstraint;
29use Requirements\Config\Markdown\QuotationBlocks;
30use Requirements\Config\Markdown\Reference;
31use Requirements\Config\MarkdownDocument;
32use Requirements\Config\SchemaValidator;
33use Requirements\Ears\ConditionOrder;
34use Requirements\Ears\LiteralMask;
35use Requirements\Ears\SystemResponse;
36use Requirements\Ears\Validator;
37use Requirements\Ears\Wording;
38use Requirements\Input\Fields;
39use Requirements\Input\InvalidInputException;
40use Requirements\Model\Excerpt;
41use Requirements\Model\Item;
42use Requirements\Model\ItemValidator;
43use Requirements\Model\Source;
44use Tests\Fake\ProjectDirectory;
45
46#[CoversClass(Reference::class)]
47#[UsesClass(DefinitionReader::class)]
48#[UsesClass(DocumentReader::class)]
49#[UsesClass(JsonSchemaFile::class)]
50#[UsesClass(MarkdownDocument::class)]
51#[UsesClass(Badges::class)]
52#[UsesClass(CardReader::class)]
53#[UsesClass(FieldReader::class)]
54#[UsesClass(FieldSections::class)]
55#[UsesClass(Frontmatter::class)]
56#[UsesClass(AllowedBlocks::class)]
57#[UsesClass(BlockKind::class)]
58#[UsesClass(DocumentSchema::class)]
59#[UsesClass(Occurrences::class)]
60#[UsesClass(SectionBlocks::class)]
61#[UsesClass(TextConstraint::class)]
62#[UsesClass(QuotationBlocks::class)]
63#[UsesClass(SchemaValidator::class)]
64#[UsesClass(ConditionOrder::class)]
65#[UsesClass(LiteralMask::class)]
66#[UsesClass(SystemResponse::class)]
67#[UsesClass(Validator::class)]
68#[UsesClass(Wording::class)]
69#[UsesClass(Fields::class)]
70#[UsesClass(Excerpt::class)]
71#[UsesClass(ItemValidator::class)]
72#[UsesClass(Source::class)]
73#[UsesClass(Item::class)]
74#[UsesClass(Loader::class)]
75#[UsesClass(Nodes::class)]
76#[Small]
77final class ReferenceTest extends TestCase
78{
79 public function testReferenceHoldsTheLinkedIdTargetAndFile(): void
80 {
81 $reference = new Reference('REQ-001', 'reference.yaml#req-001', 'definition.md');
82 self::assertSame('REQ-001', $reference->id);
83 self::assertSame('reference.yaml#req-001', $reference->url);
84 self::assertSame('definition.md', $reference->file);
85 }
86
87 #[DataProvider('providerLinks')]
88 public function testValidateAcceptsALinkToTheDefiningFileAndHeading(string $id, string $file, string $url): void
89 {
90 $project = new ProjectDirectory();
91 $project->put('definitions/cards.md', '');
92 $project->put('definitions/other file.md', '');
93 $items = ['REQ-001' => new Item('REQ-001', 'requirement', 'A name starts with a letter.', 'supported', null, [], [], [], [], [], '', 'original', 'Why.', $project->path('definitions/other file.md'), []), 'SPEC.A-1' => new Item('SPEC.A-1', 'specification', 'The reader shall emit a tree.', 'supported', null, [], [], [], [], [], '', 'original', 'Why.', $project->path('definition.yaml'), [])];
94 (new Reference($id, $url, $project->path($file)))->validate($items);
95 $this->addToAssertionCount(1);
96 }
97
98 /**
99 * @return array<string, array{string, string, string}>
100 */
101 public static function providerLinks(): array
102 {
103 return [
104 'same file' => ['REQ-001', 'definitions/other file.md', '#req-001'],
105 'same file with an uppercase fragment' => ['REQ-001', 'definitions/other file.md', '#REQ-001'],
106 'same file without a fragment' => ['REQ-001', 'definitions/other file.md', ''],
107 'sibling file' => ['REQ-001', 'definitions/cards.md', 'other%20file.md#req-001'],
108 'sibling file without a fragment' => ['REQ-001', 'definitions/cards.md', 'other%20file.md'],
109 'parent directory' => ['SPEC.A-1', 'definitions/cards.md', '../definition.yaml#speca-1'],
110 'percent-encoded fragment' => ['SPEC.A-1', 'definition.yaml', 'definition.yaml#spec%61-1'],
111 ];
112 }
113
114 #[DataProvider('providerRemoteLinks')]
115 public function testValidateRejectsALinkThatIsNotLocal(string $url): void
116 {
117 $this->expectException(InvalidInputException::class);
118 $this->expectExceptionMessage("definition.md: reference 'REQ-001' must link to a local definition file.");
119 (new Reference('REQ-001', $url, 'definition.md'))->validate([]);
120 }
121
122 /**
123 * @return array<string, array{string}>
124 */
125 public static function providerRemoteLinks(): array
126 {
127 return [
128 'scheme' => ['https://example.org/reference.yaml#req-001'],
129 'host' => ['//example.org/reference.yaml#req-001'],
130 'query' => ['reference.yaml?x=1#req-001'],
131 'malformed' => ['http:///reference.yaml'],
132 ];
133 }
134
135 public function testValidateRejectsALinkToAnUnknownItem(): void
136 {
137 $project = new ProjectDirectory();
138 $this->expectException(InvalidInputException::class);
139 $this->expectExceptionMessage($project->path('definition.md') . ": link to 'REQ-001' does not point to its loaded definition file.");
140 (new Reference('REQ-001', 'definition.yaml#req-001', $project->path('definition.md')))->validate([]);
141 }
142
143 public function testValidateRejectsALinkToAnotherFile(): void
144 {
145 $project = new ProjectDirectory();
146 $items = ['SPEC-001' => new Item('SPEC-001', 'specification', 'The reader shall emit a tree.', 'supported', null, [], [], [], [], [], '', 'original', 'Why.', $project->path('definition.yaml'), [])];
147 $this->expectException(InvalidInputException::class);
148 $this->expectExceptionMessage("link to 'SPEC-001' does not point to its loaded definition file.");
149 (new Reference('SPEC-001', 'source.html#spec-001', $project->path('definition.md')))->validate($items);
150 }
151
152 public function testValidateRejectsALinkToAMissingFile(): void
153 {
154 $project = new ProjectDirectory();
155 $items = ['SPEC-001' => new Item('SPEC-001', 'specification', 'The reader shall emit a tree.', 'supported', null, [], [], [], [], [], '', 'original', 'Why.', $project->path('definition.yaml'), [])];
156 $this->expectException(InvalidInputException::class);
157 $this->expectExceptionMessage("link to 'SPEC-001' does not point to its loaded definition file.");
158 (new Reference('SPEC-001', 'missing.yaml#spec-001', $project->path('definition.md')))->validate($items);
159 }
160
161 public function testValidateRejectsTheWrongHeadingFragment(): void
162 {
163 $project = new ProjectDirectory();
164 $items = ['SPEC-001' => new Item('SPEC-001', 'specification', 'The reader shall emit a tree.', 'supported', null, [], [], [], [], [], '', 'original', 'Why.', $project->path('definition.yaml'), [])];
165 $this->expectException(InvalidInputException::class);
166 $this->expectExceptionMessage($project->path('definition.md') . ": link to 'SPEC-001' has the wrong heading fragment.");
167 (new Reference('SPEC-001', 'definition.yaml#spec-002', $project->path('definition.md')))->validate($items);
168 }
169
170 /**
171 * @throws JsonException
172 */
173 public function testValidateRejectsALinkToAnotherLoadedDefinitionFile(): void
174 {
175 $project = new ProjectDirectory();
176 $project->write('requirements.yaml', ['version' => 1, 'definitions' => ['*.md', 'definition.yaml'], 'markdown' => ['experimental' => true]]);
177 $project->put('other.md', <<<'MD'
178---
179version: 1
180source: null
181---
182
183# OTHER-001
184
185The reader shall reject truncated input.
186
187**origin**
188
189original
190
191**reason**
192
193Avoid silent data loss.
194
195**related**
196
197- [SPEC-001](other.md#spec-001)
198MD);
199 $this->expectException(InvalidInputException::class);
200 $this->expectExceptionMessage('does not point to its loaded definition file');
201 (new Loader())->load($project->path('requirements.yaml'));
202 }
203}
204