packages/requirements/tests/Unit/Console/OptionsTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Console;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\DataProvider;
9use PHPUnit\Framework\Attributes\Small;
10use PHPUnit\Framework\Attributes\UsesClass;
11use PHPUnit\Framework\TestCase;
12use Requirements\Console\Options;
13use Requirements\Input\Fields;
14use Requirements\Input\InvalidInputException;
15use Requirements\Model\Item;
16use Requirements\Model\Source;
17use Symfony\Component\Console\Input\ArrayInput;
18use Symfony\Component\Console\Input\InputDefinition;
19use Symfony\Component\Console\Input\InputOption;
20
21#[CoversClass(Options::class)]
22#[UsesClass(Fields::class)]
23#[UsesClass(Item::class)]
24#[UsesClass(Source::class)]
25#[Small]
26final class OptionsTest extends TestCase
27{
28 public function testFromInputReadsGlobalAndCommandOptions(): void
29 {
30 $definition = new InputDefinition([
31 new InputOption('config', null, InputOption::VALUE_REQUIRED, '', 'requirements.yaml'),
32 new InputOption('json', null, InputOption::VALUE_NONE),
33 new InputOption('live', null, InputOption::VALUE_NONE),
34 new InputOption('snapshot', null, InputOption::VALUE_REQUIRED),
35 new InputOption('write-snapshot', null, InputOption::VALUE_REQUIRED),
36 new InputOption('min-coverage', null, InputOption::VALUE_REQUIRED),
37 new InputOption('min-diff-coverage', null, InputOption::VALUE_REQUIRED),
38 new InputOption('allow-removed', null, InputOption::VALUE_NONE),
39 ]);
40 $options = Options::fromInput('coverage', new ArrayInput(['--json' => true, '--snapshot' => 'base.json', '--min-coverage' => '80'], $definition));
41 self::assertSame('coverage', $options->command);
42 self::assertSame(['config' => 'requirements.yaml', 'json' => true, 'live' => false, 'snapshot' => 'base.json', 'min-coverage' => '80', 'allow-removed' => false], $options->values);
43 }
44
45 public function testFromInputReadsOnlyTheGlobalOptionsOfACommandWithoutOptions(): void
46 {
47 $definition = new InputDefinition([
48 new InputOption('config', null, InputOption::VALUE_REQUIRED, '', 'requirements.yaml'),
49 new InputOption('json', null, InputOption::VALUE_NONE),
50 ]);
51 $options = Options::fromInput('lint', new ArrayInput(['--config' => 'other.yaml'], $definition));
52 self::assertSame('lint', $options->command);
53 self::assertSame(['config' => 'other.yaml', 'json' => false], $options->values);
54 }
55
56 public function testFromInputSkipsValuesThatAreNeitherTextNorFlags(): void
57 {
58 $definition = new InputDefinition([
59 new InputOption('config', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY),
60 new InputOption('json', null, InputOption::VALUE_NONE),
61 new InputOption('check', null, InputOption::VALUE_NONE),
62 ]);
63 $options = Options::fromInput('format', new ArrayInput(['--config' => ['a.yaml', 'b.yaml'], '--check' => true], $definition));
64 self::assertSame(['json' => false, 'check' => true], $options->values);
65 }
66
67 /**
68 * @param list<array{string, bool, string}> $expected
69 */
70 #[DataProvider('providerDefinitions')]
71 public function testDefinitionsDeclaresTheOptionsOfEachCommand(string $command, array $expected): void
72 {
73 self::assertSame($expected, Options::definitions($command));
74 }
75
76 /**
77 * @return array<string, array{string, list<array{string, bool, string}>}>
78 */
79 public static function providerDefinitions(): array
80 {
81 return [
82 'spec' => ['spec', [
83 ['no-test', true, 'Display selected records and linked test counts without running tests.'],
84 ['strict', true, 'Fail when no records match or a selected supported specification has no linked tests.'],
85 ['all', true, 'Include manual tests linked to selected supported specifications.'],
86 ['id', false, 'Select an exact item ID.'],
87 ['label', false, 'Select items carrying this label.'],
88 ['category', false, 'Select an exact category.'],
89 ['source', false, 'Select a definition source ID.'],
90 ['status', false, 'Select supported or unsupported items.'],
91 ['kind', false, 'Select specification or requirement items.'],
92 ['origin', false, 'Select sourced, original or undocumented items.'],
93 ['without-source', true, 'Select independent original or undocumented items.'],
94 ]],
95 'check' => ['check', [['live', true, 'Fetch current source URIs instead of pinned local snapshots.']]],
96 'coverage' => ['coverage', [
97 ['live', true, 'Fetch current source URIs instead of pinned local snapshots.'],
98 ['snapshot', false, 'Compare with the coverage snapshot of a trusted base revision to find new, changed and removed units.'],
99 ['write-snapshot', false, 'Write a coverage snapshot (every unit in scope with its fingerprint, no source text) to this JSON file.'],
100 ['min-coverage', false, 'Override the overall coverage threshold (0–100).'],
101 ['min-diff-coverage', false, 'Override the coverage threshold for units that are new or changed since the snapshot (0–100).'],
102 ['allow-removed', true, 'Accept units that the snapshot lists but the current scope no longer contains.'],
103 ]],
104 'format' => ['format', [['check', true, 'Report formatting differences without writing files.']]],
105 'lint' => ['lint', []],
106 'unknown' => ['unknown', []],
107 ];
108 }
109
110 /**
111 * @param array<string, string|bool> $values
112 */
113 #[DataProvider('providerText')]
114 public function testTextReturnsTheValueOrTheDefault(array $values, ?string $default, ?string $expected): void
115 {
116 self::assertSame($expected, (new Options('spec', $values))->text('id', $default));
117 }
118
119 /**
120 * @return array<string, array{array<string, string|bool>, string|null, string|null}>
121 */
122 public static function providerText(): array
123 {
124 return [
125 'given' => [['id' => 'SPEC-001'], 'DEFAULT', 'SPEC-001'],
126 'given without default' => [['id' => 'SPEC-001'], null, 'SPEC-001'],
127 'empty text' => [['id' => ''], 'DEFAULT', ''],
128 'missing with default' => [[], 'DEFAULT', 'DEFAULT'],
129 'missing without default' => [[], null, null],
130 'flag with default' => [['id' => true], 'DEFAULT', 'DEFAULT'],
131 'flag without default' => [['id' => true], null, null],
132 ];
133 }
134
135 /**
136 * @param array<string, string|bool> $values
137 */
138 #[DataProvider('providerFlag')]
139 public function testFlagTellsWhetherTheFlagIsSet(array $values, bool $expected): void
140 {
141 self::assertSame($expected, (new Options('spec', $values))->flag('no-test'));
142 }
143
144 /**
145 * @return array<string, array{array<string, string|bool>, bool}>
146 */
147 public static function providerFlag(): array
148 {
149 return [
150 'set' => [['no-test' => true], true],
151 'unset' => [['no-test' => false], false],
152 'missing' => [[], false],
153 'text' => [['no-test' => '1'], false],
154 ];
155 }
156
157 /**
158 * @param array<string, string|bool> $values
159 */
160 #[DataProvider('providerPercentage')]
161 public function testPercentageReturnsTheNumber(array $values, ?float $expected): void
162 {
163 self::assertSame($expected, (new Options('coverage', $values))->percentage('min-coverage'));
164 }
165
166 /**
167 * @return array<string, array{array<string, string|bool>, float|null}>
168 */
169 public static function providerPercentage(): array
170 {
171 return [
172 'missing' => [[], null],
173 'flag' => [['min-coverage' => true], null],
174 'integer' => [['min-coverage' => '80'], 80.0],
175 'decimal' => [['min-coverage' => '12.5'], 12.5],
176 'zero' => [['min-coverage' => '0'], 0.0],
177 'hundred' => [['min-coverage' => '100'], 100.0],
178 ];
179 }
180
181 #[DataProvider('providerPercentageRejected')]
182 public function testPercentageRejectsInvalidValues(string $value, string $message): void
183 {
184 $this->expectException(InvalidInputException::class);
185 $this->expectExceptionMessage($message);
186 (new Options('coverage', ['min-diff-coverage' => $value]))->percentage('min-diff-coverage');
187 }
188
189 /**
190 * @return array<string, array{string, string}>
191 */
192 public static function providerPercentageRejected(): array
193 {
194 return [
195 'not a number' => ['not-a-number', '--min-diff-coverage requires a percentage.'],
196 'empty' => ['', '--min-diff-coverage requires a percentage.'],
197 'above' => ['100.5', 'min-diff-coverage must be a number from 0 to 100.'],
198 'below' => ['-1', 'min-diff-coverage must be a number from 0 to 100.'],
199 ];
200 }
201
202 /**
203 * @param array<string, string|bool> $values
204 */
205 #[DataProvider('providerMatchesSourcedItem')]
206 public function testMatchesFiltersASourcedItem(array $values, bool $expected): void
207 {
208 $item = new Item('SPEC-001', 'specification', 'The reader shall preserve positions.', 'supported', new Source('manual', 'source.html', 'html', 'main p'), [], [], [], [], ['diagnostics', 'reader'], 'parsing', 'sourced', '', 'definition.yaml', []);
209 self::assertSame($expected, (new Options('spec', $values))->matches($item));
210 }
211
212 /**
213 * @return array<string, array{array<string, string|bool>, bool}>
214 */
215 public static function providerMatchesSourcedItem(): array
216 {
217 return [
218 'no filter' => [[], true],
219 'flag false' => [['no-test' => true, 'without-source' => false], true],
220 'id' => [['id' => 'SPEC-001'], true],
221 'other id' => [['id' => 'SPEC-002'], false],
222 'id prefix' => [['id' => 'SPEC'], false],
223 'category' => [['category' => 'parsing'], true],
224 'other category' => [['category' => 'reader'], false],
225 'status' => [['status' => 'supported'], true],
226 'other status' => [['status' => 'unsupported'], false],
227 'kind' => [['kind' => 'specification'], true],
228 'other kind' => [['kind' => 'requirement'], false],
229 'origin' => [['origin' => 'sourced'], true],
230 'other origin' => [['origin' => 'original'], false],
231 'source' => [['source' => 'manual'], true],
232 'other source' => [['source' => 'missing'], false],
233 'first label' => [['label' => 'diagnostics'], true],
234 'second label' => [['label' => 'reader'], true],
235 'other label' => [['label' => 'strictness'], false],
236 'every filter' => [['id' => 'SPEC-001', 'category' => 'parsing', 'status' => 'supported', 'kind' => 'specification', 'origin' => 'sourced', 'source' => 'manual', 'label' => 'reader'], true],
237 'every filter but the last' => [['id' => 'SPEC-001', 'category' => 'parsing', 'status' => 'supported', 'kind' => 'specification', 'origin' => 'sourced', 'source' => 'other'], false],
238 'every filter but the label' => [['id' => 'SPEC-001', 'category' => 'parsing', 'label' => 'other'], false],
239 'without source' => [['without-source' => true], false],
240 ];
241 }
242
243 /**
244 * @param array<string, string|bool> $values
245 */
246 #[DataProvider('providerMatchesIndependentItem')]
247 public function testMatchesFiltersAnIndependentItem(array $values, bool $expected): void
248 {
249 $item = new Item('ORIGINAL-001', 'specification', 'The parser shall reject truncated input.', 'supported', null, [], [], [], [], ['strictness'], '', 'original', 'Avoid silent data loss.', 'definition.yaml', []);
250 self::assertSame($expected, (new Options('spec', $values))->matches($item));
251 }
252
253 /**
254 * @return array<string, array{array<string, string|bool>, bool}>
255 */
256 public static function providerMatchesIndependentItem(): array
257 {
258 return [
259 'without source' => [['without-source' => true], true],
260 'without source and label' => [['without-source' => true, 'label' => 'strictness'], true],
261 'without source and other label' => [['without-source' => true, 'label' => 'grammar'], false],
262 'any source' => [['source' => 'manual'], false],
263 'empty category' => [['category' => ''], true],
264 'origin' => [['origin' => 'original'], true],
265 ];
266 }
267}
268