packages/requirements/tests/Unit/Config/Markdown/FieldSectionsTest.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\FieldReader;
14use Requirements\Config\Markdown\FieldSections;
15use Requirements\Config\Markdown\MetadataReader;
16use Requirements\Config\Markdown\Nodes;
17use Requirements\Config\Markdown\Presentation;
18use Requirements\Config\Markdown\Reference;
19use Requirements\Input\InvalidInputException;
20use stdClass;
21use Tests\Fake\MarkdownNodes;
22
23#[CoversClass(FieldSections::class)]
24#[UsesClass(FieldReader::class)]
25#[UsesClass(MetadataReader::class)]
26#[UsesClass(Nodes::class)]
27#[UsesClass(Presentation::class)]
28#[UsesClass(Reference::class)]
29#[Small]
30final class FieldSectionsTest extends TestCase
31{
32    /**
33     * @throws CommonMarkException
34     */
35    public function testReadReadsEveryFieldSection(): void
36    {
37        $item = (object) ['id' => 'SPEC-001'];
38        $markdown = "**Origin**\n\noriginal\n\n**reason**\n\nAvoid silent\ndata loss.\n\nKeep input.\n\n**tests**\n\n- **unit:** Sample\\PassingTest::testPass\n\n**metadata**\n\n- **owner:** Parser team\n";
39        (new FieldSections(new Presentation()))->read($item, MarkdownNodes::blocks($markdown), 'definition.md', 'SPEC-001');
40        self::assertEquals((object) [
41            'id' => 'SPEC-001',
42            'origin' => 'original',
43            'reason' => "Avoid silent\ndata loss.\n\nKeep input.",
44            'tests' => [(object) ['runner' => 'unit', 'target' => 'Sample\\PassingTest::testPass']],
45            'metadata' => (object) ['owner' => 'Parser team'],
46        ], $item);
47    }
48
49    public function testReadReadsNothingFromNoBlocks(): void
50    {
51        $item = (object) ['id' => 'SPEC-001'];
52        $presentation = new Presentation();
53        (new FieldSections($presentation))->read($item, [], 'definition.md', 'SPEC-001');
54        self::assertEquals((object) ['id' => 'SPEC-001'], $item);
55        self::assertSame([], $presentation->links);
56    }
57
58    /**
59     * @throws CommonMarkException
60     */
61    #[DataProvider('providerReasonSpellings')]
62    public function testReadAcceptsTheSpellingsOfTheReasonField(string $heading): void
63    {
64        $item = new stdClass();
65        (new FieldSections(new Presentation()))->read($item, MarkdownNodes::blocks("**$heading**\n\nThis library does not generate C code.\n"), 'definition.md', 'GENERATOR-001');
66        self::assertEquals((object) ['reason' => 'This library does not generate C code.'], $item);
67    }
68
69    /**
70     * @return array<string, array{string}>
71     */
72    public static function providerReasonSpellings(): array
73    {
74        return [
75            'reason' => ['reason'],
76            'unsupport reason' => ['unsupport reason'],
77            'unsupported reason' => ['unsupported reason'],
78            'rationale' => ['Rationale'],
79        ];
80    }
81
82    /**
83     * @throws CommonMarkException
84     */
85    public function testReadRemembersLinksAndReferences(): void
86    {
87        $presentation = new Presentation();
88        $markdown = "**origin**\n\noriginal\n\n**requirements**\n\n- [REQ-001](reference.yaml#req-001)\n\n**related**\n\n- [SPEC-002](#spec-002)\n";
89        (new FieldSections($presentation))->read(new stdClass(), MarkdownNodes::blocks($markdown), 'definition.md', 'SPEC-001');
90        self::assertSame(['SPEC-001' => ['origin' => [], 'requirements' => ['REQ-001' => 'reference.yaml#req-001'], 'related' => ['SPEC-002' => '#spec-002']]], $presentation->links);
91        self::assertEquals([new Reference('REQ-001', 'reference.yaml#req-001', 'definition.md'), new Reference('SPEC-002', '#spec-002', 'definition.md')], $presentation->references);
92        self::assertSame([], $presentation->badges);
93    }
94
95    /**
96     * @throws CommonMarkException
97     */
98    public function testReadRemembersLabelBadges(): void
99    {
100        $presentation = new Presentation();
101        $presentation->badges['SPEC-001']['kind'] = ['requirement' => ['url' => 'kind.svg', 'title' => null]];
102        $item = new stdClass();
103        (new FieldSections($presentation))->read($item, MarkdownNodes::blocks("**labels**\n\n![grammar](assets/grammar.svg) ![strictness](https://example.invalid/badge.svg)\n"), 'definition.md', 'SPEC-001');
104        self::assertSame(['grammar', 'strictness'], $item->labels);
105        self::assertSame(['SPEC-001' => [
106            'kind' => ['requirement' => ['url' => 'kind.svg', 'title' => null]],
107            'label' => ['grammar' => ['url' => 'assets/grammar.svg', 'title' => null], 'strictness' => ['url' => 'https://example.invalid/badge.svg', 'title' => null]],
108        ]], $presentation->badges);
109    }
110
111    /**
112     * @throws CommonMarkException
113     */
114    #[DataProvider('providerMalformedSections')]
115    public function testReadRejectsMalformedSections(string $markdown, string $message): void
116    {
117        $this->expectException(InvalidInputException::class);
118        $this->expectExceptionMessage($message);
119        (new FieldSections(new Presentation()))->read(new stdClass(), MarkdownNodes::blocks($markdown), 'definition.md', 'SPEC-001');
120    }
121
122    /**
123     * @return array<string, array{string, string}>
124     */
125    public static function providerMalformedSections(): array
126    {
127        return [
128            'extra paragraph' => ["Another paragraph.\n\n**origin**\n\noriginal\n", 'definition.md: SPEC-001 expects a bold field heading after its statement.'],
129            'duplicate field' => ["**origin**\n\noriginal\n\n**origin**\n\noriginal\n", "definition.md: SPEC-001 has duplicate field 'origin'."],
130            'duplicate reason spelling' => ["**rationale**\n\nWhy.\n\n**reason**\n\nWhy.\n", "definition.md: SPEC-001 has duplicate field 'reason'."],
131            'unknown field' => ["**lables**\n\n- grammar\n", "definition.md: SPEC-001.lables: Unknown Markdown field 'lables'."],
132            'empty last field' => ["**origin**\n\noriginal\n\n**reason**\n", "definition.md: SPEC-001.reason: Markdown field 'reason' needs a value."],
133            'empty field before another' => ["**origin**\n\n**reason**\n\nWhy.\n", "definition.md: SPEC-001.origin: Markdown field 'origin' needs a value."],
134        ];
135    }
136
137    /**
138     * @throws CommonMarkException
139     */
140    public function testAssignReadsOneFieldIntoTheItem(): void
141    {
142        $item = new stdClass();
143        $presentation = new Presentation();
144        (new FieldSections($presentation))->assign($item, 'requirements', MarkdownNodes::blocks('- [REQ-001](#req-001)'), 'definition.md', 'SPEC-001');
145        self::assertSame(['REQ-001'], $item->requirements);
146        self::assertSame(['SPEC-001' => ['requirements' => ['REQ-001' => '#req-001']]], $presentation->links);
147        self::assertEquals([new Reference('REQ-001', '#req-001', 'definition.md')], $presentation->references);
148    }
149
150    /**
151     * @throws CommonMarkException
152     */
153    public function testAssignRejectsAFieldTheItemAlreadyHas(): void
154    {
155        $this->expectException(InvalidInputException::class);
156        $this->expectExceptionMessage("definition.md: SPEC-001 has duplicate field 'category'.");
157        (new FieldSections(new Presentation()))->assign((object) ['category' => 'grammar'], 'category', MarkdownNodes::blocks('lexical'), 'definition.md', 'SPEC-001');
158    }
159
160    public function testAssignKeepsTheCauseOfAMalformedValue(): void
161    {
162        $error = null;
163        try {
164            (new FieldSections(new Presentation()))->assign(new stdClass(), 'origin', [], 'definition.md', 'SPEC-001');
165        } catch (InvalidInputException $caught) {
166            $error = $caught;
167        }
168        self::assertInstanceOf(InvalidInputException::class, $error);
169        self::assertInstanceOf(InvalidInputException::class, $error->getPrevious());
170        self::assertSame("Markdown field 'origin' needs a value.", $error->getPrevious()->getMessage());
171    }
172}
173