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\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("\n\n\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' => [''],
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