packages/requirements/tests/Unit/Config/Markdown/MetadataReaderTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Config\Markdown;
6
7use League\CommonMark\Exception\CommonMarkException;
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\Markdown\MetadataReader;
14use Requirements\Config\Markdown\Nodes;
15use Requirements\Input\InvalidInputException;
16use stdClass;
17use Tests\Fake\MarkdownNodes;
18
19#[CoversClass(MetadataReader::class)]
20#[UsesClass(Nodes::class)]
21#[Small]
22final class MetadataReaderTest extends TestCase
23{
24    /**
25     * @throws CommonMarkException
26     */
27    public function testReadReadsAListOfKeysAndValues(): void
28    {
29        $markdown = "- **owner:** Parser team\n- **reviewed:** true\n- **priority:** 2\n- **text:** \"true\"\n- **values:**\n  - stable\n  - 1\n";
30        self::assertEquals((object) ['owner' => 'Parser team', 'reviewed' => true, 'priority' => 2, 'text' => 'true', 'values' => ['stable', 1]], (new MetadataReader())->read(MarkdownNodes::first($markdown)));
31    }
32
33    /**
34     * @throws CommonMarkException
35     */
36    public function testReadReadsAJsonObjectParagraph(): void
37    {
38        self::assertEquals((object) ['owner' => 'Parser team', 'values' => [1]], (new MetadataReader())->read(MarkdownNodes::first('{"owner": "Parser team", "values": [1]}')));
39    }
40
41    /**
42     * @throws CommonMarkException
43     */
44    public function testReadReadsAnEmptyJsonObjectParagraph(): void
45    {
46        self::assertEquals(new stdClass(), (new MetadataReader())->read(MarkdownNodes::first('{}')));
47    }
48
49    /**
50     * @throws CommonMarkException
51     */
52    #[DataProvider('providerNotMappings')]
53    public function testReadRejectsAValueThatIsNotAMapping(string $markdown): void
54    {
55        $this->expectException(InvalidInputException::class);
56        $this->expectExceptionMessage('Metadata must be a list of bold keys and values.');
57        (new MetadataReader())->read(MarkdownNodes::first($markdown));
58    }
59
60    /**
61     * @return array<string, array{string}>
62     */
63    public static function providerNotMappings(): array
64    {
65        return [
66            'text' => ['Parser team'],
67            'JSON array' => ['[1, 2]'],
68            'JSON number' => ['2'],
69            'sequence list' => ["- stable\n- 1\n"],
70        ];
71    }
72
73    /**
74     * @throws CommonMarkException
75     */
76    public function testNestedReadsAMapping(): void
77    {
78        self::assertEquals((object) ['a' => 1, 'b' => 'text'], (new MetadataReader())->nested(MarkdownNodes::first("- **a:** 1\n- **b:** text\n")));
79    }
80
81    /**
82     * @throws CommonMarkException
83     */
84    public function testNestedReadsASequence(): void
85    {
86        self::assertSame(['stable', 1, null, true], (new MetadataReader())->nested(MarkdownNodes::first("- stable\n- 1\n- null\n- true\n")));
87    }
88
89    /**
90     * @throws CommonMarkException
91     */
92    public function testNestedReadsNestedSequencesUnderAnEmptyListValue(): void
93    {
94        self::assertSame([['a', 'b'], []], (new MetadataReader())->nested(MarkdownNodes::first("- []\n  - a\n  - b\n- []\n")));
95    }
96
97    /**
98     * @throws CommonMarkException
99     */
100    public function testNestedReadsNestedMappings(): void
101    {
102        self::assertEquals((object) ['outer' => (object) ['inner' => [(object) ['value' => null]]]], (new MetadataReader())->nested(MarkdownNodes::first("- **outer:**\n  - **inner:**\n    - []\n      - **value:** null\n")));
103    }
104
105    /**
106     * @throws CommonMarkException
107     */
108    #[DataProvider('providerMalformedLists')]
109    public function testNestedRejectsMalformedLists(string $markdown, string $message): void
110    {
111        $this->expectException(InvalidInputException::class);
112        $this->expectExceptionMessage($message);
113        (new MetadataReader())->nested(MarkdownNodes::first($markdown));
114    }
115
116    /**
117     * @return array<string, array{string, string}>
118     */
119    public static function providerMalformedLists(): array
120    {
121        return [
122            'ordered list' => ['1. a', 'Expected a bullet list.'],
123            'quotation item' => ['- > a', 'Expected a metadata key or value.'],
124            'key after value' => ["- a\n- **b:** 1\n", 'Do not mix mapping keys and sequence values in one metadata list.'],
125            'value after key' => ["- **a:** 1\n- b\n", 'Do not mix mapping keys and sequence values in one metadata list.'],
126            'nested list under a key with a value' => ["- **a:** 1\n  - b\n", 'Nested metadata must have an empty parent value and one nested list.'],
127            'nested list under a value' => ["- x\n  - b\n", 'Nested metadata must have an empty parent value and one nested list.'],
128            'nested list under an empty list key' => ["- **a:** []\n  - b\n", 'Nested metadata must have an empty parent value and one nested list.'],
129            'two nested blocks' => ["- **a:**\n\n  - b\n\n  c\n", 'Nested metadata must have an empty parent value and one nested list.'],
130            'empty key' => ['- **:** 1', 'Metadata keys must be nonempty and unique.'],
131            'duplicate key' => ["- **a:** 1\n- **a:** 2\n", 'Metadata keys must be nonempty and unique.'],
132            'empty value' => ['- **a:**', 'Write "" for an empty string or supply a metadata value.'],
133            'key without colon' => ['- **a** 1', 'Expected a colon after the bold field name.'],
134        ];
135    }
136
137    #[DataProvider('providerScalars')]
138    public function testScalarDecodesJsonOrKeepsText(string $text, mixed $expected): void
139    {
140        self::assertEquals($expected, (new MetadataReader())->scalar($text));
141    }
142
143    /**
144     * @return array<string, array{string, mixed}>
145     */
146    public static function providerScalars(): array
147    {
148        return [
149            'text' => ['Parser team', 'Parser team'],
150            'JSON string' => ['"true"', 'true'],
151            'empty JSON string' => ['""', ''],
152            'boolean' => ['true', true],
153            'integer' => ['2', 2],
154            'float' => ['1.5', 1.5],
155            'null' => ['null', null],
156            'empty list' => ['[]', []],
157            'object' => ['{"a": 1}', (object) ['a' => 1]],
158            'broken JSON' => ['{"a": 1', '{"a": 1'],
159        ];
160    }
161
162    public function testScalarRejectsAnEmptyValue(): void
163    {
164        $this->expectException(InvalidInputException::class);
165        $this->expectExceptionMessage('Write "" for an empty string or supply a metadata value.');
166        (new MetadataReader())->scalar('');
167    }
168}
169