Parse and validate AT Protocol Lexicons with DTO generation for Laravel
1<?php
2
3namespace SocialDept\AtpSchema\Tests\Unit\Generator;
4
5use Orchestra\Testbench\TestCase;
6use SocialDept\AtpSchema\Data\LexiconDocument;
7use SocialDept\AtpSchema\Generator\DocBlockGenerator;
8use SocialDept\AtpSchema\Parser\Nsid;
9
10class DocBlockGeneratorTest extends TestCase
11{
12 protected DocBlockGenerator $generator;
13
14 protected function setUp(): void
15 {
16 parent::setUp();
17
18 $this->generator = new DocBlockGenerator();
19 }
20
21 public function test_it_generates_class_docblock_with_description(): void
22 {
23 $document = $this->createDocument('app.test.post', [
24 'type' => 'record',
25 'description' => 'A social media post',
26 'properties' => [],
27 ], 'A social media post');
28
29 $docBlock = $this->generator->generateClassDocBlock($document, $document->getMainDefinition());
30
31 $this->assertStringContainsString('/**', $docBlock);
32 $this->assertStringContainsString('* A social media post', $docBlock);
33 $this->assertStringContainsString('* Lexicon: app.test.post', $docBlock);
34 $this->assertStringContainsString('* Type: record', $docBlock);
35 }
36
37 public function test_it_generates_class_docblock_with_property_tags(): void
38 {
39 $document = $this->createDocument('app.test.user', [
40 'type' => 'record',
41 'properties' => [
42 'name' => [
43 'type' => 'string',
44 'description' => 'User name',
45 ],
46 'age' => [
47 'type' => 'integer',
48 ],
49 ],
50 'required' => ['name'],
51 ]);
52
53 $docBlock = $this->generator->generateClassDocBlock($document, $document->getMainDefinition());
54
55 $this->assertStringContainsString('@property string $name User name', $docBlock);
56 $this->assertStringContainsString('@property int|null $age', $docBlock);
57 }
58
59 public function test_it_includes_validation_constraints_in_class_docblock(): void
60 {
61 $document = $this->createDocument('app.test.post', [
62 'type' => 'record',
63 'properties' => [
64 'text' => [
65 'type' => 'string',
66 'maxLength' => 280,
67 ],
68 ],
69 'required' => ['text'],
70 ]);
71
72 $docBlock = $this->generator->generateClassDocBlock($document, $document->getMainDefinition());
73
74 $this->assertStringContainsString('Constraints:', $docBlock);
75 $this->assertStringContainsString('Required: text', $docBlock);
76 $this->assertStringContainsString('text: Max length: 280', $docBlock);
77 }
78
79 public function test_it_generates_property_docblock(): void
80 {
81 $docBlock = $this->generator->generatePropertyDocBlock(
82 'title',
83 [
84 'type' => 'string',
85 'description' => 'The post title',
86 ],
87 true
88 );
89
90 $this->assertStringContainsString('/**', $docBlock);
91 $this->assertStringContainsString('* The post title', $docBlock);
92 $this->assertStringContainsString('* @var string', $docBlock);
93 $this->assertStringContainsString('*/', $docBlock);
94 }
95
96 public function test_it_includes_constraints_in_property_docblock(): void
97 {
98 $docBlock = $this->generator->generatePropertyDocBlock(
99 'text',
100 [
101 'type' => 'string',
102 'maxLength' => 280,
103 'minLength' => 1,
104 ],
105 true
106 );
107
108 $this->assertStringContainsString('@constraint Max length: 280', $docBlock);
109 $this->assertStringContainsString('@constraint Min length: 1', $docBlock);
110 }
111
112 public function test_it_generates_method_docblock(): void
113 {
114 $docBlock = $this->generator->generateMethodDocBlock(
115 'Create a new post',
116 'static',
117 [
118 ['name' => 'text', 'type' => 'string', 'description' => 'Post text'],
119 ['name' => 'author', 'type' => 'string'],
120 ]
121 );
122
123 $this->assertStringContainsString('* Create a new post', $docBlock);
124 $this->assertStringContainsString('* @param string $text Post text', $docBlock);
125 $this->assertStringContainsString('* @param string $author', $docBlock);
126 $this->assertStringContainsString('* @return static', $docBlock);
127 }
128
129 public function test_it_handles_void_return_type(): void
130 {
131 $docBlock = $this->generator->generateMethodDocBlock(
132 'Process data',
133 'void',
134 []
135 );
136
137 $this->assertStringNotContainsString('@return', $docBlock);
138 }
139
140 public function test_it_includes_throws_annotation(): void
141 {
142 $docBlock = $this->generator->generateMethodDocBlock(
143 'Validate data',
144 'bool',
145 [],
146 '\\InvalidArgumentException'
147 );
148
149 $this->assertStringContainsString('@throws \\InvalidArgumentException', $docBlock);
150 }
151
152 public function test_it_wraps_long_descriptions(): void
153 {
154 $longDescription = 'This is a very long description that should be wrapped across multiple lines when it exceeds the maximum line width of eighty characters including the comment prefix and should definitely span more than one line';
155
156 $docBlock = $this->generator->generatePropertyDocBlock(
157 'description',
158 [
159 'type' => 'string',
160 'description' => $longDescription,
161 ],
162 true
163 );
164
165 // Just verify the long description is present in the docblock
166 $this->assertStringContainsString('This is a very long description', $docBlock);
167
168 // And that it doesn't exceed reasonable line lengths
169 $lines = explode("\n", $docBlock);
170 foreach ($lines as $line) {
171 $this->assertLessThan(120, strlen($line), 'Line too long: '.$line);
172 }
173 }
174
175 public function test_it_extracts_max_length_constraint(): void
176 {
177 $constraints = $this->invokeMethod('extractPropertyConstraints', [
178 ['maxLength' => 100],
179 ]);
180
181 $this->assertContains('@constraint Max length: 100', $constraints);
182 }
183
184 public function test_it_extracts_min_length_constraint(): void
185 {
186 $constraints = $this->invokeMethod('extractPropertyConstraints', [
187 ['minLength' => 5],
188 ]);
189
190 $this->assertContains('@constraint Min length: 5', $constraints);
191 }
192
193 public function test_it_extracts_grapheme_constraints(): void
194 {
195 $constraints = $this->invokeMethod('extractPropertyConstraints', [
196 ['maxGraphemes' => 280, 'minGraphemes' => 1],
197 ]);
198
199 $this->assertContains('@constraint Max graphemes: 280', $constraints);
200 $this->assertContains('@constraint Min graphemes: 1', $constraints);
201 }
202
203 public function test_it_extracts_number_constraints(): void
204 {
205 $constraints = $this->invokeMethod('extractPropertyConstraints', [
206 ['maximum' => 100, 'minimum' => 0],
207 ]);
208
209 $this->assertContains('@constraint Maximum: 100', $constraints);
210 $this->assertContains('@constraint Minimum: 0', $constraints);
211 }
212
213 public function test_it_extracts_array_constraints(): void
214 {
215 $constraints = $this->invokeMethod('extractPropertyConstraints', [
216 ['maxItems' => 10, 'minItems' => 1],
217 ]);
218
219 $this->assertContains('@constraint Max items: 10', $constraints);
220 $this->assertContains('@constraint Min items: 1', $constraints);
221 }
222
223 public function test_it_extracts_enum_constraint(): void
224 {
225 $constraints = $this->invokeMethod('extractPropertyConstraints', [
226 ['enum' => ['active', 'inactive', 'pending']],
227 ]);
228
229 $this->assertContains('@constraint Enum: active, inactive, pending', $constraints);
230 }
231
232 public function test_it_extracts_format_constraint(): void
233 {
234 $constraints = $this->invokeMethod('extractPropertyConstraints', [
235 ['format' => 'datetime'],
236 ]);
237
238 $this->assertContains('@constraint Format: datetime', $constraints);
239 }
240
241 public function test_it_extracts_const_constraint(): void
242 {
243 $constraints = $this->invokeMethod('extractPropertyConstraints', [
244 ['const' => true],
245 ]);
246
247 $this->assertContains('@constraint Const: true', $constraints);
248 }
249
250 public function test_it_generates_simple_docblock(): void
251 {
252 $docBlock = $this->generator->generateSimple('A simple description');
253
254 $this->assertSame(" /**\n * A simple description\n */", $docBlock);
255 }
256
257 public function test_it_generates_one_line_docblock(): void
258 {
259 $docBlock = $this->generator->generateOneLine('Quick note');
260
261 $this->assertSame(' /** Quick note */', $docBlock);
262 }
263
264 public function test_it_handles_empty_properties(): void
265 {
266 $document = $this->createDocument('app.test.empty', [
267 'type' => 'record',
268 'properties' => [],
269 'required' => [],
270 ]);
271
272 $docBlock = $this->generator->generateClassDocBlock($document, $document->getMainDefinition());
273
274 $this->assertStringContainsString('Lexicon: app.test.empty', $docBlock);
275 $this->assertStringNotContainsString('@property', $docBlock);
276 }
277
278 public function test_it_handles_nullable_properties(): void
279 {
280 $document = $this->createDocument('app.test.post', [
281 'type' => 'record',
282 'properties' => [
283 'subtitle' => ['type' => 'string'],
284 ],
285 'required' => [],
286 ]);
287
288 $docBlock = $this->generator->generateClassDocBlock($document, $document->getMainDefinition());
289
290 $this->assertStringContainsString('@property string|null $subtitle', $docBlock);
291 }
292
293 /**
294 * Helper to create a test document.
295 *
296 * @param array<string, mixed> $mainDef
297 */
298 protected function createDocument(string $nsid, array $mainDef, ?string $description = null): LexiconDocument
299 {
300 return new LexiconDocument(
301 lexicon: 1,
302 id: Nsid::parse($nsid),
303 defs: ['main' => $mainDef],
304 description: $description,
305 source: null,
306 raw: [
307 'lexicon' => 1,
308 'id' => $nsid,
309 'defs' => ['main' => $mainDef],
310 ]
311 );
312 }
313
314 /**
315 * Helper to invoke protected method.
316 *
317 * @param array<mixed> $args
318 * @return mixed
319 */
320 protected function invokeMethod(string $methodName, array $args)
321 {
322 $reflection = new \ReflectionClass($this->generator);
323 $method = $reflection->getMethod($methodName);
324 $method->setAccessible(true);
325
326 return $method->invokeArgs($this->generator, $args);
327 }
328}