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