Parse and validate AT Protocol Lexicons with DTO generation for Laravel
at dev 316 lines 11 kB view raw
1<?php 2 3namespace SocialDept\AtpSchema\Tests\Unit\Generator; 4 5use Orchestra\Testbench\TestCase; 6use SocialDept\AtpSchema\Data\LexiconDocument; 7use SocialDept\AtpSchema\Exceptions\GenerationException; 8use SocialDept\AtpSchema\Generator\ClassGenerator; 9use SocialDept\AtpSchema\Parser\Nsid; 10 11class ClassGeneratorTest extends TestCase 12{ 13 protected ClassGenerator $generator; 14 15 protected function setUp(): void 16 { 17 parent::setUp(); 18 19 $this->generator = new ClassGenerator(); 20 } 21 22 public function test_it_generates_simple_record_class(): void 23 { 24 $document = $this->createDocument('app.test.post', [ 25 'type' => 'record', 26 'properties' => [ 27 'text' => ['type' => 'string'], 28 'createdAt' => ['type' => 'string'], 29 ], 30 'required' => ['text', 'createdAt'], 31 ]); 32 33 $code = $this->generator->generate($document); 34 35 $this->assertStringContainsString('namespace App\\Lexicons\\App\\Test;', $code); 36 $this->assertStringContainsString('class Post extends Data', $code); 37 $this->assertStringContainsString('public static function getLexicon(): string', $code); 38 $this->assertStringContainsString("return 'app.test.post';", $code); 39 } 40 41 public function test_it_handles_optional_properties(): void 42 { 43 $document = $this->createDocument('app.test.post', [ 44 'type' => 'record', 45 'properties' => [ 46 'title' => ['type' => 'string'], 47 'subtitle' => ['type' => 'string'], 48 ], 49 'required' => ['title'], 50 ]); 51 52 $code = $this->generator->generate($document); 53 54 $this->assertStringContainsString('@property string $title', $code); 55 $this->assertStringContainsString('@property string|null $subtitle', $code); 56 $this->assertStringContainsString('class Post extends Data', $code); 57 } 58 59 public function test_it_generates_constructor_with_parameters(): void 60 { 61 $document = $this->createDocument('app.test.user', [ 62 'type' => 'record', 63 'properties' => [ 64 'name' => ['type' => 'string'], 65 'age' => ['type' => 'integer'], 66 ], 67 'required' => ['name'], 68 ]); 69 70 $code = $this->generator->generate($document); 71 72 $this->assertStringContainsString('@property string $name', $code); 73 $this->assertStringContainsString('@property int|null $age', $code); 74 $this->assertStringContainsString('class User extends Data', $code); 75 } 76 77 public function test_it_generates_from_array_method(): void 78 { 79 $document = $this->createDocument('app.test.post', [ 80 'type' => 'record', 81 'record' => [ 82 'properties' => [ 83 'text' => ['type' => 'string'], 84 ], 85 'required' => ['text'], 86 ], 87 ]); 88 89 $code = $this->generator->generate($document); 90 91 $this->assertStringContainsString('public static function fromArray(array $data): static', $code); 92 $this->assertStringContainsString('return new static(', $code); 93 $this->assertStringContainsString("text: \$data['text']", $code); 94 } 95 96 public function test_it_includes_use_statements(): void 97 { 98 $document = $this->createDocument('app.test.post', [ 99 'type' => 'record', 100 'properties' => [ 101 'text' => ['type' => 'string'], 102 ], 103 'required' => ['text'], 104 ]); 105 106 $code = $this->generator->generate($document); 107 108 $this->assertStringContainsString('use SocialDept\\AtpSchema\\Data\\Data;', $code); 109 } 110 111 public function test_it_includes_ref_use_statements(): void 112 { 113 $document = $this->createDocument('app.test.post', [ 114 'type' => 'record', 115 'properties' => [ 116 'author' => [ 117 'type' => 'ref', 118 'ref' => 'app.test.author', 119 ], 120 ], 121 'required' => ['author'], 122 ]); 123 124 $code = $this->generator->generate($document); 125 126 $this->assertStringContainsString('class Post extends Data', $code); 127 $this->assertStringContainsString('public static function fromArray(array $data): static', $code); 128 } 129 130 public function test_it_includes_blob_use_statements(): void 131 { 132 $document = $this->createDocument('app.test.post', [ 133 'type' => 'record', 134 'properties' => [ 135 'image' => ['type' => 'blob'], 136 ], 137 'required' => [], 138 ]); 139 140 $code = $this->generator->generate($document); 141 142 $this->assertStringContainsString('@property', $code); 143 $this->assertStringContainsString('class Post extends Data', $code); 144 } 145 146 public function test_it_generates_class_docblock(): void 147 { 148 $document = $this->createDocument('app.test.post', [ 149 'type' => 'record', 150 'description' => 'A social media post', 151 'properties' => ['text' => ['type' => 'string']], 152 'required' => ['text'], 153 ], 'A social media post'); 154 155 $code = $this->generator->generate($document); 156 157 $this->assertStringContainsString('/**', $code); 158 $this->assertStringContainsString('* A social media post', $code); 159 $this->assertStringContainsString('* Lexicon: app.test.post', $code); 160 $this->assertStringContainsString('* Type: record', $code); 161 } 162 163 public function test_it_generates_property_docblocks(): void 164 { 165 $document = $this->createDocument('app.test.post', [ 166 'type' => 'record', 167 'properties' => [ 168 'text' => [ 169 'type' => 'string', 170 'description' => 'The post content', 171 ], 172 ], 173 'required' => ['text'], 174 ]); 175 176 $code = $this->generator->generate($document); 177 178 $this->assertStringContainsString('@property string $text', $code); 179 } 180 181 public function test_it_throws_when_no_main_definition(): void 182 { 183 $document = new LexiconDocument( 184 lexicon: 1, 185 id: Nsid::parse('app.test.empty'), 186 defs: [], 187 description: null, 188 source: null, 189 raw: [] 190 ); 191 192 $this->expectException(GenerationException::class); 193 $this->expectExceptionMessage('No main definition found'); 194 195 $this->generator->generate($document); 196 } 197 198 public function test_it_throws_for_non_record_types(): void 199 { 200 $document = $this->createDocument('app.test.query', [ 201 'type' => 'query', 202 ]); 203 204 $this->expectException(GenerationException::class); 205 $this->expectExceptionMessage('Can only generate classes for record and object types'); 206 207 $this->generator->generate($document); 208 } 209 210 public function test_it_handles_empty_properties(): void 211 { 212 $document = $this->createDocument('app.test.empty', [ 213 'type' => 'record', 214 'properties' => [], 215 'required' => [], 216 ]); 217 218 $code = $this->generator->generate($document); 219 220 $this->assertStringContainsString('class Empty', $code); 221 $this->assertStringContainsString('public static function fromArray(array $data): static', $code); 222 $this->assertStringContainsString('return new static();', $code); 223 } 224 225 public function test_it_handles_array_of_refs(): void 226 { 227 $document = $this->createDocument('app.test.feed', [ 228 'type' => 'record', 229 'properties' => [ 230 'posts' => [ 231 'type' => 'array', 232 'items' => [ 233 'type' => 'ref', 234 'ref' => 'app.test.post', 235 ], 236 ], 237 ], 238 'required' => ['posts'], 239 ]); 240 241 $code = $this->generator->generate($document); 242 243 $this->assertStringContainsString('class Feed extends Data', $code); 244 $this->assertStringContainsString('public static function fromArray(array $data): static', $code); 245 } 246 247 public function test_it_generates_object_type(): void 248 { 249 $document = $this->createDocument('app.test.config', [ 250 'type' => 'object', 251 'properties' => [ 252 'enabled' => ['type' => 'boolean'], 253 ], 254 'required' => ['enabled'], 255 ]); 256 257 $code = $this->generator->generate($document); 258 259 $this->assertStringContainsString('class Config', $code); 260 $this->assertStringContainsString('public readonly bool $enabled', $code); 261 } 262 263 public function test_it_sorts_use_statements(): void 264 { 265 $document = $this->createDocument('app.test.complex', [ 266 'type' => 'record', 267 'properties' => [ 268 'image' => ['type' => 'blob'], 269 'author' => [ 270 'type' => 'ref', 271 'ref' => 'app.test.author', 272 ], 273 ], 274 'required' => [], 275 ]); 276 277 $code = $this->generator->generate($document); 278 279 $basePos = strpos($code, 'use SocialDept\\AtpSchema\\Data\\Data;'); 280 281 $this->assertNotFalse($basePos); 282 $this->assertStringContainsString('class Complex extends Data', $code); 283 } 284 285 public function test_it_provides_accessor_methods(): void 286 { 287 $naming = $this->generator->getNaming(); 288 $typeMapper = $this->generator->getTypeMapper(); 289 $renderer = $this->generator->getRenderer(); 290 291 $this->assertInstanceOf(\SocialDept\AtpSchema\Generator\NamingConverter::class, $naming); 292 $this->assertInstanceOf(\SocialDept\AtpSchema\Generator\TypeMapper::class, $typeMapper); 293 $this->assertInstanceOf(\SocialDept\AtpSchema\Generator\StubRenderer::class, $renderer); 294 } 295 296 /** 297 * Helper to create a test document. 298 * 299 * @param array<string, mixed> $mainDef 300 */ 301 protected function createDocument(string $nsid, array $mainDef, ?string $description = null): LexiconDocument 302 { 303 return new LexiconDocument( 304 lexicon: 1, 305 id: Nsid::parse($nsid), 306 defs: ['main' => $mainDef], 307 description: $description, 308 source: null, 309 raw: [ 310 'lexicon' => 1, 311 'id' => $nsid, 312 'defs' => ['main' => $mainDef], 313 ] 314 ); 315 } 316}