packages/requirements/tests/Unit/Config/Markdown/CardReaderTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Config\Markdown;
6
7use League\CommonMark\Exception\CommonMarkException;
8use League\CommonMark\Extension\CommonMark\Node\Block\Heading;
9use PHPUnit\Framework\Attributes\CoversClass;
10use PHPUnit\Framework\Attributes\DataProvider;
11use PHPUnit\Framework\Attributes\Small;
12use PHPUnit\Framework\Attributes\UsesClass;
13use PHPUnit\Framework\TestCase;
14use Requirements\Config\Markdown\Badges;
15use Requirements\Config\Markdown\CardReader;
16use Requirements\Config\Markdown\Citation;
17use Requirements\Config\Markdown\FieldReader;
18use Requirements\Config\Markdown\FieldSections;
19use Requirements\Config\Markdown\LocalPath;
20use Requirements\Config\Markdown\Nodes;
21use Requirements\Config\Markdown\Presentation;
22use Requirements\Config\Markdown\Quotation;
23use Requirements\Config\Markdown\QuotationBlocks;
24use Requirements\Config\Markdown\Reference;
25use Requirements\Config\Markdown\StaticBadge;
26use Requirements\Input\InvalidInputException;
27use Requirements\Model\Source;
28use Requirements\Source\TextFragment;
29use Requirements\Source\Unit;
30use Tests\Fake\MarkdownNodes;
31
32#[CoversClass(CardReader::class)]
33#[UsesClass(Badges::class)]
34#[UsesClass(Citation::class)]
35#[UsesClass(FieldReader::class)]
36#[UsesClass(FieldSections::class)]
37#[UsesClass(LocalPath::class)]
38#[UsesClass(Nodes::class)]
39#[UsesClass(Presentation::class)]
40#[UsesClass(Quotation::class)]
41#[UsesClass(QuotationBlocks::class)]
42#[UsesClass(Reference::class)]
43#[UsesClass(Source::class)]
44#[UsesClass(StaticBadge::class)]
45#[UsesClass(TextFragment::class)]
46#[UsesClass(Unit::class)]
47#[Small]
48final class CardReaderTest extends TestCase
49{
50    /**
51     * @throws CommonMarkException
52     */
53    public function testCardsReadsEveryCard(): void
54    {
55        $body = "\n# REQ-001\n\n![requirement](https://img.shields.io/badge/kind-requirement-blue)\n\nA name starts with a letter.\n\n> A name starts with a letter.\n\n[Source](source.html#a)\n\n   # SPEC-001\n\nWhen a name is read, the parser shall require a leading letter.\n\n**requirements**\n\n- [REQ-001](#req-001)\n";
56        $presentation = new Presentation();
57        $items = (new CardReader($presentation, new Citation(new Source('manual', 'source.html', 'html', 'main p'), '/project/definition.md', '/project')))->cards(MarkdownNodes::parse($body), $body, 'definition.md');
58        self::assertEquals([
59            (object) ['id' => 'REQ-001', 'kind' => 'requirement', 'statement' => 'A name starts with a letter.', 'evidence' => [(object) ['selector' => '#a', 'quote' => 'A name starts with a letter.']]],
60            (object) ['id' => 'SPEC-001', 'statement' => 'When a name is read, the parser shall require a leading letter.', 'requirements' => ['REQ-001']],
61        ], $items);
62        self::assertSame(['REQ-001' => ['kind' => ['requirement' => ['url' => 'https://img.shields.io/badge/kind-requirement-blue', 'title' => null]]], 'SPEC-001' => []], $presentation->badges);
63        self::assertSame(['REQ-001' => [['url' => 'source.html#a', 'label' => 'Source']]], $presentation->citations);
64        self::assertSame(['SPEC-001' => ['requirements' => ['REQ-001' => '#req-001']]], $presentation->links);
65    }
66
67    /**
68     * @throws CommonMarkException
69     */
70    public function testCardsReadsNothingFromABodyWithoutHeadings(): void
71    {
72        self::assertSame([], (new CardReader(new Presentation(), null))->cards(MarkdownNodes::parse(''), '', 'definition.md'));
73    }
74
75    /**
76     * @throws CommonMarkException
77     */
78    public function testCardsIgnoresBlocksBeforeTheFirstHeading(): void
79    {
80        $body = "Introduction.\n\n# SPEC-001\n\nThe reader shall emit a tree.\n";
81        self::assertEquals([(object) ['id' => 'SPEC-001', 'statement' => 'The reader shall emit a tree.']], (new CardReader(new Presentation(), null))->cards(MarkdownNodes::parse($body), $body, 'definition.md'));
82    }
83
84    /**
85     * @throws CommonMarkException
86     */
87    #[DataProvider('providerNotAtxHeadings')]
88    public function testCardsRejectsHeadingsNotWrittenAsAtx(string $body): void
89    {
90        $this->expectException(InvalidInputException::class);
91        $this->expectExceptionMessage('definition.md: item headings must use ATX # ID syntax.');
92        (new CardReader(new Presentation(), null))->cards(MarkdownNodes::parse($body), $body, 'definition.md');
93    }
94
95    /**
96     * @return array<string, array{string}>
97     */
98    public static function providerNotAtxHeadings(): array
99    {
100        return [
101            'setext heading' => ["SPEC-001\n========\n\nThe reader shall emit a tree.\n"],
102            'second level heading' => ["## SPEC-001\n\nThe reader shall emit a tree.\n"],
103            'second setext heading' => ["# SPEC-001\n\nThe reader shall emit a tree.\n\nSPEC-002\n--------\n\nThe reader shall emit a tree.\n"],
104        ];
105    }
106
107    /**
108     * @throws CommonMarkException
109     */
110    public function testCardsAcceptsATabAfterTheHash(): void
111    {
112        $body = "#\tSPEC-001\n\nThe reader shall emit a tree.\n";
113        self::assertEquals([(object) ['id' => 'SPEC-001', 'statement' => 'The reader shall emit a tree.']], (new CardReader(new Presentation(), null))->cards(MarkdownNodes::parse($body), $body, 'definition.md'));
114    }
115
116    /**
117     * @throws CommonMarkException
118     */
119    public function testCardReadsBadgesStatementEvidenceAndFields(): void
120    {
121        $blocks = MarkdownNodes::blocks("![lexical](../assets/lexical.svg \"category\")\n\n![grammar](../assets/grammar.svg)\n\nWhen a name is read,\n   the parser shall require a `leading` letter.\n\n> <!-- selector: #a -->\n> Names shall start with a letter.\n\n**origin**\n\nsourced\n");
122        $heading = MarkdownNodes::first('# SPEC-001');
123        self::assertInstanceOf(Heading::class, $heading);
124        $presentation = new Presentation();
125        $item = (new CardReader($presentation, new Citation(new Source('manual', 'source.html', 'html', 'main p'), '/project/definitions/names.md', '/project')))->card($heading, $blocks, 'names.md');
126        self::assertEquals((object) [
127            'id' => 'SPEC-001',
128            'category' => 'lexical',
129            'labels' => ['grammar'],
130            'statement' => 'When a name is read, the parser shall require a `leading` letter.',
131            'evidence' => [(object) ['selector' => '#a', 'quote' => 'Names shall start with a letter.']],
132            'origin' => 'sourced',
133        ], $item);
134        self::assertSame(['SPEC-001' => ['category' => ['lexical' => ['url' => '../assets/lexical.svg', 'title' => 'category']], 'label' => ['grammar' => ['url' => '../assets/grammar.svg', 'title' => null]]]], $presentation->badges);
135        self::assertSame(['SPEC-001' => [null]], $presentation->citations);
136    }
137
138    /**
139     * @throws CommonMarkException
140     */
141    public function testCardLeavesOutEvidenceWhenThereIsNoQuotation(): void
142    {
143        $heading = MarkdownNodes::first('# SPEC-001');
144        self::assertInstanceOf(Heading::class, $heading);
145        $item = (new CardReader(new Presentation(), null))->card($heading, MarkdownNodes::blocks('The reader shall emit a tree.'), 'definition.md');
146        self::assertFalse(property_exists($item, 'evidence'));
147    }
148
149    /**
150     * @throws CommonMarkException
151     */
152    #[DataProvider('providerCardsWithoutStatements')]
153    public function testCardRejectsACardWithoutAStatement(string $markdown): void
154    {
155        $heading = MarkdownNodes::first('# SPEC-001');
156        self::assertInstanceOf(Heading::class, $heading);
157        $this->expectException(InvalidInputException::class);
158        $this->expectExceptionMessage('definition.md: SPEC-001 needs a statement paragraph after its badges.');
159        (new CardReader(new Presentation(), null))->card($heading, MarkdownNodes::blocks($markdown), 'definition.md');
160    }
161
162    /**
163     * @return array<string, array{string}>
164     */
165    public static function providerCardsWithoutStatements(): array
166    {
167        return [
168            'nothing' => [''],
169            'badge without statement' => ['![requirement](https://img.shields.io/badge/kind-requirement-blue)'],
170            'field instead of statement' => ["**reason**\n\nAvoid silent data loss.\n"],
171            'list instead of statement' => ['- The reader shall emit a tree.'],
172        ];
173    }
174
175    /**
176     * @throws CommonMarkException
177     */
178    public function testCardRejectsInlineMarkdownInTheStatement(): void
179    {
180        $heading = MarkdownNodes::first('# SPEC-001');
181        self::assertInstanceOf(Heading::class, $heading);
182        $this->expectException(InvalidInputException::class);
183        $this->expectExceptionMessage('Unsupported inline Markdown');
184        (new CardReader(new Presentation(), null))->card($heading, MarkdownNodes::blocks('The reader shall emit [a tree](design.md).'), 'definition.md');
185    }
186}
187