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