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![specification](https://img.shields.io/badge/kind-specification-blue)
69![unsupported](https://img.shields.io/badge/status-unsupported-orange)
70![lexical](https://img.shields.io/badge/category-lexical-blue)
71![grammar](https://img.shields.io/badge/label-grammar-blue)
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![original](https://img.shields.io/badge/origin-original-blue)
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![lexical](../assets/lexical.svg "category")
143![grammar](../assets/grammar.svg)
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 &gt; 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