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