packages/requirements/tests/Unit/Console/CommandLineTest.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\Config\CoverageThresholds;
13use Requirements\Config\DefinitionReader;
14use Requirements\Config\Definitions;
15use Requirements\Config\DocumentReader;
16use Requirements\Config\ExtensionClasses;
17use Requirements\Config\JsonSchemaFile;
18use Requirements\Config\LinkValidator;
19use Requirements\Config\Loader;
20use Requirements\Config\MarkdownDocument;
21use Requirements\Config\SchemaValidator;
22use Requirements\Console\CommandHandler;
23use Requirements\Console\CommandLine;
24use Requirements\Console\Executor;
25use Requirements\Console\Options;
26use Requirements\Console\SpecificationReport;
27use Requirements\Ears\ConditionOrder;
28use Requirements\Ears\LiteralMask;
29use Requirements\Ears\SystemResponse;
30use Requirements\Ears\Validator;
31use Requirements\Ears\Wording;
32use Requirements\Input\Fields;
33use Requirements\Model\Excerpt;
34use Requirements\Model\Item;
35use Requirements\Model\ItemValidator;
36use Requirements\Model\Project;
37use Requirements\Model\Source;
38use Requirements\Source\DomSource;
39use Requirements\Source\JsonSource;
40use Requirements\Source\Registry as SourceRegistry;
41use Requirements\Source\ResourceLoader;
42use Requirements\Source\TextSource;
43use Requirements\Test\Registry as TestRegistry;
44use Symfony\Component\Console\Exception\ExceptionInterface;
45use Symfony\Component\Console\Input\ArrayInput;
46use Symfony\Component\Console\Output\BufferedOutput;
47use Tests\Fake\ProjectDirectory;
48
49#[CoversClass(CommandLine::class)]
50#[UsesClass(CommandHandler::class)]
51#[UsesClass(Options::class)]
52#[UsesClass(CoverageThresholds::class)]
53#[UsesClass(DefinitionReader::class)]
54#[UsesClass(Definitions::class)]
55#[UsesClass(DocumentReader::class)]
56#[UsesClass(ExtensionClasses::class)]
57#[UsesClass(JsonSchemaFile::class)]
58#[UsesClass(LinkValidator::class)]
59#[UsesClass(Loader::class)]
60#[UsesClass(MarkdownDocument::class)]
61#[UsesClass(SchemaValidator::class)]
62#[UsesClass(Executor::class)]
63#[UsesClass(SpecificationReport::class)]
64#[UsesClass(ConditionOrder::class)]
65#[UsesClass(LiteralMask::class)]
66#[UsesClass(SystemResponse::class)]
67#[UsesClass(Validator::class)]
68#[UsesClass(Wording::class)]
69#[UsesClass(Fields::class)]
70#[UsesClass(Excerpt::class)]
71#[UsesClass(Item::class)]
72#[UsesClass(ItemValidator::class)]
73#[UsesClass(Project::class)]
74#[UsesClass(Source::class)]
75#[UsesClass(DomSource::class)]
76#[UsesClass(JsonSource::class)]
77#[UsesClass(SourceRegistry::class)]
78#[UsesClass(ResourceLoader::class)]
79#[UsesClass(TextSource::class)]
80#[UsesClass(TestRegistry::class)]
81#[Small]
82final class CommandLineTest extends TestCase
83{
84    public function testCreateNamesTheApplicationAndLeavesExitAndErrorsToTheCaller(): void
85    {
86        $console = (new CommandLine())->create();
87        self::assertSame('Requirements', $console->getName());
88        self::assertFalse($console->isAutoExitEnabled());
89        self::assertFalse($console->areExceptionsCaught());
90    }
91
92    #[DataProvider('providerCreateCommands')]
93    public function testCreateRegistersEveryCommand(string $name, string $description): void
94    {
95        $command = (new CommandLine())->create()->find($name);
96        self::assertSame($name, $command->getName());
97        self::assertSame($description, $command->getDescription());
98    }
99
100    /**
101     * @return array<string, array{string, string}>
102     */
103    public static function providerCreateCommands(): array
104    {
105        return [
106            'check' => ['check', 'Verify quotations against the declared source scopes.'],
107            'coverage' => ['coverage', 'Show source coverage and enforce total and differential gates.'],
108            'spec' => ['spec', 'Browse specifications and requirements, and verify linked tests unless --no-test is set.'],
109            'lint' => ['lint', 'Validate schemas, EARS syntax and cross-file traceability.'],
110            'format' => ['format', 'Format YAML and Markdown definition documents.'],
111        ];
112    }
113
114    public function testCreateDeclaresTheGlobalOptions(): void
115    {
116        $definition = (new CommandLine())->create()->getDefinition();
117        $help = $definition->getOption('help');
118        self::assertSame('h', $help->getShortcut());
119        self::assertFalse($help->acceptValue());
120        self::assertSame('Display command help, or the application overview when no command is given.', $help->getDescription());
121        $config = $definition->getOption('config');
122        self::assertSame('c', $config->getShortcut());
123        self::assertTrue($config->isValueRequired());
124        self::assertSame('requirements.yaml', $config->getDefault());
125        self::assertSame('Path to the YAML project configuration.', $config->getDescription());
126        $json = $definition->getOption('json');
127        self::assertNull($json->getShortcut());
128        self::assertFalse($json->acceptValue());
129        self::assertSame('Write a JSON report to stdout instead of terminal tables.', $json->getDescription());
130    }
131
132    /**
133     * @throws ExceptionInterface
134     */
135    public function testCreateRunsACommandThroughItsHandler(): void
136    {
137        $project = new ProjectDirectory();
138        $output = new BufferedOutput();
139        self::assertSame(0, (new CommandLine())->create()->find('lint')->run(new ArrayInput(['--config' => $project->path('requirements.yaml'), '--json' => true]), $output));
140        self::assertSame("{\n    \"passed\": true,\n    \"message\": \"1 items validated.\"\n}\n", $output->fetch());
141    }
142
143    public function testCommandDeclaresTheNameDescriptionAndHelp(): void
144    {
145        $command = (new CommandLine())->command('check', 'Verify quotations.');
146        self::assertSame('check', $command->getName());
147        self::assertSame('Verify quotations.', $command->getDescription());
148        self::assertSame("Run <info>requirements check --config requirements.yaml</info>.\n\nExit codes: 0 success, 1 failed check/gate/spec/format, 2 invalid configuration or execution error.", $command->getHelp());
149    }
150
151    /**
152     * @param list<string> $expected
153     */
154    #[DataProvider('providerCommandOptions')]
155    public function testCommandDeclaresTheOptionsOfTheCommand(string $name, array $expected): void
156    {
157        self::assertSame($expected, array_keys((new CommandLine())->command($name, '')->getDefinition()->getOptions()));
158    }
159
160    /**
161     * @return array<string, array{string, list<string>}>
162     */
163    public static function providerCommandOptions(): array
164    {
165        return [
166            'spec' => ['spec', ['no-test', 'strict', 'all', 'id', 'label', 'category', 'source', 'status', 'kind', 'origin', 'without-source']],
167            'check' => ['check', ['live']],
168            'coverage' => ['coverage', ['live', 'snapshot', 'write-snapshot', 'min-coverage', 'min-diff-coverage', 'allow-removed']],
169            'format' => ['format', ['check']],
170            'lint' => ['lint', []],
171        ];
172    }
173
174    public function testCommandDeclaresFlagsWithoutValuesAndOtherOptionsWithRequiredValues(): void
175    {
176        $definition = (new CommandLine())->command('spec', '')->getDefinition();
177        $flag = $definition->getOption('no-test');
178        self::assertFalse($flag->acceptValue());
179        self::assertNull($flag->getShortcut());
180        self::assertSame('Display selected records and linked test counts without running tests.', $flag->getDescription());
181        $value = $definition->getOption('id');
182        self::assertTrue($value->isValueRequired());
183        self::assertNull($value->getShortcut());
184        self::assertSame('Select an exact item ID.', $value->getDescription());
185        self::assertFalse($definition->getOption('without-source')->acceptValue());
186        self::assertTrue($definition->getOption('label')->isValueRequired());
187    }
188}
189