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' => [' ', true],
42 'text before the image' => ['see ', false],
43 'statement' => ['The reader shall emit a tree.', false],
44 'heading with an image' => ['# ', false],
45 'quotation with an image' => ['> ', 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(" \n\n"), $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(''), $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(''), $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' => [' 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' => [' *a*', 'The badge row must contain only images with nonempty destinations.'],
105 'empty alt text' => ['', 'Badge alt text must contain its attribute value.'],
106 'blank alt text' => ['', 'Badge alt text must contain its attribute value.'],
107 'duplicate status' => [' ', "Duplicate 'status' badge or field."],
108 'duplicate label' => [' ', 'Label badges must be unique.'],
109 'misleading static badge' => ['', 'Static badge image text and role must agree with its alt text and title.'],
110 'unknown badge role' => ['', 'A custom badge title must be kind, status, origin, category or label.'],
111 'inline HTML in the alt text' => ['', '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(''), (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(''), (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, ''],
188 'new unsupported status' => ['status', 'unsupported', null, ''],
189 'new supported status' => ['status', 'supported', null, ''],
190 'new unsupported label' => ['label', 'unsupported', null, ''],
191 'leading dash' => ['category', '-names', null, ''],
192 'trailing dash' => ['label', 'end-', null, ''],
193 'underscore' => ['label', 'under_score', null, ''],
194 'spaces' => ['label', 'with spaces', null, ''],
195 'unicode' => ['label', '日本語', null, ''],
196 'read image' => ['label', 'grammar', ['url' => 'assets/grammar.svg', 'title' => null], ''],
197 'read image with title' => ['category', 'lexical', ['url' => '../assets/lexical.svg', 'title' => 'category'], ''],
198 'read image with quote in title' => ['category', 'lexical', ['url' => 'a.svg', 'title' => 'a"b'], ''],
199 'read image with space' => ['label', 'grammar', ['url' => 'my assets/grammar.svg', 'title' => null], ''],
200 ];
201 }
202}
203