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