packages/requirements/tests/Unit/Config/Markdown/BadgesTest.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\Badges;
14use Requirements\Config\Markdown\Nodes;
15use Requirements\Config\Markdown\StaticBadge;
16use Requirements\Input\InvalidInputException;
17use stdClass;
18use Tests\Fake\MarkdownNodes;
19
20#[CoversClass(Badges::class)]
21#[UsesClass(Nodes::class)]
22#[UsesClass(StaticBadge::class)]
23#[Small]
24final class BadgesTest extends TestCase
25{
26    /**
27     * @throws CommonMarkException
28     */
29    #[DataProvider('providerBlocks')]
30    public function testIsParagraphTellsWhetherABlockIsABadgeRow(string $markdown, bool $expected): void
31    {
32        self::assertSame($expected, Badges::isParagraph(MarkdownNodes::first($markdown)));
33    }
34
35    /**
36     * @return array<string, array{string, bool}>
37     */
38    public static function providerBlocks(): array
39    {
40        return [
41            'badge row' => ['![grammar](grammar.svg) ![lexical](lexical.svg)', true],
42            'text before the image' => ['see ![grammar](grammar.svg)', false],
43            'statement' => ['The reader shall emit a tree.', false],
44            'heading with an image' => ['# ![grammar](grammar.svg)', false],
45            'quotation with an image' => ['> ![grammar](grammar.svg)', false],
46        ];
47    }
48
49    /**
50     * @throws CommonMarkException
51     */
52    public function testReadSetsTheFieldsAndLabelsOfARow(): void
53    {
54        $item = new stdClass();
55        $badges = new Badges();
56        $badges->read(MarkdownNodes::first("![requirement](https://img.shields.io/badge/kind-requirement-blue) ![grammar](assets/grammar.svg)\n![lexical](../assets/lexical.svg \"category\")\n![strictness](https://img.shields.io/badge/label-strictness-blue)"), $item);
57        self::assertEquals((object) ['kind' => 'requirement', 'labels' => ['grammar', 'strictness'], 'category' => 'lexical'], $item);
58        self::assertSame([
59            'kind' => ['requirement' => ['url' => 'https://img.shields.io/badge/kind-requirement-blue', 'title' => null]],
60            'label' => ['grammar' => ['url' => 'assets/grammar.svg', 'title' => null], 'strictness' => ['url' => 'https://img.shields.io/badge/label-strictness-blue', 'title' => null]],
61            'category' => ['lexical' => ['url' => '../assets/lexical.svg', 'title' => 'category']],
62        ], $badges->images);
63    }
64
65    /**
66     * @throws CommonMarkException
67     */
68    public function testReadAppendsToTheLabelsAlreadyRead(): void
69    {
70        $item = (object) ['labels' => ['grammar']];
71        (new Badges())->read(MarkdownNodes::first('![strictness](strictness.svg)'), $item);
72        self::assertSame(['grammar', 'strictness'], $item->labels);
73    }
74
75    /**
76     * @throws CommonMarkException
77     */
78    public function testReadReadsTheTextOfEmphasisInTheAltText(): void
79    {
80        $item = new stdClass();
81        (new Badges())->read(MarkdownNodes::first('![a *b*](b.svg "status")'), $item);
82        self::assertSame('a b', $item->status);
83    }
84
85    /**
86     * @throws CommonMarkException
87     */
88    #[DataProvider('providerMalformedRows')]
89    public function testReadRejectsMalformedRows(string $markdown, string $message): void
90    {
91        $this->expectException(InvalidInputException::class);
92        $this->expectExceptionMessage($message);
93        (new Badges())->read(MarkdownNodes::first($markdown), new stdClass());
94    }
95
96    /**
97     * @return array<string, array{string, string}>
98     */
99    public static function providerMalformedRows(): array
100    {
101        return [
102            'text after the image' => ['![grammar](grammar.svg) text', 'The badge row must contain only images with nonempty destinations.'],
103            'empty destination' => ['![grammar]()', 'The badge row must contain only images with nonempty destinations.'],
104            'emphasis after the image' => ['![grammar](grammar.svg) *a*', 'The badge row must contain only images with nonempty destinations.'],
105            'empty alt text' => ['![](grammar.svg)', 'Badge alt text must contain its attribute value.'],
106            'blank alt text' => ['![ ](grammar.svg)', 'Badge alt text must contain its attribute value.'],
107            'duplicate status' => ['![supported](a.svg "status") ![unsupported](b.svg "status")', "Duplicate 'status' badge or field."],
108            'duplicate label' => ['![parser](a.svg) ![parser](b.svg)', 'Label badges must be unique.'],
109            'misleading static badge' => ['![unsupported](https://img.shields.io/badge/status-supported-blue)', 'Static badge image text and role must agree with its alt text and title.'],
110            'unknown badge role' => ['![value](a.svg "lable")', 'A custom badge title must be kind, status, origin, category or label.'],
111            'inline HTML in the alt text' => ['![<b>a</b>](a.svg)', 'Unsupported inline Markdown'],
112        ];
113    }
114
115    /**
116     * @throws CommonMarkException
117     */
118    public function testReadRejectsAFieldTheItemAlreadyHas(): void
119    {
120        $this->expectException(InvalidInputException::class);
121        $this->expectExceptionMessage("Duplicate 'category' badge or field.");
122        (new Badges())->read(MarkdownNodes::first('![grammar](a.svg "category")'), (object) ['category' => 'lexical']);
123    }
124
125    /**
126     * @throws CommonMarkException
127     */
128    public function testReadRejectsLabelsThatAreNotAList(): void
129    {
130        $this->expectException(InvalidInputException::class);
131        $this->expectExceptionMessage('Label badges must be unique.');
132        (new Badges())->read(MarkdownNodes::first('![grammar](a.svg)'), (object) ['labels' => 'parser']);
133    }
134
135    #[DataProvider('providerFields')]
136    public function testFieldDecidesTheFieldABadgeSets(string $url, ?string $title, string $expected): void
137    {
138        self::assertSame($expected, Badges::field($url, $title));
139    }
140
141    /**
142     * @return array<string, array{string, ?string, string}>
143     */
144    public static function providerFields(): array
145    {
146        return [
147            'title kind' => ['a.svg', 'kind', 'kind'],
148            'title status' => ['a.svg', 'status', 'status'],
149            'title origin' => ['a.svg', 'origin', 'origin'],
150            'title category' => ['a.svg', 'category', 'category'],
151            'title label' => ['https://img.shields.io/badge/status-supported-blue', 'label', 'label'],
152            'static status' => ['https://img.shields.io/badge/status-supported-blue', null, 'status'],
153            'static kind' => ['https://img.shields.io/badge/kind-requirement-blue', null, 'kind'],
154            'static origin' => ['https://img.shields.io/badge/origin-original-blue', null, 'origin'],
155            'static category' => ['https://img.shields.io/badge/category-lexical-blue', null, 'category'],
156            'static label' => ['https://img.shields.io/badge/label-grammar-blue', null, 'label'],
157            'static other role' => ['https://img.shields.io/badge/color-red-blue', null, 'label'],
158            'static role without dash' => ['https://img.shields.io/badge/status', null, 'label'],
159            'role later in the path' => ['https://img.shields.io/x/badge/status-supported-blue', null, 'label'],
160            'other host' => ['https://example.org/badge/status-supported-blue', null, 'label'],
161            'local image' => ['assets/grammar.svg', null, 'label'],
162        ];
163    }
164
165    public function testFieldRejectsATitleNamingAnotherField(): void
166    {
167        $this->expectException(InvalidInputException::class);
168        $this->expectExceptionMessage('A custom badge title must be kind, status, origin, category or label.');
169        Badges::field('a.svg', 'lable');
170    }
171
172    /**
173     * @param array{url: string, title: ?string}|null $image
174     */
175    #[DataProvider('providerRenderedBadges')]
176    public function testRenderWritesAMarkdownImage(string $field, string $value, ?array $image, string $expected): void
177    {
178        self::assertSame($expected, Badges::render($field, $value, $image));
179    }
180
181    /**
182     * @return array<string, array{string, string, array{url: string, title: ?string}|null, string}>
183     */
184    public static function providerRenderedBadges(): array
185    {
186        return [
187            'new kind' => ['kind', 'requirement', null, '![requirement](https://img.shields.io/badge/kind-requirement-blue)'],
188            'new unsupported status' => ['status', 'unsupported', null, '![unsupported](https://img.shields.io/badge/status-unsupported-orange)'],
189            'new supported status' => ['status', 'supported', null, '![supported](https://img.shields.io/badge/status-supported-blue)'],
190            'new unsupported label' => ['label', 'unsupported', null, '![unsupported](https://img.shields.io/badge/label-unsupported-blue)'],
191            'leading dash' => ['category', '-names', null, '![\\-names](https://img.shields.io/badge/category---names-blue)'],
192            'trailing dash' => ['label', 'end-', null, '![end-](https://img.shields.io/badge/label-end---blue)'],
193            'underscore' => ['label', 'under_score', null, '![under\\_score](https://img.shields.io/badge/label-under__score-blue)'],
194            'spaces' => ['label', 'with spaces', null, '![with spaces](https://img.shields.io/badge/label-with%20spaces-blue)'],
195            'unicode' => ['label', '日本語', null, '![日本語](https://img.shields.io/badge/label-%E6%97%A5%E6%9C%AC%E8%AA%9E-blue)'],
196            'read image' => ['label', 'grammar', ['url' => 'assets/grammar.svg', 'title' => null], '![grammar](assets/grammar.svg)'],
197            'read image with title' => ['category', 'lexical', ['url' => '../assets/lexical.svg', 'title' => 'category'], '![lexical](../assets/lexical.svg "category")'],
198            'read image with quote in title' => ['category', 'lexical', ['url' => 'a.svg', 'title' => 'a"b'], '![lexical](a.svg "a&quot;b")'],
199            'read image with space' => ['label', 'grammar', ['url' => 'my assets/grammar.svg', 'title' => null], '![grammar](<my%20assets/grammar.svg>)'],
200        ];
201    }
202}
203