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 
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
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
303
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('', $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\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' => ['', 'SPEC-001 needs a statement paragraph after its badges.'],
390 'duplicate status' => [" \n\nThe reader shall emit a tree.", "Duplicate 'status' badge or field."],
391 'duplicate label' => [" \n\nThe reader shall emit a tree.", 'Label badges must be unique.'],
392 'badge field conflict' => ["\n\nThe reader shall emit a tree.\n\n**category**\n\nlexical", "SPEC-001 has duplicate field 'category'."],
393 'misleading static badge' => ["\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' => ["\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\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 © 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\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\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\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