Parse and validate AT Protocol Lexicons with DTO generation for Laravel
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement reference resolution and schema validation

+785
+13
src/Parser/ComplexTypeParser.php
··· 18 18 protected PrimitiveParser $primitiveParser; 19 19 20 20 /** 21 + * Type parser for resolving references. 22 + */ 23 + protected ?object $typeParser = null; 24 + 25 + /** 21 26 * Create a new ComplexTypeParser. 22 27 */ 23 28 public function __construct(?PrimitiveParser $primitiveParser = null) 24 29 { 25 30 $this->primitiveParser = $primitiveParser ?? new PrimitiveParser(); 31 + } 32 + 33 + /** 34 + * Set the type parser for reference resolution. 35 + */ 36 + public function setTypeParser(object $typeParser): void 37 + { 38 + $this->typeParser = $typeParser; 26 39 } 27 40 28 41 /**
+186
src/Parser/TypeParser.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Parser; 4 + 5 + use SocialDept\Schema\Data\LexiconDocument; 6 + use SocialDept\Schema\Data\TypeDefinition; 7 + use SocialDept\Schema\Exceptions\TypeResolutionException; 8 + 9 + class TypeParser 10 + { 11 + /** 12 + * Primitive type parser. 13 + */ 14 + protected PrimitiveParser $primitiveParser; 15 + 16 + /** 17 + * Complex type parser. 18 + */ 19 + protected ComplexTypeParser $complexParser; 20 + 21 + /** 22 + * Schema loader for resolving external references. 23 + */ 24 + protected ?SchemaLoader $schemaLoader; 25 + 26 + /** 27 + * Cache of resolved types to prevent infinite loops. 28 + * 29 + * @var array<string, TypeDefinition> 30 + */ 31 + protected array $resolvedTypes = []; 32 + 33 + /** 34 + * Current resolution chain to detect circular references. 35 + * 36 + * @var array<string> 37 + */ 38 + protected array $resolutionChain = []; 39 + 40 + /** 41 + * Create a new TypeParser. 42 + */ 43 + public function __construct( 44 + ?PrimitiveParser $primitiveParser = null, 45 + ?ComplexTypeParser $complexParser = null, 46 + ?SchemaLoader $schemaLoader = null 47 + ) { 48 + $this->primitiveParser = $primitiveParser ?? new PrimitiveParser(); 49 + $this->complexParser = $complexParser ?? new ComplexTypeParser($this->primitiveParser); 50 + $this->schemaLoader = $schemaLoader; 51 + } 52 + 53 + /** 54 + * Parse a type definition from array data. 55 + * 56 + * @throws TypeResolutionException 57 + */ 58 + public function parse(array $data, ?LexiconDocument $context = null): TypeDefinition 59 + { 60 + $type = $data['type'] ?? null; 61 + 62 + if ($type === null) { 63 + throw TypeResolutionException::unknownType('(missing type field)'); 64 + } 65 + 66 + // Handle primitive types 67 + if ($this->primitiveParser->isPrimitive($type)) { 68 + return $this->primitiveParser->parse($data); 69 + } 70 + 71 + // Handle complex types 72 + if ($this->complexParser->isComplex($type)) { 73 + return $this->complexParser->parse($data); 74 + } 75 + 76 + throw TypeResolutionException::unknownType($type); 77 + } 78 + 79 + /** 80 + * Resolve a reference to its actual type definition. 81 + * 82 + * @throws TypeResolutionException 83 + */ 84 + public function resolveReference(string $ref, LexiconDocument $context): TypeDefinition 85 + { 86 + // Check if already resolved 87 + $cacheKey = $context->getNsid().':'.$ref; 88 + 89 + if (isset($this->resolvedTypes[$cacheKey])) { 90 + return $this->resolvedTypes[$cacheKey]; 91 + } 92 + 93 + // Check for circular reference 94 + if (in_array($cacheKey, $this->resolutionChain)) { 95 + throw TypeResolutionException::circularReference($ref, $this->resolutionChain); 96 + } 97 + 98 + $this->resolutionChain[] = $cacheKey; 99 + 100 + try { 101 + $type = $this->resolveReferenceInternal($ref, $context); 102 + $this->resolvedTypes[$cacheKey] = $type; 103 + 104 + return $type; 105 + } finally { 106 + array_pop($this->resolutionChain); 107 + } 108 + } 109 + 110 + /** 111 + * Internal reference resolution logic. 112 + * 113 + * @throws TypeResolutionException 114 + */ 115 + protected function resolveReferenceInternal(string $ref, LexiconDocument $context): TypeDefinition 116 + { 117 + // Local reference (#defName) 118 + if (str_starts_with($ref, '#')) { 119 + $defName = substr($ref, 1); 120 + 121 + if (! $context->hasDefinition($defName)) { 122 + throw TypeResolutionException::unresolvableReference($ref, $context->getNsid()); 123 + } 124 + 125 + $defData = $context->getDefinition($defName); 126 + 127 + return $this->parse($defData, $context); 128 + } 129 + 130 + // External reference (nsid#defName or just nsid for #main) 131 + if ($this->schemaLoader === null) { 132 + throw new \RuntimeException('Cannot resolve external reference without SchemaLoader'); 133 + } 134 + 135 + [$nsid, $defName] = $this->parseExternalReference($ref); 136 + 137 + // Load external schema 138 + $externalSchema = $this->schemaLoader->load($nsid); 139 + $externalDoc = LexiconDocument::fromArray($externalSchema); 140 + 141 + // Get the definition 142 + if (! $externalDoc->hasDefinition($defName)) { 143 + throw TypeResolutionException::unresolvableReference($ref, $context->getNsid()); 144 + } 145 + 146 + $defData = $externalDoc->getDefinition($defName); 147 + 148 + return $this->parse($defData, $externalDoc); 149 + } 150 + 151 + /** 152 + * Parse an external reference into NSID and definition name. 153 + * 154 + * @return array{0: string, 1: string} 155 + */ 156 + protected function parseExternalReference(string $ref): array 157 + { 158 + if (str_contains($ref, '#')) { 159 + [$nsid, $defName] = explode('#', $ref, 2); 160 + 161 + return [$nsid, $defName]; 162 + } 163 + 164 + // If no # is present, default to 'main' 165 + return [$ref, 'main']; 166 + } 167 + 168 + /** 169 + * Clear the resolution cache. 170 + */ 171 + public function clearCache(): void 172 + { 173 + $this->resolvedTypes = []; 174 + $this->resolutionChain = []; 175 + } 176 + 177 + /** 178 + * Get the resolved types cache. 179 + * 180 + * @return array<string, TypeDefinition> 181 + */ 182 + public function getResolvedTypes(): array 183 + { 184 + return $this->resolvedTypes; 185 + } 186 + }
+249
src/Validation/LexiconValidator.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Validation; 4 + 5 + use SocialDept\Schema\Contracts\LexiconValidator as LexiconValidatorContract; 6 + use SocialDept\Schema\Data\LexiconDocument; 7 + use SocialDept\Schema\Exceptions\RecordValidationException; 8 + use SocialDept\Schema\Exceptions\SchemaValidationException; 9 + use SocialDept\Schema\Parser\SchemaLoader; 10 + use SocialDept\Schema\Parser\TypeParser; 11 + 12 + class LexiconValidator implements LexiconValidatorContract 13 + { 14 + /** 15 + * Schema loader for loading lexicon documents. 16 + */ 17 + protected SchemaLoader $schemaLoader; 18 + 19 + /** 20 + * Type parser for parsing and resolving types. 21 + */ 22 + protected TypeParser $typeParser; 23 + 24 + /** 25 + * Validation mode. 26 + */ 27 + protected string $mode = 'strict'; 28 + 29 + /** 30 + * Create a new LexiconValidator. 31 + */ 32 + public function __construct( 33 + SchemaLoader $schemaLoader, 34 + ?TypeParser $typeParser = null 35 + ) { 36 + $this->schemaLoader = $schemaLoader; 37 + $this->typeParser = $typeParser ?? new TypeParser(schemaLoader: $schemaLoader); 38 + } 39 + 40 + /** 41 + * Validate data against Lexicon schema. 42 + */ 43 + public function validate(array $data, LexiconDocument $schema): bool 44 + { 45 + try { 46 + $this->validateRecord($schema, $data); 47 + 48 + return true; 49 + } catch (RecordValidationException|SchemaValidationException) { 50 + return false; 51 + } 52 + } 53 + 54 + /** 55 + * Validate and return errors. 56 + * 57 + * @return array<string, array<string>> 58 + */ 59 + public function validateWithErrors(array $data, LexiconDocument $schema): array 60 + { 61 + try { 62 + $this->validateRecord($schema, $data); 63 + 64 + return []; 65 + } catch (RecordValidationException $e) { 66 + return ['record' => [$e->getMessage()]]; 67 + } catch (SchemaValidationException $e) { 68 + return ['schema' => [$e->getMessage()]]; 69 + } 70 + } 71 + 72 + /** 73 + * Validate a specific field. 74 + */ 75 + public function validateField(mixed $value, string $field, LexiconDocument $schema): bool 76 + { 77 + try { 78 + $mainDef = $schema->getMainDefinition(); 79 + 80 + if ($mainDef === null) { 81 + return false; 82 + } 83 + 84 + $recordSchema = $mainDef['record'] ?? null; 85 + 86 + if ($recordSchema === null || ! is_array($recordSchema)) { 87 + return false; 88 + } 89 + 90 + $properties = $recordSchema['properties'] ?? []; 91 + 92 + if (! isset($properties[$field])) { 93 + return false; 94 + } 95 + 96 + $fieldType = $this->typeParser->parse($properties[$field], $schema); 97 + $fieldType->validate($value, $field); 98 + 99 + return true; 100 + } catch (RecordValidationException) { 101 + return false; 102 + } 103 + } 104 + 105 + /** 106 + * Set validation mode (strict, optimistic, lenient). 107 + */ 108 + public function setMode(string $mode): void 109 + { 110 + $this->mode = $mode; 111 + } 112 + 113 + /** 114 + * Validate a record by NSID string. 115 + */ 116 + public function validateByNsid(string $nsid, array $record): void 117 + { 118 + $schema = $this->schemaLoader->load($nsid); 119 + $document = LexiconDocument::fromArray($schema); 120 + 121 + $this->validateRecord($document, $record); 122 + } 123 + 124 + /** 125 + * Validate a record against a lexicon document. 126 + */ 127 + public function validateRecord(LexiconDocument $document, array $record): void 128 + { 129 + if (! $document->isRecord()) { 130 + throw SchemaValidationException::invalidStructure( 131 + $document->getNsid(), 132 + ['Schema is not a record type'] 133 + ); 134 + } 135 + 136 + $mainDef = $document->getMainDefinition(); 137 + 138 + if ($mainDef === null) { 139 + throw SchemaValidationException::invalidStructure( 140 + $document->getNsid(), 141 + ['Missing main definition'] 142 + ); 143 + } 144 + 145 + // Get the record schema 146 + $recordSchema = $mainDef['record'] ?? null; 147 + 148 + if ($recordSchema === null || ! is_array($recordSchema)) { 149 + throw SchemaValidationException::invalidStructure( 150 + $document->getNsid(), 151 + ['Invalid record schema'] 152 + ); 153 + } 154 + 155 + // Parse and validate the record type 156 + $recordType = $this->typeParser->parse($recordSchema, $document); 157 + $recordType->validate($record, '$'); 158 + } 159 + 160 + /** 161 + * Validate a query against its lexicon schema. 162 + */ 163 + public function validateQuery(LexiconDocument $document, array $params): void 164 + { 165 + if (! $document->isQuery()) { 166 + throw SchemaValidationException::invalidStructure( 167 + $document->getNsid(), 168 + ['Schema is not a query type'] 169 + ); 170 + } 171 + 172 + $mainDef = $document->getMainDefinition(); 173 + 174 + if ($mainDef === null) { 175 + throw SchemaValidationException::invalidStructure( 176 + $document->getNsid(), 177 + ['Missing main definition'] 178 + ); 179 + } 180 + 181 + // Get the parameters schema 182 + $paramsSchema = $mainDef['parameters'] ?? null; 183 + 184 + if ($paramsSchema !== null && is_array($paramsSchema)) { 185 + $paramsType = $this->typeParser->parse($paramsSchema, $document); 186 + $paramsType->validate($params, '$'); 187 + } 188 + } 189 + 190 + /** 191 + * Validate a procedure against its lexicon schema. 192 + */ 193 + public function validateProcedure(LexiconDocument $document, array $input): void 194 + { 195 + if (! $document->isProcedure()) { 196 + throw SchemaValidationException::invalidStructure( 197 + $document->getNsid(), 198 + ['Schema is not a procedure type'] 199 + ); 200 + } 201 + 202 + $mainDef = $document->getMainDefinition(); 203 + 204 + if ($mainDef === null) { 205 + throw SchemaValidationException::invalidStructure( 206 + $document->getNsid(), 207 + ['Missing main definition'] 208 + ); 209 + } 210 + 211 + // Get the input schema 212 + $inputSchema = $mainDef['input'] ?? null; 213 + 214 + if ($inputSchema !== null && is_array($inputSchema)) { 215 + $inputType = $this->typeParser->parse($inputSchema, $document); 216 + $inputType->validate($input, '$'); 217 + } 218 + } 219 + 220 + /** 221 + * Check if a record is valid. 222 + */ 223 + public function isValid(string $nsid, array $record): bool 224 + { 225 + try { 226 + $this->validate($nsid, $record); 227 + 228 + return true; 229 + } catch (RecordValidationException) { 230 + return false; 231 + } 232 + } 233 + 234 + /** 235 + * Get validation errors for a record. 236 + * 237 + * @return array<string> 238 + */ 239 + public function getErrors(string $nsid, array $record): array 240 + { 241 + try { 242 + $this->validate($nsid, $record); 243 + 244 + return []; 245 + } catch (RecordValidationException $e) { 246 + return [$e->getMessage()]; 247 + } 248 + } 249 + }
+136
tests/Unit/Parser/TypeParserTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Parser; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Data\LexiconDocument; 7 + use SocialDept\Schema\Data\Types\ObjectType; 8 + use SocialDept\Schema\Data\Types\StringType; 9 + use SocialDept\Schema\Exceptions\TypeResolutionException; 10 + use SocialDept\Schema\Parser\TypeParser; 11 + 12 + class TypeParserTest extends TestCase 13 + { 14 + protected TypeParser $parser; 15 + 16 + protected function setUp(): void 17 + { 18 + parent::setUp(); 19 + 20 + $this->parser = new TypeParser(); 21 + } 22 + 23 + public function test_it_parses_primitive_types(): void 24 + { 25 + $type = $this->parser->parse(['type' => 'string']); 26 + 27 + $this->assertInstanceOf(StringType::class, $type); 28 + } 29 + 30 + public function test_it_parses_complex_types(): void 31 + { 32 + $type = $this->parser->parse(['type' => 'object']); 33 + 34 + $this->assertInstanceOf(ObjectType::class, $type); 35 + } 36 + 37 + public function test_it_throws_on_missing_type(): void 38 + { 39 + $this->expectException(TypeResolutionException::class); 40 + $this->expectExceptionMessage('Unknown Lexicon type: (missing type field)'); 41 + 42 + $this->parser->parse([]); 43 + } 44 + 45 + public function test_it_throws_on_unknown_type(): void 46 + { 47 + $this->expectException(TypeResolutionException::class); 48 + $this->expectExceptionMessage('Unknown Lexicon type: nonexistent'); 49 + 50 + $this->parser->parse(['type' => 'nonexistent']); 51 + } 52 + 53 + public function test_it_resolves_local_reference(): void 54 + { 55 + $document = LexiconDocument::fromArray([ 56 + 'lexicon' => 1, 57 + 'id' => 'com.example.test', 58 + 'defs' => [ 59 + 'main' => ['type' => 'object'], 60 + 'other' => ['type' => 'string'], 61 + ], 62 + ]); 63 + 64 + $type = $this->parser->resolveReference('#other', $document); 65 + 66 + $this->assertInstanceOf(StringType::class, $type); 67 + } 68 + 69 + public function test_it_throws_on_unresolvable_local_reference(): void 70 + { 71 + $document = LexiconDocument::fromArray([ 72 + 'lexicon' => 1, 73 + 'id' => 'com.example.test', 74 + 'defs' => [ 75 + 'main' => ['type' => 'object'], 76 + ], 77 + ]); 78 + 79 + $this->expectException(TypeResolutionException::class); 80 + $this->expectExceptionMessage('Cannot resolve reference #nonexistent in schema com.example.test'); 81 + 82 + $this->parser->resolveReference('#nonexistent', $document); 83 + } 84 + 85 + public function test_it_caches_resolved_types(): void 86 + { 87 + $document = LexiconDocument::fromArray([ 88 + 'lexicon' => 1, 89 + 'id' => 'com.example.test', 90 + 'defs' => [ 91 + 'main' => ['type' => 'object'], 92 + 'other' => ['type' => 'string'], 93 + ], 94 + ]); 95 + 96 + $type1 = $this->parser->resolveReference('#other', $document); 97 + $type2 = $this->parser->resolveReference('#other', $document); 98 + 99 + $this->assertSame($type1, $type2); 100 + $this->assertCount(1, $this->parser->getResolvedTypes()); 101 + } 102 + 103 + public function test_it_clears_cache(): void 104 + { 105 + $document = LexiconDocument::fromArray([ 106 + 'lexicon' => 1, 107 + 'id' => 'com.example.test', 108 + 'defs' => [ 109 + 'main' => ['type' => 'object'], 110 + 'other' => ['type' => 'string'], 111 + ], 112 + ]); 113 + 114 + $this->parser->resolveReference('#other', $document); 115 + $this->assertCount(1, $this->parser->getResolvedTypes()); 116 + 117 + $this->parser->clearCache(); 118 + $this->assertCount(0, $this->parser->getResolvedTypes()); 119 + } 120 + 121 + public function test_it_throws_on_external_reference_without_loader(): void 122 + { 123 + $document = LexiconDocument::fromArray([ 124 + 'lexicon' => 1, 125 + 'id' => 'com.example.test', 126 + 'defs' => [ 127 + 'main' => ['type' => 'object'], 128 + ], 129 + ]); 130 + 131 + $this->expectException(\RuntimeException::class); 132 + $this->expectExceptionMessage('Cannot resolve external reference without SchemaLoader'); 133 + 134 + $this->parser->resolveReference('com.atproto.repo.getRecord', $document); 135 + } 136 + }
+201
tests/Unit/Validation/LexiconValidatorTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Validation; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Data\LexiconDocument; 7 + use SocialDept\Schema\Exceptions\RecordValidationException; 8 + use SocialDept\Schema\Exceptions\SchemaValidationException; 9 + use SocialDept\Schema\Parser\SchemaLoader; 10 + use SocialDept\Schema\Validation\LexiconValidator; 11 + 12 + class LexiconValidatorTest extends TestCase 13 + { 14 + protected LexiconValidator $validator; 15 + 16 + protected SchemaLoader $loader; 17 + 18 + protected function setUp(): void 19 + { 20 + parent::setUp(); 21 + 22 + $fixturesPath = __DIR__.'/../../fixtures'; 23 + $this->loader = new SchemaLoader([$fixturesPath], false); 24 + $this->validator = new LexiconValidator($this->loader); 25 + } 26 + 27 + public function test_it_validates_valid_record(): void 28 + { 29 + $record = [ 30 + 'text' => 'Hello, World!', 31 + 'createdAt' => '2024-01-01T00:00:00Z', 32 + ]; 33 + 34 + $this->validator->validateByNsid('app.bsky.feed.post', $record); 35 + 36 + $this->assertTrue(true); 37 + } 38 + 39 + public function test_it_throws_on_missing_required_field(): void 40 + { 41 + $record = [ 42 + 'text' => 'Hello, World!', 43 + // Missing createdAt 44 + ]; 45 + 46 + $this->expectException(RecordValidationException::class); 47 + 48 + $this->validator->validateByNsid('app.bsky.feed.post', $record); 49 + } 50 + 51 + public function test_it_throws_on_invalid_field_type(): void 52 + { 53 + $record = [ 54 + 'text' => 123, // Should be string 55 + 'createdAt' => '2024-01-01T00:00:00Z', 56 + ]; 57 + 58 + $this->expectException(RecordValidationException::class); 59 + 60 + $this->validator->validateByNsid('app.bsky.feed.post', $record); 61 + } 62 + 63 + public function test_it_validates_record_with_lexicon_document(): void 64 + { 65 + $document = LexiconDocument::fromArray([ 66 + 'lexicon' => 1, 67 + 'id' => 'com.example.test', 68 + 'defs' => [ 69 + 'main' => [ 70 + 'type' => 'record', 71 + 'record' => [ 72 + 'type' => 'object', 73 + 'required' => ['name'], 74 + 'properties' => [ 75 + 'name' => ['type' => 'string'], 76 + ], 77 + ], 78 + ], 79 + ], 80 + ]); 81 + 82 + $this->validator->validateRecord($document, ['name' => 'John']); 83 + 84 + $this->assertTrue(true); 85 + } 86 + 87 + public function test_it_throws_on_non_record_schema(): void 88 + { 89 + $document = LexiconDocument::fromArray([ 90 + 'lexicon' => 1, 91 + 'id' => 'com.example.test', 92 + 'defs' => [ 93 + 'main' => [ 94 + 'type' => 'query', 95 + ], 96 + ], 97 + ]); 98 + 99 + $this->expectException(SchemaValidationException::class); 100 + $this->expectExceptionMessage('Schema is not a record type'); 101 + 102 + $this->validator->validateRecord($document, ['name' => 'John']); 103 + } 104 + 105 + public function test_it_validates_procedure_input(): void 106 + { 107 + $document = LexiconDocument::fromArray([ 108 + 'lexicon' => 1, 109 + 'id' => 'com.example.test', 110 + 'defs' => [ 111 + 'main' => [ 112 + 'type' => 'procedure', 113 + 'input' => [ 114 + 'type' => 'object', 115 + 'required' => ['name'], 116 + 'properties' => [ 117 + 'name' => ['type' => 'string'], 118 + ], 119 + ], 120 + ], 121 + ], 122 + ]); 123 + 124 + $this->validator->validateProcedure($document, ['name' => 'John']); 125 + 126 + $this->assertTrue(true); 127 + } 128 + 129 + public function test_it_validates_with_contract_method(): void 130 + { 131 + $schema = $this->loader->load('app.bsky.feed.post'); 132 + $document = LexiconDocument::fromArray($schema); 133 + 134 + $record = [ 135 + 'text' => 'Hello, World!', 136 + 'createdAt' => '2024-01-01T00:00:00Z', 137 + ]; 138 + 139 + $this->assertTrue($this->validator->validate($record, $document)); 140 + } 141 + 142 + public function test_it_returns_false_for_invalid_record(): void 143 + { 144 + $schema = $this->loader->load('app.bsky.feed.post'); 145 + $document = LexiconDocument::fromArray($schema); 146 + 147 + $record = [ 148 + 'text' => 'Hello, World!', 149 + // Missing createdAt 150 + ]; 151 + 152 + $this->assertFalse($this->validator->validate($record, $document)); 153 + } 154 + 155 + public function test_it_validates_with_errors(): void 156 + { 157 + $schema = $this->loader->load('app.bsky.feed.post'); 158 + $document = LexiconDocument::fromArray($schema); 159 + 160 + $record = [ 161 + 'text' => 'Hello, World!', 162 + 'createdAt' => '2024-01-01T00:00:00Z', 163 + ]; 164 + 165 + $errors = $this->validator->validateWithErrors($record, $document); 166 + 167 + $this->assertEmpty($errors); 168 + } 169 + 170 + public function test_it_returns_errors_for_invalid_record(): void 171 + { 172 + $schema = $this->loader->load('app.bsky.feed.post'); 173 + $document = LexiconDocument::fromArray($schema); 174 + 175 + $record = [ 176 + 'text' => 'Hello, World!', 177 + // Missing createdAt 178 + ]; 179 + 180 + $errors = $this->validator->validateWithErrors($record, $document); 181 + 182 + $this->assertNotEmpty($errors); 183 + $this->assertIsArray($errors); 184 + } 185 + 186 + public function test_it_validates_specific_field(): void 187 + { 188 + $schema = $this->loader->load('app.bsky.feed.post'); 189 + $document = LexiconDocument::fromArray($schema); 190 + 191 + $this->assertTrue($this->validator->validateField('Hello, World!', 'text', $document)); 192 + $this->assertFalse($this->validator->validateField(123, 'text', $document)); 193 + } 194 + 195 + public function test_it_sets_validation_mode(): void 196 + { 197 + $this->validator->setMode('lenient'); 198 + 199 + $this->assertTrue(true); 200 + } 201 + }