packages/requirements/tests/Unit/Config/Markdown/Profile/SectionBlocksTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Config\Markdown\Profile;
6
7use League\CommonMark\Environment\Environment;
8use League\CommonMark\Exception\CommonMarkException;
9use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
10use League\CommonMark\Parser\MarkdownParser;
11use PHPUnit\Framework\Attributes\CoversClass;
12use PHPUnit\Framework\Attributes\DataProvider;
13use PHPUnit\Framework\Attributes\Small;
14use PHPUnit\Framework\Attributes\UsesClass;
15use PHPUnit\Framework\TestCase;
16use Requirements\Config\Markdown\Nodes;
17use Requirements\Config\Markdown\Profile\BlockKind;
18use Requirements\Config\Markdown\Profile\Occurrences;
19use Requirements\Config\Markdown\Profile\SectionBlocks;
20use Requirements\Config\Markdown\Profile\TextConstraint;
21use Requirements\Input\Fields;
22use Requirements\Input\InvalidInputException;
23
24#[CoversClass(SectionBlocks::class)]
25#[UsesClass(BlockKind::class)]
26#[UsesClass(Fields::class)]
27#[UsesClass(Nodes::class)]
28#[UsesClass(Occurrences::class)]
29#[UsesClass(TextConstraint::class)]
30#[Small]
31final class SectionBlocksTest extends TestCase
32{
33    /**
34     * @param array<string, mixed> $schema
35     * @throws CommonMarkException
36     */
37    #[DataProvider('providerAccepted')]
38    public function testCheckAcceptsMatchingBlocks(string $markdown, array $schema): void
39    {
40        $environment = new Environment();
41        $environment->addExtension(new CommonMarkCoreExtension());
42        $nodes = array_values([...(new MarkdownParser($environment))->parse($markdown)->children()]);
43        SectionBlocks::check($nodes, $schema, 'doc.md');
44        $this->addToAssertionCount(1);
45    }
46
47    /**
48     * @return array<string, array{string, array<string, mixed>}>
49     */
50    public static function providerAccepted(): array
51    {
52        return [
53            'bundled section' => ["The reader shall emit a tree.\n\n> Quoted.\n\n- one\n", ['blocks' => [['type' => ['paragraph', 'bullet-list', 'quote'], 'minContains' => 1]], 'additionalBlocks' => false]],
54            'type as a string' => ["> Quoted.\n", ['blocks' => [['type' => 'quote']]]],
55            'entries in order' => ["A.\n\n- b\n", ['blocks' => [['type' => 'paragraph', 'maxContains' => 1], ['type' => 'bullet-list', 'minContains' => 0]], 'additionalBlocks' => false]],
56            'entry repeats' => ["A.\n\nB.\n\nC.\n", ['blocks' => [['type' => 'paragraph', 'minContains' => 3]], 'additionalBlocks' => false]],
57            'first matching entry only' => ["A.\n", ['blocks' => [['type' => 'paragraph'], ['type' => 'paragraph', 'minContains' => 0, 'maxContains' => 0]], 'additionalBlocks' => false]],
58            'text constraint selects entry' => ["A.\n\nB.\n", ['blocks' => [['type' => 'paragraph', 'text' => ['pattern' => '^A'], 'maxContains' => 1], ['type' => 'paragraph', 'maxContains' => 1]], 'additionalBlocks' => false]],
59            'text constraint sees nothing in a list' => ["- a\n", ['blocks' => [['type' => 'bullet-list', 'text' => ['const' => '']]], 'additionalBlocks' => false]],
60            'text constraint reads paragraph text' => ["A *b*.\n", ['blocks' => [['type' => 'paragraph', 'text' => ['const' => 'A b.']]], 'additionalBlocks' => false]],
61            'additional blocks allowed by default' => ["A.\n\n```\ncode\n```\n", ['blocks' => [['type' => 'paragraph']]]],
62            'additional blocks allowed explicitly' => ["```\ncode\n```\n\nA.\n", ['blocks' => [['type' => 'paragraph']], 'additionalBlocks' => true]],
63            'no blocks and no entries' => ['', ['blocks' => []]],
64            'no blocks with optional entry' => ['', ['blocks' => [['type' => 'paragraph', 'minContains' => 0]]]],
65        ];
66    }
67
68    /**
69     * @throws CommonMarkException
70     */
71    #[DataProvider('providerUnexpected')]
72    public function testCheckRejectsUnexpectedBlock(string $markdown): void
73    {
74        $environment = new Environment();
75        $environment->addExtension(new CommonMarkCoreExtension());
76        $nodes = array_values([...(new MarkdownParser($environment))->parse($markdown)->children()]);
77        $this->expectException(InvalidInputException::class);
78        $this->expectExceptionMessage('doc.md: unexpected document-schema block.');
79        SectionBlocks::check($nodes, ['blocks' => [['type' => 'paragraph', 'maxContains' => 1], ['type' => 'bullet-list', 'minContains' => 0, 'text' => ['pattern' => 'a']]], 'additionalBlocks' => false], 'doc.md');
80    }
81
82    /**
83     * @return array<string, array{string}>
84     */
85    public static function providerUnexpected(): array
86    {
87        return [
88            'kind matching no entry' => ["A.\n\n```\ncode\n```\n"],
89            'earlier entry after a later one' => ["A.\n\n- b\n\nC.\n"],
90            'text constraint on a list' => ["A.\n\n- a\n"],
91        ];
92    }
93
94    /**
95     * @throws CommonMarkException
96     */
97    #[DataProvider('providerOccurrences')]
98    public function testCheckRejectsEntryOccurrences(string $markdown, string $message): void
99    {
100        $environment = new Environment();
101        $environment->addExtension(new CommonMarkCoreExtension());
102        $nodes = array_values([...(new MarkdownParser($environment))->parse($markdown)->children()]);
103        $this->expectException(InvalidInputException::class);
104        $this->expectExceptionMessage($message);
105        SectionBlocks::check($nodes, ['blocks' => [['type' => 'paragraph', 'text' => ['pattern' => '^A'], 'maxContains' => 1], ['type' => ['paragraph', 'quote'], 'maxContains' => 2]]], 'doc.md');
106    }
107
108    /**
109     * @return array<string, array{string, string}>
110     */
111    public static function providerOccurrences(): array
112    {
113        return [
114            'missing section' => ['', 'doc.md: section block 1: document-schema occurrence constraint failed.'],
115            'first entry skipped' => ["B.\n\nA.\n", 'doc.md: section block 1: document-schema occurrence constraint failed.'],
116            'first entry repeated' => ["A.\n\nA2.\n\nB.\n", 'doc.md: section block 1: document-schema occurrence constraint failed.'],
117            'second entry missing' => ["A.\n", 'doc.md: section block 2: document-schema occurrence constraint failed.'],
118            'second entry too often' => ["A.\n\nB.\n\n> C.\n\nD.\n", 'doc.md: section block 2: document-schema occurrence constraint failed.'],
119        ];
120    }
121
122    /**
123     * @param array<string, mixed> $schema
124     * @throws CommonMarkException
125     */
126    #[DataProvider('providerMalformedSchema')]
127    public function testCheckRejectsMalformedSchema(array $schema, string $message): void
128    {
129        $environment = new Environment();
130        $environment->addExtension(new CommonMarkCoreExtension());
131        $nodes = array_values([...(new MarkdownParser($environment))->parse("A.\n")->children()]);
132        $this->expectException(InvalidInputException::class);
133        $this->expectExceptionMessage($message);
134        SectionBlocks::check($nodes, $schema, 'doc.md');
135    }
136
137    /**
138     * @return array<string, array{array<string, mixed>, string}>
139     */
140    public static function providerMalformedSchema(): array
141    {
142        return [
143            'blocks not a list' => [['blocks' => ['first' => ['type' => 'paragraph']]], 'blocks must be a list.'],
144            'entry not a mapping' => [['blocks' => ['paragraph']], 'block must be a mapping.'],
145            'unknown entry key' => [['blocks' => [['type' => 'paragraph', 'header' => []]]], "block: unknown field 'header'."],
146            'type neither string nor list' => [['blocks' => [['type' => 1]]], 'block.type must be a list.'],
147            'text not a mapping' => [['blocks' => [['type' => 'paragraph', 'text' => 'A.']]], 'block.text must be a mapping.'],
148        ];
149    }
150}
151