packages/requirements/tests/Unit/Config/Markdown/Profile/DocumentSchemaTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Config\Markdown\Profile;
6
7use JsonException;
8use League\CommonMark\Environment\Environment;
9use League\CommonMark\Exception\CommonMarkException;
10use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
11use League\CommonMark\Parser\MarkdownParser;
12use PHPUnit\Framework\Attributes\CoversClass;
13use PHPUnit\Framework\Attributes\DataProvider;
14use PHPUnit\Framework\Attributes\Small;
15use PHPUnit\Framework\Attributes\UsesClass;
16use PHPUnit\Framework\TestCase;
17use Requirements\Config\Markdown\Nodes;
18use Requirements\Config\Markdown\Profile\AllowedBlocks;
19use Requirements\Config\Markdown\Profile\BlockKind;
20use Requirements\Config\Markdown\Profile\DocumentSchema;
21use Requirements\Config\Markdown\Profile\Occurrences;
22use Requirements\Config\Markdown\Profile\SectionBlocks;
23use Requirements\Config\Markdown\Profile\TextConstraint;
24use Requirements\Config\Markdown\Quotation;
25use Requirements\Config\SchemaValidator;
26use Requirements\Input\Fields;
27use Requirements\Input\InvalidInputException;
28use stdClass;
29
30#[CoversClass(DocumentSchema::class)]
31#[UsesClass(AllowedBlocks::class)]
32#[UsesClass(BlockKind::class)]
33#[UsesClass(Fields::class)]
34#[UsesClass(Nodes::class)]
35#[UsesClass(Occurrences::class)]
36#[UsesClass(Quotation::class)]
37#[UsesClass(SchemaValidator::class)]
38#[UsesClass(SectionBlocks::class)]
39#[UsesClass(TextConstraint::class)]
40#[Small]
41final class DocumentSchemaTest extends TestCase
42{
43 /**
44 * @throws CommonMarkException
45 * @throws JsonException
46 */
47 #[DataProvider('providerValidDocuments')]
48 public function testValidateAcceptsProfileDocument(string $markdown, stdClass $frontmatter): void
49 {
50 $environment = new Environment();
51 $environment->addExtension(new CommonMarkCoreExtension());
52 (new DocumentSchema())->validate((new MarkdownParser($environment))->parse($markdown), $frontmatter, 'doc.md');
53 $this->addToAssertionCount(1);
54 }
55
56 /**
57 * @return array<string, array{string, stdClass}>
58 */
59 public static function providerValidDocuments(): array
60 {
61 return [
62 'one item' => ["# SPEC-001\n\nThe reader shall emit a tree.\n", (object) ['version' => 1, 'source' => null]],
63 'item with every block kind' => ["# SPEC-001\n\nThe reader shall emit a tree.\n\n**evidence**\n\n> <!-- selector: #a -->\n> Quoted.\n\n- one\n", (object) ['version' => 1, 'source' => (object) ['id' => 'manual']]],
64 'several items' => ["# SPEC-001\n\nFirst.\n\n# SPEC-002\n\nSecond.\n\n# spec.3_x\n\nThird.\n", (object) ['version' => 1, 'source' => null]],
65 'frontmatter declaring its schema' => ["# SPEC-001\n\nFirst.\n", (object) ['$schema' => SchemaValidator::BASE . 'definition.document.yaml', 'version' => 1, 'source' => null]],
66 ];
67 }
68
69 /**
70 * @throws CommonMarkException
71 * @throws JsonException
72 */
73 public function testValidateLeavesFrontmatterSchemaInPlace(): void
74 {
75 $environment = new Environment();
76 $environment->addExtension(new CommonMarkCoreExtension());
77 $frontmatter = (object) ['$schema' => 'local.json', 'version' => 1, 'source' => null];
78 (new DocumentSchema())->validate((new MarkdownParser($environment))->parse("# SPEC-001\n\nFirst.\n"), $frontmatter, 'doc.md');
79 self::assertEquals((object) ['$schema' => 'local.json', 'version' => 1, 'source' => null], $frontmatter);
80 }
81
82 /**
83 * @throws CommonMarkException
84 * @throws JsonException
85 */
86 #[DataProvider('providerInvalidFrontmatter')]
87 public function testValidateRejectsFrontmatter(stdClass $frontmatter, string $detail): void
88 {
89 $environment = new Environment();
90 $environment->addExtension(new CommonMarkCoreExtension());
91 $this->expectException(InvalidInputException::class);
92 $this->expectExceptionMessageMatches('~^doc\.md: document-schema frontmatter: \{.*' . preg_quote($detail, '~') . '~');
93 (new DocumentSchema())->validate((new MarkdownParser($environment))->parse("# SPEC-001\n\nFirst.\n"), $frontmatter, 'doc.md');
94 }
95
96 /**
97 * @return array<string, array{stdClass, string}>
98 */
99 public static function providerInvalidFrontmatter(): array
100 {
101 return [
102 'missing source' => [(object) ['version' => 1], 'The required properties (source) are missing'],
103 'missing version' => [(object) ['source' => null], 'The required properties (version) are missing'],
104 'wrong version' => [(object) ['version' => 2, 'source' => null], 'const'],
105 'source of wrong type' => [(object) ['version' => 1, 'source' => 'manual'], 'source'],
106 'unknown key' => [(object) ['version' => 1, 'source' => null, 'items' => []], 'items'],
107 ];
108 }
109
110 /**
111 * @throws CommonMarkException
112 * @throws JsonException
113 */
114 #[DataProvider('providerInvalidBodies')]
115 public function testValidateRejectsBody(string $markdown, string $message): void
116 {
117 $environment = new Environment();
118 $environment->addExtension(new CommonMarkCoreExtension());
119 $document = (new MarkdownParser($environment))->parse($markdown);
120 $this->expectException(InvalidInputException::class);
121 $this->expectExceptionMessage($message);
122 (new DocumentSchema())->validate($document, (object) ['version' => 1, 'source' => null], 'doc.md');
123 }
124
125 /**
126 * @return array<string, array{string, string}>
127 */
128 public static function providerInvalidBodies(): array
129 {
130 return [
131 'content before the first heading' => ["Introduction.\n\n# SPEC-001\n\nFirst.\n", 'doc.md: document-schema forbids content before the first heading.'],
132 'forbidden block before the first heading' => ["```\ncode\n```\n\n# SPEC-001\n\nFirst.\n", 'doc.md: document-schema forbids unsupported blocks; use quotations, paragraphs and bullet lists.'],
133 'second level heading' => ["## SPEC-001\n\nFirst.\n", 'doc.md: document-schema requires top-level item ID headings.'],
134 'nested heading after an item' => ["# SPEC-001\n\nFirst.\n\n## Details\n\nMore.\n", 'doc.md: document-schema requires top-level item ID headings.'],
135 'heading that is not an ID' => ["# 1SPEC\n\nFirst.\n", 'doc.md: document-schema requires top-level item ID headings.'],
136 'heading with spaces' => ["# SPEC 001\n\nFirst.\n", 'doc.md: document-schema requires top-level item ID headings.'],
137 'no item' => ['', 'doc.md: item sections: document-schema occurrence constraint failed.'],
138 'empty first item' => ["# SPEC-001\n\n# SPEC-002\n\nSecond.\n", 'doc.md: section block 1: document-schema occurrence constraint failed.'],
139 'empty last item' => ["# SPEC-001\n\nFirst.\n\n# SPEC-002\n", 'doc.md: section block 1: document-schema occurrence constraint failed.'],
140 'forbidden block in an item' => ["# SPEC-001\n\nFirst.\n\n1. one\n", 'doc.md: document-schema forbids ordered-list blocks; use quotations, paragraphs and bullet lists.'],
141 'misplaced comment in an item' => ["# SPEC-001\n\nFirst.\n\n> <!-- note -->\n> Quoted.\n", 'Only a selector comment is allowed at the start of an evidence quotation.'],
142 ];
143 }
144}
145