packages/requirements/tests/Unit/Console/FormatterTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Console;
6
7use JsonException;
8use PHPUnit\Framework\Attributes\CoversClass;
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\Markdown\Badges;
21use Requirements\Config\Markdown\CardReader;
22use Requirements\Config\Markdown\FieldReader;
23use Requirements\Config\Markdown\FieldSections;
24use Requirements\Config\Markdown\Frontmatter;
25use Requirements\Config\Markdown\Nodes;
26use Requirements\Config\Markdown\Profile\AllowedBlocks;
27use Requirements\Config\Markdown\Profile\BlockKind;
28use Requirements\Config\Markdown\Profile\DocumentSchema;
29use Requirements\Config\Markdown\Profile\Occurrences;
30use Requirements\Config\Markdown\Profile\SectionBlocks;
31use Requirements\Config\Markdown\Profile\TextConstraint;
32use Requirements\Config\Markdown\QuotationBlocks;
33use Requirements\Config\Markdown\Render\CardWriter;
34use Requirements\Config\Markdown\Render\FieldWriter;
35use Requirements\Config\Markdown\Render\Record;
36use Requirements\Config\Markdown\StaticBadge;
37use Requirements\Config\MarkdownDocument;
38use Requirements\Config\SchemaValidator;
39use Requirements\Console\Formatter;
40use Requirements\Ears\ConditionOrder;
41use Requirements\Ears\LiteralMask;
42use Requirements\Ears\SystemResponse;
43use Requirements\Ears\Validator;
44use Requirements\Ears\Wording;
45use Requirements\Input\Fields;
46use Requirements\Input\InvalidInputException;
47use Requirements\Model\Excerpt;
48use Requirements\Model\Item;
49use Requirements\Model\ItemValidator;
50use Requirements\Model\Project;
51use Requirements\Model\Source;
52use Requirements\Source\DomSource;
53use Requirements\Source\JsonSource;
54use Requirements\Source\Registry as SourceRegistry;
55use Requirements\Source\ResourceLoader;
56use Requirements\Source\TextSource;
57use Requirements\Test\Registry as TestRegistry;
58use Tests\Fake\ProjectDirectory;
59
60#[CoversClass(Formatter::class)]
61#[UsesClass(CoverageThresholds::class)]
62#[UsesClass(DefinitionReader::class)]
63#[UsesClass(Definitions::class)]
64#[UsesClass(DocumentReader::class)]
65#[UsesClass(ExtensionClasses::class)]
66#[UsesClass(JsonSchemaFile::class)]
67#[UsesClass(LinkValidator::class)]
68#[UsesClass(Loader::class)]
69#[UsesClass(MarkdownDocument::class)]
70#[UsesClass(SchemaValidator::class)]
71#[UsesClass(ConditionOrder::class)]
72#[UsesClass(LiteralMask::class)]
73#[UsesClass(SystemResponse::class)]
74#[UsesClass(Validator::class)]
75#[UsesClass(Wording::class)]
76#[UsesClass(Fields::class)]
77#[UsesClass(Excerpt::class)]
78#[UsesClass(Item::class)]
79#[UsesClass(ItemValidator::class)]
80#[UsesClass(Project::class)]
81#[UsesClass(Source::class)]
82#[UsesClass(DomSource::class)]
83#[UsesClass(JsonSource::class)]
84#[UsesClass(SourceRegistry::class)]
85#[UsesClass(ResourceLoader::class)]
86#[UsesClass(TextSource::class)]
87#[UsesClass(TestRegistry::class)]
88#[UsesClass(Badges::class)]
89#[UsesClass(CardReader::class)]
90#[UsesClass(FieldReader::class)]
91#[UsesClass(FieldSections::class)]
92#[UsesClass(Frontmatter::class)]
93#[UsesClass(Nodes::class)]
94#[UsesClass(AllowedBlocks::class)]
95#[UsesClass(BlockKind::class)]
96#[UsesClass(DocumentSchema::class)]
97#[UsesClass(Occurrences::class)]
98#[UsesClass(SectionBlocks::class)]
99#[UsesClass(TextConstraint::class)]
100#[UsesClass(QuotationBlocks::class)]
101#[UsesClass(CardWriter::class)]
102#[UsesClass(FieldWriter::class)]
103#[UsesClass(Record::class)]
104#[UsesClass(StaticBadge::class)]
105#[Small]
106final class FormatterTest extends TestCase
107{
108 /**
109 * @throws JsonException
110 */
111 public function testFormatLeavesCanonicalDocumentsUnchanged(): void
112 {
113 $project = new ProjectDirectory();
114 $configuration = $project->read('requirements.yaml');
115 $definition = $project->read('definition.yaml');
116 self::assertSame([], (new Formatter())->format([$project->path('requirements.yaml'), $project->path('definition.yaml')], false));
117 self::assertSame($configuration, $project->read('requirements.yaml'));
118 self::assertSame($definition, $project->read('definition.yaml'));
119 }
120
121 /**
122 * @throws JsonException
123 */
124 public function testFormatRewritesDocumentsInTheirCanonicalForm(): void
125 {
126 $project = new ProjectDirectory();
127 $definition = $project->read('definition.yaml');
128 $project->put('requirements.yaml', "{version: 1, definitions: [definition.yaml], coverage: {minimum: 0}}\n");
129 $project->put('definition.yaml', "version: 1\nsource: {id: manual, uri: source.html, format: html, selector: main p}\nitems:\n - {id: SPEC-001, statement: 'When a name is read, the parser shall require a leading letter.', evidence: [{selector: '#a', quote: 'Names shall start with a letter.'}]}\n");
130 $files = [$project->path('requirements.yaml'), $project->path('definition.yaml')];
131 self::assertSame($files, (new Formatter())->format($files, false));
132 self::assertSame("version: 1\ndefinitions:\n - definition.yaml\ncoverage:\n minimum: 0\n", $project->read('requirements.yaml'));
133 self::assertSame($definition, $project->read('definition.yaml'));
134 self::assertSame([], (new Formatter())->format($files, false));
135 }
136
137 /**
138 * @throws JsonException
139 */
140 public function testFormatOnlyReportsChangesWhenChecking(): void
141 {
142 $project = new ProjectDirectory();
143 $unformatted = "version: 1\nsource: {id: manual, uri: source.html, format: html, selector: main p}\nitems:\n - {id: SPEC-001, statement: 'When a name is read, the parser shall require a leading letter.', evidence: [{selector: '#a', quote: 'Names shall start with a letter.'}]}\n";
144 $project->put('definition.yaml', $unformatted);
145 self::assertSame([$project->path('definition.yaml')], (new Formatter())->format([$project->path('requirements.yaml'), $project->path('definition.yaml')], true));
146 self::assertSame($unformatted, $project->read('definition.yaml'));
147 }
148
149 /**
150 * @throws JsonException
151 */
152 public function testFormatRendersMarkdownDefinitionsAsMarkdown(): void
153 {
154 $project = new ProjectDirectory();
155 $project->write('requirements.yaml', ['version' => 1, 'definitions' => ['definition.md'], 'markdown' => ['experimental' => true]]);
156 $project->put('definition.md', "---\nversion: 1\nsource: null\n---\n\n# ORIGINAL-001\n\nThe converter shall uppercase letters.\n\n**origin**\n\noriginal\n\n**reason**\n\nDemonstrate the runner contract.\n\n**labels**\n\n- grammar\n");
157 $files = [$project->path('requirements.yaml'), $project->path('definition.md')];
158 self::assertSame([$project->path('definition.md')], (new Formatter())->format($files, false, ['experimental' => true]));
159 self::assertSame("---\nversion: 1\nsource: null\n---\n\n# ORIGINAL-001\n\n\n\n\nThe converter shall uppercase letters.\n\n**rationale**\n\nDemonstrate the runner contract.\n", $project->read('definition.md'));
160 self::assertSame([], (new Formatter())->format($files, true, ['experimental' => true]));
161 }
162
163 /**
164 * @throws JsonException
165 */
166 public function testFormatRequiresTheMarkdownOptIn(): void
167 {
168 $project = new ProjectDirectory();
169 $project->put('definition.md', "---\nversion: 1\nsource: null\n---\n\n# ORIGINAL-001\n\nThe converter shall uppercase letters.\n");
170 $this->expectException(InvalidInputException::class);
171 $this->expectExceptionMessage('Markdown definitions require markdown.experimental: true.');
172 (new Formatter())->format([$project->path('requirements.yaml'), $project->path('definition.md')], true);
173 }
174
175 /**
176 * @throws JsonException
177 */
178 public function testFormatReadsTheFirstFileAsTheConfiguration(): void
179 {
180 $project = new ProjectDirectory();
181 $this->expectException(InvalidInputException::class);
182 (new Formatter())->format([$project->path('definition.yaml')], true);
183 }
184
185 /**
186 * @throws JsonException
187 */
188 public function testFormatRejectsAnInvalidDefinition(): void
189 {
190 $project = new ProjectDirectory();
191 $project->put('definition.yaml', "version: 1\nsource: null\nitems: []\nlables: []\n");
192 $this->expectException(InvalidInputException::class);
193 $this->expectExceptionMessage('lables');
194 (new Formatter())->format([$project->path('requirements.yaml'), $project->path('definition.yaml')], true);
195 }
196
197 /**
198 * @throws JsonException
199 */
200 public function testFormatKeepsBundledSchemaDeclarationsAndEmptyMaps(): void
201 {
202 $project = new ProjectDirectory();
203 $loader = new Loader();
204 $data = $loader->document($project->path('definition.yaml'));
205 $data = ['$schema' => SchemaValidator::BASE . 'definition.schema.json', ...$data];
206 $project->write('definition.yaml', $data);
207 $project->put('definition.yaml', $project->read('definition.yaml') . " metadata: {}\n");
208 $project->write('requirements.yaml', ['$schema' => SchemaValidator::BASE . 'config.schema.json', 'version' => 1, 'definitions' => ['definition.yaml']]);
209 $loaded = $loader->load($project->path('requirements.yaml'));
210 (new Formatter())->format($loaded->files, false);
211 self::assertCount(1, $loader->load($project->path('requirements.yaml'))->items);
212 $formatted = file_get_contents($project->path('definition.yaml'));
213 self::assertIsString($formatted);
214 self::assertStringContainsString('metadata: { }', $formatted);
215 }
216}
217