packages/requirements/tests/Unit/Input/FieldsTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Input;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\DataProvider;
9use PHPUnit\Framework\Attributes\Small;
10use PHPUnit\Framework\TestCase;
11use Requirements\Input\Fields;
12use Requirements\Input\InvalidInputException;
13use stdClass;
14
15#[CoversClass(Fields::class)]
16#[Small]
17final class FieldsTest extends TestCase
18{
19 public function testMappingReturnsTheMapping(): void
20 {
21 self::assertSame(['a' => 1, 'b' => [2]], Fields::mapping(['a' => 1, 'b' => [2]], 'source'));
22 }
23
24 public function testMappingAcceptsAnEmptyArray(): void
25 {
26 self::assertSame([], Fields::mapping([], 'metadata'));
27 }
28
29 #[DataProvider('providerMappingRejectsNonArrays')]
30 public function testMappingRejectsNonArrays(mixed $value): void
31 {
32 $this->expectException(InvalidInputException::class);
33 $this->expectExceptionMessage('source must be a mapping.');
34 Fields::mapping($value, 'source');
35 }
36
37 /**
38 * @return array<string, array{mixed}>
39 */
40 public static function providerMappingRejectsNonArrays(): array
41 {
42 return [
43 'null' => [null],
44 'string' => ['text'],
45 'integer' => [1],
46 'object' => [new stdClass()],
47 ];
48 }
49
50 #[DataProvider('providerMappingRejectsIntegerKeys')]
51 public function testMappingRejectsIntegerKeys(mixed $value): void
52 {
53 $this->expectException(InvalidInputException::class);
54 $this->expectExceptionMessage('item must have string keys.');
55 Fields::mapping($value, 'item');
56 }
57
58 /**
59 * @return array<string, array{mixed}>
60 */
61 public static function providerMappingRejectsIntegerKeys(): array
62 {
63 return [
64 'list' => [['a', 'b']],
65 'integer key after string keys' => [['a' => 1, 'b' => 2, 5 => 3]],
66 ];
67 }
68
69 public function testKeysAcceptsAllowedKeys(): void
70 {
71 Fields::keys(['selector' => 'p', 'quote' => 'q'], ['selector', 'quote', 'other'], 'evidence');
72 $this->addToAssertionCount(1);
73 }
74
75 public function testKeysAcceptsAnEmptyMapping(): void
76 {
77 Fields::keys([], [], 'evidence');
78 $this->addToAssertionCount(1);
79 }
80
81 public function testKeysRejectsAnUnknownKeyAfterKnownOnes(): void
82 {
83 $this->expectException(InvalidInputException::class);
84 $this->expectExceptionMessage("evidence: unknown field 'extra'.");
85 Fields::keys(['selector' => 'p', 'extra' => 'x'], ['selector', 'quote'], 'evidence');
86 }
87
88 public function testTextReturnsTheValue(): void
89 {
90 self::assertSame(' spaced ', Fields::text(['id' => ' spaced '], 'id'));
91 }
92
93 public function testTextPrefersTheValueToTheDefault(): void
94 {
95 self::assertSame('requirement', Fields::text(['kind' => 'requirement'], 'kind', 'specification'));
96 }
97
98 public function testTextUsesTheDefaultWhenAbsent(): void
99 {
100 self::assertSame('specification', Fields::text([], 'kind', 'specification'));
101 }
102
103 public function testTextUsesTheDefaultWhenNull(): void
104 {
105 self::assertSame('.', Fields::text(['cwd' => null], 'cwd', '.'));
106 }
107
108 /**
109 * @param array<string, mixed> $data
110 */
111 #[DataProvider('providerTextRejectsMissingOrBlankValues')]
112 public function testTextRejectsMissingOrBlankValues(array $data, ?string $default): void
113 {
114 $this->expectException(InvalidInputException::class);
115 $this->expectExceptionMessage('id must be a nonempty string.');
116 Fields::text($data, 'id', $default);
117 }
118
119 /**
120 * @return array<string, array{array<string, mixed>, ?string}>
121 */
122 public static function providerTextRejectsMissingOrBlankValues(): array
123 {
124 return [
125 'missing' => [[], null],
126 'empty' => [['id' => ''], null],
127 'blank' => [['id' => " \t\n"], null],
128 'integer' => [['id' => 1], null],
129 'list' => [['id' => ['a']], null],
130 'blank default' => [[], ' '],
131 'blank value over default' => [['id' => ' '], 'default'],
132 ];
133 }
134
135 public function testSequenceReturnsTheList(): void
136 {
137 self::assertSame([1, 'a', null], Fields::sequence([1, 'a', null], 'tests'));
138 }
139
140 public function testSequenceAcceptsAnEmptyList(): void
141 {
142 self::assertSame([], Fields::sequence([], 'tests'));
143 }
144
145 #[DataProvider('providerSequenceRejectsNonLists')]
146 public function testSequenceRejectsNonLists(mixed $value): void
147 {
148 $this->expectException(InvalidInputException::class);
149 $this->expectExceptionMessage('tests must be a list.');
150 Fields::sequence($value, 'tests');
151 }
152
153 /**
154 * @return array<string, array{mixed}>
155 */
156 public static function providerSequenceRejectsNonLists(): array
157 {
158 return [
159 'string' => ['a'],
160 'null' => [null],
161 'mapping' => [['a' => 1]],
162 'sparse' => [[1 => 'a']],
163 ];
164 }
165
166 public function testStringsReturnsTheStrings(): void
167 {
168 self::assertSame(['a', ' b '], Fields::strings(['a', ' b '], 'labels'));
169 }
170
171 public function testStringsKeepsDuplicatesWhenNotUnique(): void
172 {
173 self::assertSame(['php', 'php'], Fields::strings(['php', 'php'], 'runner.command', false));
174 }
175
176 public function testStringsRejectsDuplicatesByDefault(): void
177 {
178 $this->expectException(InvalidInputException::class);
179 $this->expectExceptionMessage('labels contains duplicates.');
180 Fields::strings(['a', 'b', 'a'], 'labels');
181 }
182
183 public function testStringsRejectsANonList(): void
184 {
185 $this->expectException(InvalidInputException::class);
186 $this->expectExceptionMessage('labels must be a list.');
187 Fields::strings('a', 'labels');
188 }
189
190 #[DataProvider('providerStringsRejectsNonStringEntries')]
191 public function testStringsRejectsNonStringEntries(mixed $value): void
192 {
193 $this->expectException(InvalidInputException::class);
194 $this->expectExceptionMessage('labels must contain nonempty strings.');
195 Fields::strings($value, 'labels', false);
196 }
197
198 /**
199 * @return array<string, array{mixed}>
200 */
201 public static function providerStringsRejectsNonStringEntries(): array
202 {
203 return [
204 'integer' => [['a', 1]],
205 'empty' => [['a', '']],
206 'blank' => [['a', ' ']],
207 'null' => [[null]],
208 ];
209 }
210
211 #[DataProvider('providerPercentageAcceptsNumbersInRange')]
212 public function testPercentageAcceptsNumbersInRange(mixed $value, float $expected): void
213 {
214 self::assertSame($expected, Fields::percentage($value, 'coverage.minimum'));
215 }
216
217 /**
218 * @return array<string, array{mixed, float}>
219 */
220 public static function providerPercentageAcceptsNumbersInRange(): array
221 {
222 return [
223 'zero' => [0, 0.0],
224 'integer' => [50, 50.0],
225 'float' => [12.5, 12.5],
226 'hundred' => [100, 100.0],
227 'hundred float' => [100.0, 100.0],
228 ];
229 }
230
231 #[DataProvider('providerPercentageRejectsOtherValues')]
232 public function testPercentageRejectsOtherValues(mixed $value): void
233 {
234 $this->expectException(InvalidInputException::class);
235 $this->expectExceptionMessage('coverage.minimum must be a number from 0 to 100.');
236 Fields::percentage($value, 'coverage.minimum');
237 }
238
239 /**
240 * @return array<string, array{mixed}>
241 */
242 public static function providerPercentageRejectsOtherValues(): array
243 {
244 return [
245 'numeric string' => ['50'],
246 'null' => [null],
247 'boolean' => [true],
248 'negative' => [-1],
249 'slightly negative' => [-0.001],
250 'above' => [101],
251 'slightly above' => [100.001],
252 'infinite' => [INF],
253 'not a number' => [NAN],
254 ];
255 }
256}
257