packages/requirements/tests/Unit/Config/Markdown/FieldSectionsTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Config\Markdown;
6
7use League\CommonMark\Exception\CommonMarkException;
8use PHPUnit\Framework\Attributes\CoversClass;
9use PHPUnit\Framework\Attributes\DataProvider;
10use PHPUnit\Framework\Attributes\Small;
11use PHPUnit\Framework\Attributes\UsesClass;
12use PHPUnit\Framework\TestCase;
13use Requirements\Config\Markdown\FieldReader;
14use Requirements\Config\Markdown\FieldSections;
15use Requirements\Config\Markdown\MetadataReader;
16use Requirements\Config\Markdown\Nodes;
17use Requirements\Config\Markdown\Presentation;
18use Requirements\Config\Markdown\Reference;
19use Requirements\Input\InvalidInputException;
20use stdClass;
21use Tests\Fake\MarkdownNodes;
22
23#[CoversClass(FieldSections::class)]
24#[UsesClass(FieldReader::class)]
25#[UsesClass(MetadataReader::class)]
26#[UsesClass(Nodes::class)]
27#[UsesClass(Presentation::class)]
28#[UsesClass(Reference::class)]
29#[Small]
30final class FieldSectionsTest extends TestCase
31{
32 /**
33 * @throws CommonMarkException
34 */
35 public function testReadReadsEveryFieldSection(): void
36 {
37 $item = (object) ['id' => 'SPEC-001'];
38 $markdown = "**Origin**\n\noriginal\n\n**reason**\n\nAvoid silent\ndata loss.\n\nKeep input.\n\n**tests**\n\n- **unit:** Sample\\PassingTest::testPass\n\n**metadata**\n\n- **owner:** Parser team\n";
39 (new FieldSections(new Presentation()))->read($item, MarkdownNodes::blocks($markdown), 'definition.md', 'SPEC-001');
40 self::assertEquals((object) [
41 'id' => 'SPEC-001',
42 'origin' => 'original',
43 'reason' => "Avoid silent\ndata loss.\n\nKeep input.",
44 'tests' => [(object) ['runner' => 'unit', 'target' => 'Sample\\PassingTest::testPass']],
45 'metadata' => (object) ['owner' => 'Parser team'],
46 ], $item);
47 }
48
49 public function testReadReadsNothingFromNoBlocks(): void
50 {
51 $item = (object) ['id' => 'SPEC-001'];
52 $presentation = new Presentation();
53 (new FieldSections($presentation))->read($item, [], 'definition.md', 'SPEC-001');
54 self::assertEquals((object) ['id' => 'SPEC-001'], $item);
55 self::assertSame([], $presentation->links);
56 }
57
58 /**
59 * @throws CommonMarkException
60 */
61 #[DataProvider('providerReasonSpellings')]
62 public function testReadAcceptsTheSpellingsOfTheReasonField(string $heading): void
63 {
64 $item = new stdClass();
65 (new FieldSections(new Presentation()))->read($item, MarkdownNodes::blocks("**$heading**\n\nThis library does not generate C code.\n"), 'definition.md', 'GENERATOR-001');
66 self::assertEquals((object) ['reason' => 'This library does not generate C code.'], $item);
67 }
68
69 /**
70 * @return array<string, array{string}>
71 */
72 public static function providerReasonSpellings(): array
73 {
74 return [
75 'reason' => ['reason'],
76 'unsupport reason' => ['unsupport reason'],
77 'unsupported reason' => ['unsupported reason'],
78 'rationale' => ['Rationale'],
79 ];
80 }
81
82 /**
83 * @throws CommonMarkException
84 */
85 public function testReadRemembersLinksAndReferences(): void
86 {
87 $presentation = new Presentation();
88 $markdown = "**origin**\n\noriginal\n\n**requirements**\n\n- [REQ-001](reference.yaml#req-001)\n\n**related**\n\n- [SPEC-002](#spec-002)\n";
89 (new FieldSections($presentation))->read(new stdClass(), MarkdownNodes::blocks($markdown), 'definition.md', 'SPEC-001');
90 self::assertSame(['SPEC-001' => ['origin' => [], 'requirements' => ['REQ-001' => 'reference.yaml#req-001'], 'related' => ['SPEC-002' => '#spec-002']]], $presentation->links);
91 self::assertEquals([new Reference('REQ-001', 'reference.yaml#req-001', 'definition.md'), new Reference('SPEC-002', '#spec-002', 'definition.md')], $presentation->references);
92 self::assertSame([], $presentation->badges);
93 }
94
95 /**
96 * @throws CommonMarkException
97 */
98 public function testReadRemembersLabelBadges(): void
99 {
100 $presentation = new Presentation();
101 $presentation->badges['SPEC-001']['kind'] = ['requirement' => ['url' => 'kind.svg', 'title' => null]];
102 $item = new stdClass();
103 (new FieldSections($presentation))->read($item, MarkdownNodes::blocks("**labels**\n\n \n"), 'definition.md', 'SPEC-001');
104 self::assertSame(['grammar', 'strictness'], $item->labels);
105 self::assertSame(['SPEC-001' => [
106 'kind' => ['requirement' => ['url' => 'kind.svg', 'title' => null]],
107 'label' => ['grammar' => ['url' => 'assets/grammar.svg', 'title' => null], 'strictness' => ['url' => 'https://example.invalid/badge.svg', 'title' => null]],
108 ]], $presentation->badges);
109 }
110
111 /**
112 * @throws CommonMarkException
113 */
114 #[DataProvider('providerMalformedSections')]
115 public function testReadRejectsMalformedSections(string $markdown, string $message): void
116 {
117 $this->expectException(InvalidInputException::class);
118 $this->expectExceptionMessage($message);
119 (new FieldSections(new Presentation()))->read(new stdClass(), MarkdownNodes::blocks($markdown), 'definition.md', 'SPEC-001');
120 }
121
122 /**
123 * @return array<string, array{string, string}>
124 */
125 public static function providerMalformedSections(): array
126 {
127 return [
128 'extra paragraph' => ["Another paragraph.\n\n**origin**\n\noriginal\n", 'definition.md: SPEC-001 expects a bold field heading after its statement.'],
129 'duplicate field' => ["**origin**\n\noriginal\n\n**origin**\n\noriginal\n", "definition.md: SPEC-001 has duplicate field 'origin'."],
130 'duplicate reason spelling' => ["**rationale**\n\nWhy.\n\n**reason**\n\nWhy.\n", "definition.md: SPEC-001 has duplicate field 'reason'."],
131 'unknown field' => ["**lables**\n\n- grammar\n", "definition.md: SPEC-001.lables: Unknown Markdown field 'lables'."],
132 'empty last field' => ["**origin**\n\noriginal\n\n**reason**\n", "definition.md: SPEC-001.reason: Markdown field 'reason' needs a value."],
133 'empty field before another' => ["**origin**\n\n**reason**\n\nWhy.\n", "definition.md: SPEC-001.origin: Markdown field 'origin' needs a value."],
134 ];
135 }
136
137 /**
138 * @throws CommonMarkException
139 */
140 public function testAssignReadsOneFieldIntoTheItem(): void
141 {
142 $item = new stdClass();
143 $presentation = new Presentation();
144 (new FieldSections($presentation))->assign($item, 'requirements', MarkdownNodes::blocks('- [REQ-001](#req-001)'), 'definition.md', 'SPEC-001');
145 self::assertSame(['REQ-001'], $item->requirements);
146 self::assertSame(['SPEC-001' => ['requirements' => ['REQ-001' => '#req-001']]], $presentation->links);
147 self::assertEquals([new Reference('REQ-001', '#req-001', 'definition.md')], $presentation->references);
148 }
149
150 /**
151 * @throws CommonMarkException
152 */
153 public function testAssignRejectsAFieldTheItemAlreadyHas(): void
154 {
155 $this->expectException(InvalidInputException::class);
156 $this->expectExceptionMessage("definition.md: SPEC-001 has duplicate field 'category'.");
157 (new FieldSections(new Presentation()))->assign((object) ['category' => 'grammar'], 'category', MarkdownNodes::blocks('lexical'), 'definition.md', 'SPEC-001');
158 }
159
160 public function testAssignKeepsTheCauseOfAMalformedValue(): void
161 {
162 $error = null;
163 try {
164 (new FieldSections(new Presentation()))->assign(new stdClass(), 'origin', [], 'definition.md', 'SPEC-001');
165 } catch (InvalidInputException $caught) {
166 $error = $caught;
167 }
168 self::assertInstanceOf(InvalidInputException::class, $error);
169 self::assertInstanceOf(InvalidInputException::class, $error->getPrevious());
170 self::assertSame("Markdown field 'origin' needs a value.", $error->getPrevious()->getMessage());
171 }
172}
173