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