packages/requirements/tests/Unit/Config/MarkdownDocumentTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Config;
6
7use JsonException;
8use League\CommonMark\CommonMarkConverter;
9use League\CommonMark\Exception\CommonMarkException;
10use PHPUnit\Framework\Attributes\CoversClass;
11use PHPUnit\Framework\Attributes\DataProvider;
12use PHPUnit\Framework\Attributes\Medium;
13use PHPUnit\Framework\Attributes\UsesClass;
14use PHPUnit\Framework\TestCase;
15use Requirements\Config\CoverageThresholds;
16use Requirements\Config\DefinitionReader;
17use Requirements\Config\Definitions;
18use Requirements\Config\DocumentReader;
19use Requirements\Config\ExtensionClasses;
20use Requirements\Config\JsonSchemaFile;
21use Requirements\Config\LinkValidator;
22use Requirements\Config\Loader;
23use Requirements\Config\Markdown\Badges;
24use Requirements\Config\Markdown\CardReader;
25use Requirements\Config\Markdown\Citation;
26use Requirements\Config\Markdown\FieldReader;
27use Requirements\Config\Markdown\FieldSections;
28use Requirements\Config\Markdown\Frontmatter;
29use Requirements\Config\Markdown\LocalPath;
30use Requirements\Config\Markdown\MetadataReader;
31use Requirements\Config\Markdown\Nodes;
32use Requirements\Config\Markdown\Profile\AllowedBlocks;
33use Requirements\Config\Markdown\Profile\BlockKind;
34use Requirements\Config\Markdown\Profile\DocumentSchema;
35use Requirements\Config\Markdown\Profile\Occurrences;
36use Requirements\Config\Markdown\Profile\SectionBlocks;
37use Requirements\Config\Markdown\Profile\TextConstraint;
38use Requirements\Config\Markdown\Quotation;
39use Requirements\Config\Markdown\QuotationBlocks;
40use Requirements\Config\Markdown\Reference;
41use Requirements\Config\Markdown\Render\CardWriter;
42use Requirements\Config\Markdown\Render\FieldWriter;
43use Requirements\Config\Markdown\Render\MetadataWriter;
44use Requirements\Config\Markdown\Render\Record;
45use Requirements\Config\Markdown\StaticBadge;
46use Requirements\Config\MarkdownDocument;
47use Requirements\Config\SchemaValidator;
48use Requirements\Console\Formatter;
49use Requirements\Ears\ConditionOrder;
50use Requirements\Ears\LiteralMask;
51use Requirements\Ears\SystemResponse;
52use Requirements\Ears\Validator;
53use Requirements\Ears\Wording;
54use Requirements\Input\Fields;
55use Requirements\Input\InvalidInputException;
56use Requirements\Model\Excerpt;
57use Requirements\Model\Item;
58use Requirements\Model\ItemValidator;
59use Requirements\Model\Project;
60use Requirements\Model\Source;
61use Requirements\Report\Analysis;
62use Requirements\Report\Analyzer;
63use Requirements\Report\Claims;
64use Requirements\Report\EvidenceMatcher;
65use Requirements\Report\SourceUnit;
66use Requirements\Report\UnitCollector;
67use Requirements\Source\DomSource;
68use Requirements\Source\JsonSource;
69use Requirements\Source\LocalFile;
70use Requirements\Source\Registry as SourceRegistry;
71use Requirements\Source\ResourceLoader;
72use Requirements\Source\ResourceLocation;
73use Requirements\Source\TextFragment;
74use Requirements\Source\TextSource;
75use Requirements\Source\Unit;
76use Requirements\Test\Registry as RunnerRegistry;
77use stdClass;
78use Symfony\Component\DomCrawler\Crawler;
79use Tests\Fake\GrammarCards;
80use Tests\Fake\ProjectDirectory;
81
82#[CoversClass(MarkdownDocument::class)]
83#[UsesClass(CoverageThresholds::class)]
84#[UsesClass(DefinitionReader::class)]
85#[UsesClass(Definitions::class)]
86#[UsesClass(DocumentReader::class)]
87#[UsesClass(ExtensionClasses::class)]
88#[UsesClass(JsonSchemaFile::class)]
89#[UsesClass(LinkValidator::class)]
90#[UsesClass(Loader::class)]
91#[UsesClass(Badges::class)]
92#[UsesClass(CardReader::class)]
93#[UsesClass(Citation::class)]
94#[UsesClass(FieldReader::class)]
95#[UsesClass(FieldSections::class)]
96#[UsesClass(Frontmatter::class)]
97#[UsesClass(LocalPath::class)]
98#[UsesClass(MetadataReader::class)]
99#[UsesClass(Nodes::class)]
100#[UsesClass(AllowedBlocks::class)]
101#[UsesClass(BlockKind::class)]
102#[UsesClass(DocumentSchema::class)]
103#[UsesClass(Occurrences::class)]
104#[UsesClass(SectionBlocks::class)]
105#[UsesClass(TextConstraint::class)]
106#[UsesClass(Quotation::class)]
107#[UsesClass(QuotationBlocks::class)]
108#[UsesClass(Reference::class)]
109#[UsesClass(CardWriter::class)]
110#[UsesClass(FieldWriter::class)]
111#[UsesClass(MetadataWriter::class)]
112#[UsesClass(Record::class)]
113#[UsesClass(StaticBadge::class)]
114#[UsesClass(SchemaValidator::class)]
115#[UsesClass(Formatter::class)]
116#[UsesClass(ConditionOrder::class)]
117#[UsesClass(LiteralMask::class)]
118#[UsesClass(SystemResponse::class)]
119#[UsesClass(Validator::class)]
120#[UsesClass(Wording::class)]
121#[UsesClass(Fields::class)]
122#[UsesClass(Excerpt::class)]
123#[UsesClass(Item::class)]
124#[UsesClass(ItemValidator::class)]
125#[UsesClass(Project::class)]
126#[UsesClass(Source::class)]
127#[UsesClass(Analysis::class)]
128#[UsesClass(Analyzer::class)]
129#[UsesClass(Claims::class)]
130#[UsesClass(EvidenceMatcher::class)]
131#[UsesClass(SourceUnit::class)]
132#[UsesClass(UnitCollector::class)]
133#[UsesClass(DomSource::class)]
134#[UsesClass(JsonSource::class)]
135#[UsesClass(LocalFile::class)]
136#[UsesClass(ResourceLoader::class)]
137#[UsesClass(ResourceLocation::class)]
138#[UsesClass(TextFragment::class)]
139#[UsesClass(TextSource::class)]
140#[UsesClass(Unit::class)]
141#[UsesClass(SourceRegistry::class)]
142#[UsesClass(RunnerRegistry::class)]
143#[Medium]
144final class MarkdownDocumentTest extends TestCase
145{
146    /**
147     * @throws JsonException
148     */
149    public function testReadSharesTheYamlModelAndRenderPreservesLinksAndBadges(): void
150    {
151        $project = new ProjectDirectory();
152        $yaml = (new Loader())->load($project->path('requirements.yaml'));
153        $project->write('requirements.yaml', ['version' => 1, 'definitions' => ['definition.md', 'reference.yaml'], 'markdown' => ['experimental' => true]]);
154        $project->write('reference.yaml', ['version' => 1, 'source' => ['id' => 'reference', 'uri' => 'source.html', 'format' => 'html', 'selector' => '#b'], 'items' => [['id' => 'REQ-001', 'kind' => 'requirement', 'statement' => 'Names may contain digits.', 'evidence' => [['selector' => '#b', 'quote' => 'Names may contain digits.']]]]]);
155        $project->put('definition.md', <<<'MD'
156---
157version: 1
158source:
159  id: manual
160  uri: source.html
161  format: html
162  selector: main p
163---
164
165# SPEC-001
166
167When a name is read, the parser shall require a leading letter.
168
169**evidence**
170
171- **selector:** #a
172
173  > Names shall start with a letter.
174
175**requirements**
176
177- [REQ-001](reference.yaml#req-001)
178
179**labels**
180
181![grammar](assets/grammar.svg) ![strictness](https://example.invalid/badge.svg)
182
183**metadata**
184
185- **owner:** Parser team
186- **reviewed:** true
187- **priority:** 2
188- **text:** "true"
189- **values:**
190  - stable
191  - 1
192
193**design**
194
195- [Parser design](https://example.org/design)
196- Preserve the original source spelling.
197MD);
198        $loaded = (new Loader())->load($project->path('requirements.yaml'));
199        $item = $loaded->items['SPEC-001'];
200        self::assertSame($yaml->items['SPEC-001']->statement, $item->statement);
201        self::assertSame($yaml->items['SPEC-001']->data['evidence'], $item->data['evidence']);
202        self::assertSame(['REQ-001'], $item->requirements);
203        self::assertSame(['grammar', 'strictness'], $item->labels);
204        self::assertSame(['owner' => 'Parser team', 'reviewed' => true, 'priority' => 2, 'text' => 'true', 'values' => ['stable', 1]], $item->data['metadata']);
205        $formatter = new Formatter();
206        $formatter->format($loaded->files, false, $loaded->markdown);
207        $formatted = $project->read('definition.md');
208        self::assertStringContainsString('reference.yaml#req-001', $formatted);
209        self::assertStringContainsString('assets/grammar.svg', $formatted);
210        self::assertStringNotContainsString('```', $formatted);
211        self::assertEquals($item->data, (new Loader())->load($project->path('requirements.yaml'))->items['SPEC-001']->data);
212        self::assertSame([], $formatter->format($loaded->files, true, $loaded->markdown));
213    }
214
215    /**
216     * @throws JsonException
217     * @throws CommonMarkException
218     */
219    public function testReadMatchesTheYamlModelAndRenderShowsOnlyReaderFacingInformation(): void
220    {
221        $project = new ProjectDirectory();
222        $yaml = (new Loader())->load(GrammarCards::yaml($project));
223        $markdown = (new Loader())->load(GrammarCards::markdown($project));
224        self::assertEquals(array_column($yaml->items, 'data', 'id'), array_column(array_intersect_key($markdown->items, $yaml->items), 'data', 'id'));
225        self::assertSame([], (new Analyzer())->analyze($markdown)->errors);
226        $text = $project->read('markdown/grammar.md');
227        self::assertNotSame('', $text);
228        $body = preg_replace('/\A---\n.*?\n---\n/s', '', $text);
229        self::assertIsString($body);
230        $html = (string) (new CommonMarkConverter())->convert($body);
231        $document = new Crawler($html);
232        self::assertSame(['requirement', 'lexical', 'grammar', 'unsupported'], $document->filter('img')->extract(['alt']));
233        self::assertSame(['A name starts with a letter.', 'The generator produces C code.'], $document->filter('blockquote p')->each(static fn (Crawler $node): string => $node->text()));
234        self::assertSame(['source.html#names', '#req-001', 'source.html#generation'], $document->filter('a')->extract(['href']));
235        self::assertStringNotContainsString('selector:', $document->text());
236        self::assertStringNotContainsString('**kind**', $body);
237        self::assertStringNotContainsString('**evidence**', $body);
238        self::assertStringNotContainsString('**labels**', $body);
239        self::assertSame([], (new Formatter())->format($markdown->files, true, $markdown->markdown));
240    }
241
242    /**
243     * @throws JsonException
244     */
245    public function testReadAcceptsTheProposedSelectorCommentAndReasonSpelling(): void
246    {
247        $project = new ProjectDirectory();
248        $project->write('requirements.yaml', ['version' => 1, 'definitions' => ['definition.md'], 'markdown' => ['experimental' => true]]);
249        $project->put('definition.md', <<<'MD'
250---
251version: 1
252source:
253  id: manual
254  uri: source.html
255  format: html
256  selector: main p
257---
258
259# GENERATOR-001
260
261![unsupported](<https://img.shields.io/badge/status-unsupported-blue>)
262
263The generator shall produce C code.
264
265> <!-- **selector:** \#c -->
266> The generator shall produce C code.
267
268**unsupport reason**
269
270This library reads grammars and does not generate C code.
271MD);
272        $loaded = (new Loader())->load($project->path('requirements.yaml'));
273        self::assertSame('unsupported', $loaded->items['GENERATOR-001']->status);
274        self::assertSame('#c', $loaded->items['GENERATOR-001']->evidence[0]->selector);
275        self::assertSame([], (new Analyzer())->analyze($loaded)->errors);
276        (new Formatter())->format($loaded->files, false, $loaded->markdown);
277        $formatted = $project->read('definition.md');
278        self::assertStringContainsString('[Source](source.html#c)', $formatted);
279        self::assertStringContainsString('**unsupported reason**', $formatted);
280        self::assertStringContainsString('status-unsupported-blue', $formatted);
281    }
282
283    /**
284     * @throws JsonException
285     */
286    public function testReadResolvesCitationsFromTheMarkdownFileAndKeepsQuotationsSeparate(): void
287    {
288        $project = new ProjectDirectory();
289        $project->write('requirements.yaml', ['version' => 1, 'definitions' => ['definitions/*.md'], 'markdown' => ['experimental' => true]]);
290        $project->put('definitions/names.md', <<<'MD'
291---
292version: 1
293source:
294  id: manual
295  uri: source.html
296  format: html
297  selector: main p
298---
299
300# SPEC-001
301
302![lexical](../assets/lexical.svg "category")
303![grammar](../assets/grammar.svg)
304
305When a name is read, the parser shall require a leading letter.
306
307> Names shall start with a letter.
308
309[Names](../source.html#a)
310
311> Names may contain digits.
312>
313> [Digits](../source.html#:~:text=Names%20may%20contain%20digits.)
314MD);
315        $loaded = (new Loader())->load($project->path('requirements.yaml'));
316        $item = $loaded->items['SPEC-001'];
317        self::assertSame('lexical', $item->category);
318        self::assertSame(['grammar'], $item->labels);
319        self::assertCount(2, $item->evidence);
320        self::assertSame('#a', $item->evidence[0]->selector);
321        self::assertSame('#:~:text=Names%20may%20contain%20digits.', $item->evidence[1]->selector);
322        self::assertSame([], (new Analyzer())->analyze($loaded)->errors);
323        $formatter = new Formatter();
324        $formatter->format($loaded->files, false, $loaded->markdown);
325        $formatted = $project->read('definitions/names.md');
326        self::assertStringContainsString('[Names](../source.html#a)', $formatted);
327        self::assertStringContainsString('[Digits](../source.html#:~:text=Names%20may%20contain%20digits.)', $formatted);
328        self::assertStringContainsString('![lexical](../assets/lexical.svg "category")', $formatted);
329        self::assertEquals($item->data, (new Loader())->load($project->path('requirements.yaml'))->items['SPEC-001']->data);
330        self::assertSame([], $formatter->format($loaded->files, true, $loaded->markdown));
331    }
332
333    /**
334     * @throws JsonException
335     */
336    #[DataProvider('providerInvalidStructure')]
337    public function testReadRejectsInvalidStructureAndFields(string $body, string $message): void
338    {
339        $project = new ProjectDirectory();
340        $file = $project->put('definition.md', "---\nversion: 1\nsource: null\n---\n\n" . $body);
341        $this->expectException(InvalidInputException::class);
342        $this->expectExceptionMessage($message);
343        (new DocumentReader())->read($file, 'definition', ['experimental' => true]);
344    }
345
346    /**
347     * @return array<string, array{string, string}>
348     */
349    public static function providerInvalidStructure(): array
350    {
351        return [
352            'setext heading' => ["SPEC-001\n========\n\nThe reader shall emit a tree.\n", 'item headings must use ATX # ID syntax.'],
353            'prose link' => ["# SPEC-001\n\nThe reader shall emit [a tree](design.md).\n", 'Unsupported inline Markdown'],
354            'nested heading' => ["## SPEC-001\n\nThe reader shall emit a tree.\n", 'document-schema requires top-level item ID headings.'],
355            'missing statement' => ["# SPEC-001\n\n**reason**\n\nAvoid silent data loss.\n", 'SPEC-001 needs a statement paragraph after its badges.'],
356            'extra paragraph' => ["# SPEC-001\n\nThe reader shall emit a tree.\n\nAnother paragraph.\n", 'SPEC-001 expects a bold field heading after its statement.'],
357            'code fence' => ["# SPEC-001\n\nThe reader shall emit a tree.\n\n```yaml\norigin: original\n```\n", 'document-schema forbids unsupported blocks'],
358            'unknown field' => ["# SPEC-001\n\nThe reader shall emit a tree.\n\n**lables**\n\n- grammar\n", "Unknown Markdown field 'lables'."],
359            'duplicate field' => ["# SPEC-001\n\nThe reader shall emit a tree.\n\n**origin**\n\noriginal\n\n**origin**\n\noriginal\n", "SPEC-001 has duplicate field 'origin'."],
360            'evidence without selector' => ["# SPEC-001\n\nThe reader shall emit a tree.\n\n**evidence**\n\n> Original text.\n", 'SPEC-001.evidence: Expected a bullet list.'],
361            'evidence without quotation' => ["# SPEC-001\n\nThe reader shall emit a tree.\n\n**evidence**\n\n- **selector:** #a\n", 'Evidence must use a bold selector field followed by one block quotation.'],
362            'ordered list' => ["# SPEC-001\n\nThe reader shall emit a tree.\n\n**labels**\n\n1. grammar\n", 'document-schema forbids ordered-list blocks'],
363            'reference without link' => ["# SPEC-001\n\nThe reader shall emit a tree.\n\n**requirements**\n\n- REQ-001\n", 'Expected one Markdown link with a nonempty destination.'],
364            'empty label image' => ["# SPEC-001\n\nThe reader shall emit a tree.\n\n**labels**\n\n![](badge.svg)\n", 'schema validation failed'],
365            'nested code' => ["# SPEC-001\n\nThe reader shall emit a tree.\n\n**metadata**\n\n- **example:**\n\n  ```yaml\n  x: y\n  ```\n", 'document-schema forbids unsupported blocks'],
366            'raw HTML' => ["# SPEC-001\n\nThe reader shall emit a tree.\n\n<div>unsupported</div>\n", 'document-schema forbids unsupported blocks'],
367        ];
368    }
369
370    /**
371     * @throws JsonException
372     */
373    #[DataProvider('providerMisleadingCards')]
374    public function testReadRejectsAmbiguousOrMisleadingCardData(string $body, string $message): void
375    {
376        $project = new ProjectDirectory();
377        $file = $project->put('definition.md', "---\nversion: 1\nsource:\n  id: manual\n  uri: source.html\n  format: html\n  selector: main p\n---\n\n# SPEC-001\n\n" . $body);
378        $this->expectException(InvalidInputException::class);
379        $this->expectExceptionMessage($message);
380        (new DocumentReader())->read($file, 'definition', ['experimental' => true]);
381    }
382
383    /**
384     * @return array<string, array{string, string}>
385     */
386    public static function providerMisleadingCards(): array
387    {
388        return [
389            'badge without statement' => ['![requirement](https://img.shields.io/badge/kind-requirement-blue)', 'SPEC-001 needs a statement paragraph after its badges.'],
390            'duplicate status' => ["![supported](a.svg \"status\") ![unsupported](b.svg \"status\")\n\nThe reader shall emit a tree.", "Duplicate 'status' badge or field."],
391            'duplicate label' => ["![parser](a.svg) ![parser](b.svg)\n\nThe reader shall emit a tree.", 'Label badges must be unique.'],
392            'badge field conflict' => ["![grammar](a.svg \"category\")\n\nThe reader shall emit a tree.\n\n**category**\n\nlexical", "SPEC-001 has duplicate field 'category'."],
393            'misleading static badge' => ["![unsupported](https://img.shields.io/badge/status-supported-blue)\n\nThe reader shall emit a tree.", 'Static badge image text and role must agree with its alt text and title.'],
394            'wrong hidden text directive' => ["The reader shall emit a tree.\n\n> <!-- selector: #:~:text=Different%20text. -->\n> Names shall start with a letter.", 'An exact Text Fragment selector must identify the complete quoted HTML unit.'],
395            'unknown badge role' => ["![value](a.svg \"lable\")\n\nThe reader shall emit a tree.", 'A custom badge title must be kind, status, origin, category or label.'],
396            'wrong citation resource' => ["The reader shall emit a tree.\n\n> <!-- selector: #a -->\n> Names shall start with a letter.\n\n[Source](different.html#a)", 'An evidence citation must link to this definition\'s source resource.'],
397            'wrong citation anchor' => ["The reader shall emit a tree.\n\n> <!-- selector: #a -->\n> Names shall start with a letter.\n\n[Source](source.html#b)", 'The citation anchor disagrees with the evidence selector.'],
398            'wrong citation text' => ["The reader shall emit a tree.\n\n> Names shall start with a letter.\n\n[Source](source.html#:~:text=Different%20text.)", 'An exact Text Fragment citation must identify the complete quoted HTML unit.'],
399            'unsupported fragment range' => ["The reader shall emit a tree.\n\n> Names shall start with a letter.\n\n[Source](source.html#:~:text=Names,letter.)", 'Use one exact Text Fragment'],
400            'quote with no locator' => ["The reader shall emit a tree.\n\n> Names shall start with a letter.", 'Evidence needs a selector comment or a source link identifying the quoted unit.'],
401            'arbitrary comment' => ["The reader shall emit a tree.\n\n> <!-- unknown: value -->\n> Names shall start with a letter.", 'Only a selector comment is allowed at the start of an evidence quotation.'],
402            'raw HTML in quote' => ["The reader shall emit a tree.\n\n> <div>Names shall start with a letter.</div>", 'Only a selector comment is allowed at the start of an evidence quotation.'],
403            'comment outside quote' => ["The reader shall emit a tree.\n\n<!-- selector: #a -->", 'document-schema forbids unsupported blocks'],
404            'two citations' => ["The reader shall emit a tree.\n\n> Names shall start with a letter.\n>\n> [Source](source.html#a)\n\n[Source](source.html#a)", 'An evidence quotation can have only one source citation.'],
405        ];
406    }
407
408    /**
409     * @throws JsonException
410     */
411    public function testReadRequiresTheExperimentalOptIn(): void
412    {
413        $this->expectException(InvalidInputException::class);
414        $this->expectExceptionMessage('experimental');
415        (new DocumentReader())->read('unread.md', 'definition');
416    }
417
418    /**
419     * @param array<string, mixed> $options
420     * @throws JsonException
421     */
422    #[DataProvider('providerWithoutOptIn')]
423    public function testReadRejectsOptionsWithoutTheOptIn(array $options): void
424    {
425        $this->expectException(InvalidInputException::class);
426        $this->expectExceptionMessage('unread.md: Markdown definitions require markdown.experimental: true.');
427        (new MarkdownDocument())->read('unread.md', $options);
428    }
429
430    /**
431     * @return array<string, array{array<string, mixed>}>
432     */
433    public static function providerWithoutOptIn(): array
434    {
435        return [
436            'no options' => [[]],
437            'disabled' => [['experimental' => false]],
438            'truthy string' => [['experimental' => 'true']],
439        ];
440    }
441
442    /**
443     * @throws JsonException
444     */
445    public function testReadRejectsABodyThatIsNotUtf8(): void
446    {
447        $project = new ProjectDirectory();
448        $file = $project->put('definition.md', "---\nversion: 1\nsource: null\n---\n\n# SPEC-001\n\nThe reader shall emit \xff.\n");
449        $this->expectException(InvalidInputException::class);
450        $this->expectExceptionMessage('Unexpected encoding - UTF-8 or ASCII was expected');
451        (new MarkdownDocument())->read($file, ['experimental' => true]);
452    }
453
454    /**
455     * @throws JsonException
456     */
457    public function testReadRejectsUnknownMarkdownOptions(): void
458    {
459        $this->expectException(InvalidInputException::class);
460        $this->expectExceptionMessage("markdown: unknown field 'strict'.");
461        (new MarkdownDocument())->read('unread.md', ['experimental' => true, 'strict' => true]);
462    }
463
464    /**
465     * @throws JsonException
466     */
467    public function testReadReadsACardDefinitionWithoutASource(): void
468    {
469        $project = new ProjectDirectory();
470        $file = $project->put('definition.md', "---\nversion: 1\nsource: null\n---\n\n# SPEC-001\n\n![original](https://img.shields.io/badge/origin-original-blue)\n\nThe reader shall emit a tree.\n\n**rationale**\n\nKeep the tree.\n\n**related**\n\n- [REQ-001](other.md#req-001)\n");
471        $markdown = new MarkdownDocument();
472        self::assertEquals((object) ['version' => 1, 'source' => null, 'items' => [(object) ['id' => 'SPEC-001', 'origin' => 'original', 'statement' => 'The reader shall emit a tree.', 'reason' => 'Keep the tree.', 'related' => ['REQ-001']]]], $markdown->read($file, ['experimental' => true]));
473    }
474
475    /**
476     * @throws JsonException
477     */
478    public function testReadResolvesTheSourceAgainstTheConfigurationDirectory(): void
479    {
480        $project = new ProjectDirectory();
481        $file = $project->put('definitions/names.md', "---\nversion: 1\nsource:\n  id: manual\n  uri: source.html\n  format: html\n  selector: 'main p'\n---\n\n# SPEC-001\n\nThe reader shall emit a tree.\n\n> Names shall start with a letter.\n\n[Names](../source.html#a)\n");
482        $markdown = new MarkdownDocument();
483        $data = $markdown->read($file, ['experimental' => true], $project->directory);
484        self::assertEquals((object) ['version' => 1, 'source' => (object) ['id' => 'manual', 'uri' => 'source.html', 'format' => 'html', 'selector' => 'main p'], 'items' => [(object) ['id' => 'SPEC-001', 'statement' => 'The reader shall emit a tree.', 'evidence' => [(object) ['selector' => '#a', 'quote' => 'Names shall start with a letter.']]]]], $data);
485        self::assertSame(file_get_contents($file), $markdown->render($data));
486    }
487
488    /**
489     * @throws JsonException
490     */
491    public function testReadResolvesTheSourceAgainstTheFileDirectoryByDefault(): void
492    {
493        $project = new ProjectDirectory();
494        $file = $project->put('definitions/names.md', "---\nversion: 1\nsource:\n  id: manual\n  uri: source.html\n  format: html\n  selector: 'main p'\n---\n\n# SPEC-001\n\nThe reader shall emit a tree.\n\n> Names shall start with a letter.\n\n[Names](../source.html#a)\n");
495        $this->expectException(InvalidInputException::class);
496        $this->expectExceptionMessage('An evidence citation must link to this definition\'s source resource.');
497        (new MarkdownDocument())->read($file, ['experimental' => true]);
498    }
499
500    /**
501     * @throws JsonException
502     */
503    public function testReferencesReturnsTheLinksOfTheLastRead(): void
504    {
505        $project = new ProjectDirectory();
506        $first = $project->put('first.md', "---\nversion: 1\nsource: null\n---\n\n# SPEC-001\n\nThe reader shall emit a tree.\n\n**origin**\n\noriginal\n\n**reason**\n\nWhy.\n\n**requirements**\n\n- [REQ-001](reference.yaml#req-001)\n\n**related**\n\n- [SPEC-002](#spec-002)\n");
507        $second = $project->put('second.md', "---\nversion: 1\nsource: null\n---\n\n# SPEC-003\n\nThe reader shall emit a tree.\n\n**related**\n\n- [SPEC-001](first.md#spec-001)\n");
508        $markdown = new MarkdownDocument();
509        self::assertSame([], $markdown->references());
510        $markdown->read($first, ['experimental' => true]);
511        self::assertEquals([new Reference('REQ-001', 'reference.yaml#req-001', $first), new Reference('SPEC-002', '#spec-002', $first)], $markdown->references());
512        $markdown->read($second, ['experimental' => true]);
513        self::assertEquals([new Reference('SPEC-001', 'first.md#spec-001', $second)], $markdown->references());
514    }
515
516    /**
517     * @throws JsonException
518     */
519    public function testRenderKeepsEmptyListsAndStructuredMetadata(): void
520    {
521        $project = new ProjectDirectory();
522        $data = (object) ['version' => 1, 'source' => null, 'items' => [(object) ['id' => 'SPEC-001', 'statement' => 'The reader shall preserve `shall` literally.', 'origin' => 'original', 'reason' => 'Keep keywords.', 'labels' => [], 'metadata' => (object) ['empty' => new stdClass(), 'array' => [], 'quote' => 'true', 'nested' => [(object) ['value' => null]], 'zero' => 0, 'entities' => 'Keep &copy; and <tokens> literally.', 'numbered' => '1. Entry', 'bullet' => '- Entry', 'lines' => "a\nb"]]]];
523        $file = $project->path('definition.md');
524        $markdown = new MarkdownDocument();
525        file_put_contents($file, $markdown->render($data));
526        self::assertEquals($data, $markdown->read($file, ['experimental' => true]));
527    }
528
529    /**
530     * @throws JsonException
531     */
532    public function testRenderKeepsBadgeValuesWithSeparatorsAndUnicode(): void
533    {
534        $project = new ProjectDirectory();
535        $data = (object) ['version' => 1, 'source' => null, 'items' => [(object) ['id' => 'SPEC-001', 'statement' => 'The reader shall preserve names.', 'origin' => 'original', 'reason' => 'Preserve names.', 'category' => '-names', 'labels' => ['-feature', 'under_score', 'with spaces', 'end-', '日本語']]]];
536        $file = $project->path('definition.md');
537        $markdown = new MarkdownDocument();
538        file_put_contents($file, $markdown->render($data));
539        self::assertEquals($data, $markdown->read($file, ['experimental' => true]));
540    }
541
542    /**
543     * @throws JsonException
544     */
545    public function testRenderAddsTextFragmentNavigationForAComplexCssSelector(): void
546    {
547        $project = new ProjectDirectory();
548        $project->write('requirements.yaml', ['version' => 1, 'definitions' => ['definition.md'], 'markdown' => ['experimental' => true]]);
549        $project->put('definition.md', <<<'MD'
550---
551version: 1
552source:
553  id: manual
554  uri: source.html
555  format: html
556  selector: main p
557---
558
559# SPEC-001
560
561The parser shall require leading letters.
562
563> <!-- selector: main > p:first-child -->
564> Names shall start with a letter.
565MD);
566        $loaded = (new Loader())->load($project->path('requirements.yaml'));
567        (new Formatter())->format($loaded->files, false, $loaded->markdown);
568        $formatted = $project->read('definition.md');
569        self::assertStringContainsString('[Source](source.html#:~:text=Names%20shall%20start%20with%20a%20letter.)', $formatted);
570        $after = (new Loader())->load($project->path('requirements.yaml'));
571        self::assertSame('main > p:first-child', $after->items['SPEC-001']->evidence[0]->selector);
572        self::assertSame([], (new Analyzer())->analyze($after)->errors);
573    }
574
575    /**
576     * @throws JsonException
577     */
578    public function testRenderWritesNewBadgesWithoutARead(): void
579    {
580        $data = (object) ['version' => 1, 'items' => [(object) ['id' => 'SPEC-001', 'statement' => 'The reader shall emit a tree.', 'labels' => ['grammar'], 'related' => ['REQ-001']]]];
581        self::assertSame("---\nversion: 1\n---\n\n# SPEC-001\n\n![grammar](https://img.shields.io/badge/label-grammar-blue)\n\nThe reader shall emit a tree.\n\n**related**\n\n- [REQ-001](#req-001)\n", (new MarkdownDocument())->render($data));
582    }
583
584    /**
585     * @throws JsonException
586     */
587    public function testRenderForgetsWhatAnEarlierReadRemembered(): void
588    {
589        $project = new ProjectDirectory();
590        $first = $project->put('first.md', "---\nversion: 1\nsource: null\n---\n\n# SPEC-001\n\n![grammar](assets/grammar.svg)\n\nThe reader shall emit a tree.\n\n**related**\n\n- [REQ-001](other.md#req-001)\n");
591        $second = $project->put('second.md', "---\nversion: 1\nsource:\n  id: manual\n  uri: source.html\n  format: html\n  selector: main p\n---\n\n# SPEC-002\n\nThe reader shall emit a tree.\n");
592        $markdown = new MarkdownDocument();
593        $markdown->read($first, ['experimental' => true]);
594        $markdown->read($second, ['experimental' => true]);
595        $data = (object) ['version' => 1, 'source' => null, 'items' => [(object) ['id' => 'SPEC-001', 'labels' => ['grammar'], 'statement' => 'The reader shall emit a tree.', 'evidence' => [(object) ['selector' => '#a', 'quote' => 'Names shall start with a letter.']], 'related' => ['REQ-001']]]];
596        $third = $project->put('third.md', "---\nversion: 1\nsource: null\n---\n\n# SPEC-003\n\nThe reader shall emit a tree.\n");
597        $markdown->read($third, ['experimental' => true]);
598        self::assertSame("---\nversion: 1\nsource: null\n---\n\n# SPEC-001\n\n![grammar](https://img.shields.io/badge/label-grammar-blue)\n\nThe reader shall emit a tree.\n\n> <!-- selector: #a -->\n> Names shall start with a letter.\n\n**related**\n\n- [REQ-001](#req-001)\n", $markdown->render($data));
599    }
600}
601