packages/requirements/tests/Unit/Config/DocumentReaderTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Config;
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\DocumentReader;
14use Requirements\Config\JsonSchemaFile;
15use Requirements\Config\Markdown\Badges;
16use Requirements\Config\Markdown\CardReader;
17use Requirements\Config\Markdown\Citation;
18use Requirements\Config\Markdown\FieldReader;
19use Requirements\Config\Markdown\FieldSections;
20use Requirements\Config\Markdown\Frontmatter;
21use Requirements\Config\Markdown\Nodes;
22use Requirements\Config\Markdown\Profile\AllowedBlocks;
23use Requirements\Config\Markdown\Profile\BlockKind;
24use Requirements\Config\Markdown\Profile\DocumentSchema;
25use Requirements\Config\Markdown\Profile\Occurrences;
26use Requirements\Config\Markdown\Profile\SectionBlocks;
27use Requirements\Config\Markdown\Profile\TextConstraint;
28use Requirements\Config\Markdown\QuotationBlocks;
29use Requirements\Config\MarkdownDocument;
30use Requirements\Config\SchemaValidator;
31use Requirements\Input\Fields;
32use Requirements\Input\InvalidInputException;
33use Requirements\Model\Source;
34use stdClass;
35use Symfony\Component\Yaml\Exception\ParseException;
36use Tests\Fake\ProjectDirectory;
37
38#[CoversClass(DocumentReader::class)]
39#[UsesClass(Fields::class)]
40#[UsesClass(JsonSchemaFile::class)]
41#[UsesClass(MarkdownDocument::class)]
42#[UsesClass(SchemaValidator::class)]
43#[UsesClass(Badges::class)]
44#[UsesClass(CardReader::class)]
45#[UsesClass(Citation::class)]
46#[UsesClass(FieldReader::class)]
47#[UsesClass(FieldSections::class)]
48#[UsesClass(Frontmatter::class)]
49#[UsesClass(Nodes::class)]
50#[UsesClass(AllowedBlocks::class)]
51#[UsesClass(BlockKind::class)]
52#[UsesClass(DocumentSchema::class)]
53#[UsesClass(Occurrences::class)]
54#[UsesClass(SectionBlocks::class)]
55#[UsesClass(TextConstraint::class)]
56#[UsesClass(QuotationBlocks::class)]
57#[UsesClass(Source::class)]
58#[Small]
59final class DocumentReaderTest extends TestCase
60{
61    /**
62     * @throws JsonException
63     */
64    public function testReadParsesYamlDefinitionAsObjects(): void
65    {
66        $project = new ProjectDirectory();
67        $expected = (object) [
68            'version' => 1,
69            'source' => (object) ['id' => 'manual', 'uri' => 'source.html', 'format' => 'html', 'selector' => 'main p'],
70            'items' => [(object) ['id' => 'SPEC-001', 'statement' => 'When a name is read, the parser shall require a leading letter.', 'evidence' => [(object) ['selector' => '#a', 'quote' => 'Names shall start with a letter.']]]],
71        ];
72        self::assertEquals($expected, (new DocumentReader())->read($project->path('definition.yaml'), 'definition'));
73    }
74
75    /**
76     * @throws JsonException
77     */
78    public function testReadParsesYamlConfiguration(): void
79    {
80        $project = new ProjectDirectory();
81        $expected = (object) ['version' => 1, 'definitions' => ['definition.yaml'], 'coverage' => (object) ['minimum' => 0]];
82        self::assertEquals($expected, (new DocumentReader())->read($project->path('requirements.yaml'), 'config'));
83    }
84
85    /**
86     * @throws JsonException
87     */
88    public function testReadParsesEmptyYamlMappingAsObject(): void
89    {
90        $project = new ProjectDirectory();
91        $project->put('local.yaml', "version: 1\nsource:\n  id: manual\n  uri: source.html\n  format: html\n  selector: p\n  options: {}\nitems: []\n");
92        $document = (new DocumentReader())->read($project->path('local.yaml'), 'definition');
93        self::assertInstanceOf(stdClass::class, $document->source);
94        self::assertEquals(new stdClass(), $document->source->options);
95    }
96
97    /**
98     * @throws JsonException
99     */
100    public function testReadValidatesAgainstNamedSchema(): void
101    {
102        $project = new ProjectDirectory();
103        $this->expectException(InvalidInputException::class);
104        $this->expectExceptionMessage($project->path('requirements.yaml') . ': schema validation failed: {"/":["The required properties (source) are missing"]}');
105        (new DocumentReader())->read($project->path('requirements.yaml'), 'definition');
106    }
107
108    /**
109     * @throws JsonException
110     */
111    public function testReadRejectsDocumentThatIsNotMapping(): void
112    {
113        $project = new ProjectDirectory();
114        $file = $project->put('list.yaml', "- 1\n");
115        $this->expectException(InvalidInputException::class);
116        $this->expectExceptionMessage("$file: schema validation failed: {\"/\":[\"The data (array) must match the type: object\"]}");
117        (new DocumentReader())->read($file, 'definition');
118    }
119
120    /**
121     * @throws JsonException
122     */
123    #[DataProvider('providerMalformedYaml')]
124    public function testReadRejectsMalformedYaml(string $yaml, string $message): void
125    {
126        $project = new ProjectDirectory();
127        $file = $project->put('malformed.yaml', $yaml);
128        $this->expectException(ParseException::class);
129        $this->expectExceptionMessage($message);
130        (new DocumentReader())->read($file, 'definition');
131    }
132
133    /**
134     * @return array<string, array{string, string}>
135     */
136    public static function providerMalformedYaml(): array
137    {
138        return [
139            'unclosed sequence' => ["version: [\n", 'Malformed inline YAML string'],
140            'custom tag' => ["version: !custom 1\nsource: null\nitems: []\n", 'Tags support is not enabled'],
141            'php object' => ["version: 1\nsource: null\nitems: []\nx: !php/object 'O:8:\"stdClass\":0:{}'\n", 'Object support when parsing a YAML file has been disabled'],
142        ];
143    }
144
145    /**
146     * @throws JsonException
147     */
148    #[DataProvider('providerMarkdownFiles')]
149    public function testReadDispatchesMarkdownFiles(string $file): void
150    {
151        $project = new ProjectDirectory();
152        $path = $project->put($file, "---\nversion: 1\nsource: null\n---\n\n# SPEC-001\n\nThe converter shall uppercase letters.\n");
153        $this->expectException(InvalidInputException::class);
154        $this->expectExceptionMessage("$path: Markdown definitions require markdown.experimental: true.");
155        (new DocumentReader())->read($path, 'definition');
156    }
157
158    /**
159     * @return array<string, array{string}>
160     */
161    public static function providerMarkdownFiles(): array
162    {
163        return [
164            'md' => ['definition.md'],
165            'markdown' => ['definition.markdown'],
166            'upper case' => ['definition.MD'],
167        ];
168    }
169
170    /**
171     * @throws JsonException
172     */
173    public function testReadParsesMarkdownDefinitionWithOptions(): void
174    {
175        $project = new ProjectDirectory();
176        $path = $project->put('definition.md', "---\nversion: 1\nsource:\n  id: manual\n  uri: source.html\n  format: html\n  selector: main p\n---\n\n# SPEC-001\n\nWhen a name is read, the parser shall require a leading letter.\n\n**evidence**\n\n- **selector:** #a\n\n  > Names shall start with a letter.\n");
177        $reader = new DocumentReader();
178        $expected = (object) [
179            'version' => 1,
180            'source' => (object) ['id' => 'manual', 'uri' => 'source.html', 'format' => 'html', 'selector' => 'main p'],
181            'items' => [(object) ['id' => 'SPEC-001', 'statement' => 'When a name is read, the parser shall require a leading letter.', 'evidence' => [(object) ['selector' => '#a', 'quote' => 'Names shall start with a letter.']]]],
182        ];
183        self::assertEquals($expected, $reader->read($path, 'definition', ['experimental' => true], $project->directory));
184        self::assertSame([], $reader->markdown->references());
185    }
186
187    /**
188     * @throws JsonException
189     */
190    public function testReadValidatesMarkdownAgainstDefinitionSchema(): void
191    {
192        $project = new ProjectDirectory();
193        $path = $project->put('definition.md', "---\nversion: 1\nsource: null\n---\n\n# SPEC-001\n\nThe converter shall uppercase letters.\n");
194        $this->expectException(InvalidInputException::class);
195        $this->expectExceptionMessage("$path: schema validation failed:");
196        (new DocumentReader())->read($path, 'config', ['experimental' => true]);
197    }
198
199    public function testMarkdownIsTheGivenDocument(): void
200    {
201        $markdown = new MarkdownDocument();
202        self::assertSame($markdown, (new DocumentReader($markdown))->markdown);
203    }
204
205    #[DataProvider('providerPaths')]
206    public function testIsMarkdownRecognizesExtensions(string $path, bool $expected): void
207    {
208        self::assertSame($expected, DocumentReader::isMarkdown($path));
209    }
210
211    /**
212     * @return array<string, array{string, bool}>
213     */
214    public static function providerPaths(): array
215    {
216        return [
217            'md' => ['definition.md', true],
218            'markdown' => ['docs/definition.markdown', true],
219            'upper case md' => ['DEFINITION.MD', true],
220            'mixed case markdown' => ['definition.MarkDown', true],
221            'yaml' => ['definition.yaml', false],
222            'yml' => ['definition.yml', false],
223            'json' => ['definition.json', false],
224            'no extension' => ['md', false],
225            'md in the directory only' => ['docs.md/definition.yaml', false],
226            'md before the extension' => ['definition.md.yaml', false],
227            'suffix without dot' => ['definitionmd', false],
228            'longer extension' => ['definition.mdx', false],
229        ];
230    }
231
232    /**
233     * @throws JsonException
234     */
235    public function testMappingConvertsNestedObjectsToArrays(): void
236    {
237        $document = (object) ['version' => 1, 'source' => (object) ['id' => 'manual', 'options' => new stdClass()], 'items' => [(object) ['id' => 'SPEC-001']]];
238        self::assertSame(['version' => 1, 'source' => ['id' => 'manual', 'options' => []], 'items' => [['id' => 'SPEC-001']]], DocumentReader::mapping($document, 'definition.yaml'));
239    }
240
241    /**
242     * @throws JsonException
243     */
244    public function testMappingAcceptsEmptyDocument(): void
245    {
246        self::assertSame([], DocumentReader::mapping(new stdClass(), 'definition.yaml'));
247    }
248
249    /**
250     * @throws JsonException
251     */
252    public function testMappingRejectsNumericKeys(): void
253    {
254        $this->expectException(InvalidInputException::class);
255        $this->expectExceptionMessage('definition.yaml must have string keys.');
256        DocumentReader::mapping((object) ['first', 'second'], 'definition.yaml');
257    }
258
259    /**
260     * @throws JsonException
261     */
262    public function testMappingRejectsUnencodableValues(): void
263    {
264        $this->expectException(JsonException::class);
265        DocumentReader::mapping((object) ['value' => NAN], 'definition.yaml');
266    }
267
268    /**
269     * @throws JsonException
270     */
271    public function testMappingRejectsDeepDocuments(): void
272    {
273        $this->expectException(JsonException::class);
274        DocumentReader::mapping((object) ['value' => json_decode(str_repeat('[', 600) . str_repeat(']', 600), false, 1000)], 'definition.yaml');
275    }
276
277    #[DataProvider('providerYaml')]
278    public function testYamlWritesFormattedDocument(mixed $data, string $expected): void
279    {
280        self::assertSame($expected, DocumentReader::yaml($data));
281    }
282
283    /**
284     * @return array<string, array{mixed, string}>
285     */
286    public static function providerYaml(): array
287    {
288        return [
289            'multi-line text as literal block' => [['statement' => "First line.\nSecond line.\n"], "statement: |\n  First line.\n  Second line.\n"],
290            'object as mapping' => [(object) ['source' => (object) ['id' => 'manual']], "source:\n  id: manual\n"],
291            'empty list as sequence' => [['items' => []], "items: []\n"],
292            'two space indentation' => [['a' => ['b' => ['c' => 1]]], "a:\n  b:\n    c: 1\n"],
293            'nesting kept inline only after twelve levels' => [['1' => ['2' => ['3' => ['4' => ['5' => ['6' => ['7' => ['8' => ['9' => ['10' => ['11' => ['12' => ['13' => 1]]]]]]]]]]]]], "1:\n  2:\n    3:\n      4:\n        5:\n          6:\n            7:\n              8:\n                9:\n                  10:\n                    11:\n                      12: { 13: 1 }\n"],
294            'scalar' => ['text', 'text'],
295        ];
296    }
297}
298