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