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![original](https://img.shields.io/badge/origin-original-blue)\n![grammar](https://img.shields.io/badge/label-grammar-blue)\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