packages/requirements/tests/Unit/Config/Markdown/Render/CardWriterTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Config\Markdown\Render;
6
7use JsonException;
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\DocumentReader;
14use Requirements\Config\Markdown\Badges;
15use Requirements\Config\Markdown\Citation;
16use Requirements\Config\Markdown\LocalPath;
17use Requirements\Config\Markdown\Nodes;
18use Requirements\Config\Markdown\Quotation;
19use Requirements\Config\Markdown\Render\CardWriter;
20use Requirements\Config\Markdown\Render\FieldWriter;
21use Requirements\Config\Markdown\Render\MetadataWriter;
22use Requirements\Config\Markdown\Render\Record;
23use Requirements\Input\Fields;
24use Requirements\Input\InvalidInputException;
25use Requirements\Model\Source;
26use Requirements\Source\TextFragment;
27use Requirements\Source\Unit;
28use stdClass;
29
30#[CoversClass(CardWriter::class)]
31#[UsesClass(Badges::class)]
32#[UsesClass(Citation::class)]
33#[UsesClass(DocumentReader::class)]
34#[UsesClass(Fields::class)]
35#[UsesClass(FieldWriter::class)]
36#[UsesClass(LocalPath::class)]
37#[UsesClass(MetadataWriter::class)]
38#[UsesClass(Nodes::class)]
39#[UsesClass(Quotation::class)]
40#[UsesClass(Record::class)]
41#[UsesClass(Source::class)]
42#[UsesClass(TextFragment::class)]
43#[UsesClass(Unit::class)]
44#[Small]
45final class CardWriterTest extends TestCase
46{
47 /**
48 * @throws JsonException
49 */
50 public function testRenderWritesEveryFieldInTheCanonicalOrder(): void
51 {
52 $data = (object) ['version' => 1, 'source' => (object) ['id' => 'manual', 'uri' => 'source.html', 'format' => 'html', 'selector' => 'main p'], 'items' => [
53 (object) ['id' => 'SPEC-001', 'statement' => 'The reader shall emit a tree.', 'status' => 'unsupported', 'reason' => 'Why.', 'kind' => 'specification', 'labels' => ['grammar'], 'category' => 'lexical', 'evidence' => [(object) ['selector' => '#a', 'quote' => 'Names shall start with a letter.']], 'requirements' => ['REQ-001'], 'design' => [(object) ['text' => 'Keep spelling.']], 'tests' => [], 'related' => ['SPEC-003'], 'metadata' => (object) ['owner' => 'Parser team']],
54 (object) ['id' => 'SPEC-002', 'statement' => '1. The reader', 'origin' => 'original', 'reason' => 'Why.', 'labels' => [], 'evidence' => []],
55 ]];
56 $expected = <<<'MD'
57---
58version: 1
59source:
60 id: manual
61 uri: source.html
62 format: html
63 selector: 'main p'
64---
65
66# SPEC-001
67
68
69
70
71
72
73The reader shall emit a tree.
74
75> <!-- selector: #a -->
76> Names shall start with a letter.
77
78**unsupported reason**
79
80Why.
81
82**requirements**
83
84- [REQ-001](#req-001)
85
86**tests**
87
88None.
89
90**related**
91
92- [SPEC-003](#spec-003)
93
94**design**
95
96- Keep spelling.
97
98**metadata**
99
100- **owner:** Parser team
101
102# SPEC-002
103
104
105
1061\. The reader
107
108**rationale**
109
110Why.
111
112**labels**
113
114None.
115
116**evidence**
117
118None.
119
120MD;
121 self::assertSame($expected, (new CardWriter())->render($data, [], []));
122 self::assertTrue(property_exists($data, 'items'));
123 }
124
125 /**
126 * @throws JsonException
127 */
128 public function testRenderReusesTheLinksBadgesAndCitationsRead(): void
129 {
130 $data = (object) ['version' => 1, 'items' => [(object) ['id' => 'SPEC-001', 'statement' => 'The reader shall emit a tree.', 'category' => 'lexical', 'labels' => ['grammar'], 'evidence' => [(object) ['selector' => '#a', 'quote' => 'Names shall start with a letter.'], (object) ['selector' => 'main > p', 'quote' => 'Names may contain digits.']], 'requirements' => ['REQ-001']]]];
131 $links = ['SPEC-001' => ['requirements' => ['REQ-001' => 'reference.yaml#req-001']]];
132 $badges = ['SPEC-001' => ['category' => ['lexical' => ['url' => '../assets/lexical.svg', 'title' => 'category']], 'label' => ['grammar' => ['url' => '../assets/grammar.svg', 'title' => null]]]];
133 $citations = ['SPEC-001' => [['url' => '../source.html#a', 'label' => 'Names']]];
134 $source = new Citation(new Source('manual', 'source.html', 'html', 'main p'), '/project/definitions/names.md', '/project');
135 $expected = <<<'MD'
136---
137version: 1
138---
139
140# SPEC-001
141
142
143
144
145The reader shall emit a tree.
146
147> Names shall start with a letter.
148
149[Names](../source.html#a)
150
151> <!-- selector: main > p -->
152> Names may contain digits.
153
154[Source](../source.html#:~:text=Names%20may%20contain%20digits.)
155
156**requirements**
157
158- [REQ-001](reference.yaml#req-001)
159
160MD;
161 self::assertSame($expected, (new CardWriter())->render($data, $links, $badges, $citations, $source));
162 }
163
164 /**
165 * @throws JsonException
166 */
167 public function testRenderWritesADefinitionWithoutItems(): void
168 {
169 self::assertSame("---\nversion: 1\nsource: null\n---\n", (new CardWriter())->render((object) ['version' => 1, 'source' => null, 'items' => []], [], []));
170 }
171
172 /**
173 * @throws JsonException
174 */
175 public function testRenderEscapesTheStatement(): void
176 {
177 $data = (object) ['version' => 1, 'items' => [(object) ['id' => 'SPEC-001', 'statement' => 'The reader shall preserve `shall` literally.']]];
178 self::assertSame("---\nversion: 1\n---\n\n# SPEC-001\n\nThe reader shall preserve \\`shall\\` literally.\n", (new CardWriter())->render($data, [], []));
179 }
180
181 /**
182 * @throws JsonException
183 */
184 #[DataProvider('providerMalformedDefinitions')]
185 public function testRenderRejectsRecordsThatCannotBeCards(stdClass $data, string $message): void
186 {
187 $this->expectException(InvalidInputException::class);
188 $this->expectExceptionMessage($message);
189 (new CardWriter())->render($data, [], []);
190 }
191
192 /**
193 * @return array<string, array{stdClass, string}>
194 */
195 public static function providerMalformedDefinitions(): array
196 {
197 return [
198 'items not a list' => [(object) ['version' => 1, 'items' => (object) []], 'items must be a list.'],
199 'item not a record' => [(object) ['version' => 1, 'items' => ['SPEC-001']], 'Expected a Markdown record.'],
200 'item without ID' => [(object) ['version' => 1, 'items' => [(object) ['statement' => 'The reader shall emit a tree.']]], 'id must be a nonempty string.'],
201 'item without statement' => [(object) ['version' => 1, 'items' => [(object) ['id' => 'SPEC-001']]], 'statement must be a nonempty string.'],
202 'labels not strings' => [(object) ['version' => 1, 'items' => [(object) ['id' => 'SPEC-001', 'statement' => 'S.', 'labels' => [1]]]], 'labels must contain nonempty strings.'],
203 'evidence not a record' => [(object) ['version' => 1, 'items' => [(object) ['id' => 'SPEC-001', 'statement' => 'S.', 'evidence' => ['#a']]]], 'Expected a Markdown record.'],
204 'kind not a string' => [(object) ['version' => 1, 'items' => [(object) ['id' => 'SPEC-001', 'statement' => 'S.', 'kind' => 1]]], 'kind must be a nonempty string.'],
205 ];
206 }
207}
208