Skip to content

Commit d3701d2

Browse files
committed
feat: implement metadata support
1 parent 5a703fe commit d3701d2

10 files changed

Lines changed: 616 additions & 31 deletions

File tree

.junie/guidelines.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ make test
6262

6363
# Run specific test file (within Docker container)
6464
docker compose exec app vendor/bin/phpunit tests/Path/To/TestFile.php
65+
66+
# Run tests by filter/pattern (correct approach)
67+
docker compose exec app vendor/bin/phpunit --filter=TestClassName
68+
# Note: make test FILTER=TestName is NOT the correct command for filtering
6569
```
6670

6771
### Test Structure and Patterns
@@ -119,6 +123,22 @@ final class ExampleTest extends TestCase
119123
- **Good Example:** `$registry = $factory->make(); $this->assertTrue($registry->hasSpec('required'));`
120124
- Focus on testing that the created object works correctly, not just that it exists
121125

126+
### Testing Classes with Magic Methods
127+
128+
**Field Class Testing Pattern:**
129+
130+
When testing classes like `Field` that use `__call` method to map virtual methods from docblock through specs:
131+
132+
- Focus on testing the mapping engine that connects docblock methods to specs
133+
- Create a simple `Specs` instance and use `register()` to add test specs instead of using factories
134+
- Testing with one spec is sufficient to verify the mapping mechanism works
135+
- Test the fluent API behavior and rule registration through the `__call` method
136+
137+
**Factory Naming Conventions:**
138+
139+
- Use `DefaultSpecsFactory` instead of `BasicSpecsFactory` for consistency
140+
- When possible, avoid factories in tests and create objects directly for simpler test setup
141+
122142
## Additional Development Information
123143

124144
### Project Architecture
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Constructo\Contract\Schema;
6+
7+
use Constructo\Core\Metadata\Schema\Registry\Specs;
8+
9+
interface SpecsFactory
10+
{
11+
public function make(): Specs;
12+
}

src/Core/Metadata/Schema/Registry/SpecsFactory.php renamed to src/Factory/DefaultSpecsFactory.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,18 @@
22

33
declare(strict_types=1);
44

5-
namespace Constructo\Core\Metadata\Schema\Registry;
5+
namespace Constructo\Factory;
66

7+
use Constructo\Contract\Schema\SpecsFactory;
8+
use Constructo\Core\Metadata\Schema\Registry\Specs;
79
use InvalidArgumentException;
810

911
use function assert;
1012
use function Constructo\Cast\arrayify;
1113
use function Constructo\Cast\stringify;
1214
use function gettype;
1315

14-
readonly class SpecsFactory
16+
readonly class DefaultSpecsFactory implements SpecsFactory
1517
{
1618
public function __construct(private array $specs = [])
1719
{

src/Core/Metadata/Schema/SchemaFactory.php renamed to src/Factory/SchemaFactory.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22

33
declare(strict_types=1);
44

5-
namespace Constructo\Core\Metadata\Schema;
5+
namespace Constructo\Factory;
66

7+
use Constructo\Contract\Schema\SpecsFactory;
78
use Constructo\Core\Metadata\Schema;
89
use Constructo\Core\Metadata\Schema\Field\Fieldset;
9-
use Constructo\Core\Metadata\Schema\Registry\SpecsFactory;
1010

1111
class SchemaFactory
1212
{
@@ -16,7 +16,7 @@ public function __construct(private readonly SpecsFactory $specsFactory)
1616

1717
public function make(): Schema
1818
{
19-
$schemaRegistry = $this->specsFactory->make();
20-
return new Schema($schemaRegistry, new Fieldset());
19+
$specs = $this->specsFactory->make();
20+
return new Schema($specs, new Fieldset());
2121
}
2222
}

src/Support/Reflective/Schema/Parameter/Registry/TypesFactory.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
namespace Constructo\Support\Reflective\Schema\Parameter\Registry;
66

77
use Constructo\Core\Metadata\Schema;
8-
use Constructo\Core\Metadata\Schema\SchemaFactory;
8+
use Constructo\Factory\SchemaFactory;
99
use Constructo\Support\Cache;
1010
use Constructo\Support\Reflective\Schema\SchemaReflector;
1111
use ReflectionException;
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Constructo\Test\Core\Metadata\Schema;
6+
7+
use BadMethodCallException;
8+
use Constructo\Core\Metadata\Schema\Field;
9+
use Constructo\Core\Metadata\Schema\Field\Rules;
10+
use Constructo\Core\Metadata\Schema\Registry\Spec;
11+
use Constructo\Core\Metadata\Schema\Registry\Specs;
12+
use Constructo\Factory\DefaultSpecsFactory;
13+
use Constructo\Support\Set;
14+
use InvalidArgumentException;
15+
use PHPUnit\Framework\TestCase;
16+
17+
final class FieldTest extends TestCase
18+
{
19+
private Specs $specs;
20+
21+
protected function setUp(): void
22+
{
23+
$specsData = [
24+
'required' => [],
25+
'string' => [],
26+
'integer' => [],
27+
'numeric' => [],
28+
'boolean' => [],
29+
'email' => [],
30+
'url' => [],
31+
'uuid' => [],
32+
'min' => ['params' => ['min']],
33+
'max' => ['params' => ['max']],
34+
'between' => ['params' => ['min', 'max']],
35+
'in' => ['params' => ['values']],
36+
'regex' => ['params' => ['pattern', 'parameters:optional']],
37+
'nullable' => [],
38+
'sometimes' => [],
39+
'bail' => [],
40+
];
41+
42+
$specsFactory = new DefaultSpecsFactory($specsData);
43+
$this->specs = $specsFactory->make();
44+
}
45+
46+
public function testCanCreateFieldWithBasicProperties(): void
47+
{
48+
$field = new Field($this->specs, new Rules(), 'test_field');
49+
50+
$this->assertSame('test_field', $field->name);
51+
$this->assertTrue($field->isAvailable());
52+
$this->assertNull($field->mapping());
53+
$this->assertNull($field->getSource());
54+
}
55+
56+
public function testCanSetAndGetSource(): void
57+
{
58+
$field = new Field($this->specs, new Rules(), 'test_field');
59+
60+
$field->setSource('original_field');
61+
62+
$this->assertSame('original_field', $field->getSource());
63+
}
64+
65+
public function testCanCallRequiredRule(): void
66+
{
67+
$field = new Field($this->specs, new Rules(), 'test_field');
68+
69+
$result = $field->required();
70+
71+
$this->assertSame($field, $result);
72+
$this->assertTrue($field->hasRule('required'));
73+
$this->assertContains('required', $field->rules());
74+
}
75+
76+
public function testCanCallStringRule(): void
77+
{
78+
$field = new Field($this->specs, new Rules(), 'test_field');
79+
80+
$result = $field->string();
81+
82+
$this->assertSame($field, $result);
83+
$this->assertTrue($field->hasRule('string'));
84+
$this->assertContains('string', $field->rules());
85+
}
86+
87+
public function testCanCallRuleWithParameters(): void
88+
{
89+
$field = new Field($this->specs, new Rules(), 'test_field');
90+
91+
$result = $field->min(5);
92+
93+
$this->assertSame($field, $result);
94+
$this->assertTrue($field->hasRule('min'));
95+
$this->assertContains('min:5', $field->rules());
96+
}
97+
98+
public function testCanCallRuleWithMultipleParameters(): void
99+
{
100+
$field = new Field($this->specs, new Rules(), 'test_field');
101+
102+
$result = $field->between(5, 10);
103+
104+
$this->assertSame($field, $result);
105+
$this->assertTrue($field->hasRule('between'));
106+
$this->assertContains('between:5,10', $field->rules());
107+
}
108+
109+
public function testCanChainMultipleRules(): void
110+
{
111+
$field = new Field($this->specs, new Rules(), 'test_field');
112+
113+
$result = $field->required()->string()->min(3)->max(50);
114+
115+
$this->assertSame($field, $result);
116+
$this->assertTrue($field->hasRule('required'));
117+
$this->assertTrue($field->hasRule('string'));
118+
$this->assertTrue($field->hasRule('min'));
119+
$this->assertTrue($field->hasRule('max'));
120+
121+
$rules = $field->rules();
122+
$this->assertContains('required', $rules);
123+
$this->assertContains('string', $rules);
124+
$this->assertContains('min:3', $rules);
125+
$this->assertContains('max:50', $rules);
126+
}
127+
128+
public function testCanSetMappingWithString(): void
129+
{
130+
$field = new Field($this->specs, new Rules(), 'test_field');
131+
132+
$result = $field->map('mapped_field');
133+
134+
$this->assertSame($field, $result);
135+
$this->assertSame('mapped_field', $field->mapping());
136+
}
137+
138+
public function testCanSetMappingWithClosure(): void
139+
{
140+
$field = new Field($this->specs, new Rules(), 'test_field');
141+
$closure = fn($value) => strtoupper($value);
142+
143+
$result = $field->map($closure);
144+
145+
$this->assertSame($field, $result);
146+
$this->assertSame($closure, $field->mapping());
147+
}
148+
149+
public function testCanSetFieldAsUnavailable(): void
150+
{
151+
$field = new Field($this->specs, new Rules(), 'test_field');
152+
153+
$result = $field->unavailable();
154+
155+
$this->assertSame($field, $result);
156+
$this->assertFalse($field->isAvailable());
157+
}
158+
159+
public function testCanSetFieldAsAvailable(): void
160+
{
161+
$field = new Field($this->specs, new Rules(), 'test_field');
162+
163+
// First make it unavailable
164+
$field->unavailable();
165+
$this->assertFalse($field->isAvailable());
166+
167+
// Then make it available again
168+
$result = $field->available();
169+
170+
$this->assertSame($field, $result);
171+
$this->assertTrue($field->isAvailable());
172+
}
173+
174+
public function testHasRuleReturnsFalseForNonExistentRule(): void
175+
{
176+
$field = new Field($this->specs, new Rules(), 'test_field');
177+
178+
$this->assertFalse($field->hasRule('nonexistent'));
179+
}
180+
181+
public function testRulesReturnsEmptyArrayInitially(): void
182+
{
183+
$field = new Field($this->specs, new Rules(), 'test_field');
184+
185+
$this->assertSame([], $field->rules());
186+
}
187+
188+
public function testThrowsExceptionForUnsupportedMethod(): void
189+
{
190+
$field = new Field($this->specs, new Rules(), 'test_field');
191+
192+
$this->expectException(BadMethodCallException::class);
193+
$this->expectExceptionMessage("Entry 'unsupported' is not supported.");
194+
195+
$field->unsupported();
196+
}
197+
198+
public function testCanCallAllBasicValidationRules(): void
199+
{
200+
$field = new Field($this->specs, new Rules(), 'test_field');
201+
202+
$field->required()
203+
->string()
204+
->integer()
205+
->numeric()
206+
->boolean()
207+
->email()
208+
->url()
209+
->uuid()
210+
->nullable()
211+
->sometimes()
212+
->bail();
213+
214+
$this->assertTrue($field->hasRule('required'));
215+
$this->assertTrue($field->hasRule('string'));
216+
$this->assertTrue($field->hasRule('integer'));
217+
$this->assertTrue($field->hasRule('numeric'));
218+
$this->assertTrue($field->hasRule('boolean'));
219+
$this->assertTrue($field->hasRule('email'));
220+
$this->assertTrue($field->hasRule('url'));
221+
$this->assertTrue($field->hasRule('uuid'));
222+
$this->assertTrue($field->hasRule('nullable'));
223+
$this->assertTrue($field->hasRule('sometimes'));
224+
$this->assertTrue($field->hasRule('bail'));
225+
}
226+
227+
public function testCanCallRulesWithArrayParameters(): void
228+
{
229+
$field = new Field($this->specs, new Rules(), 'test_field');
230+
231+
$result = $field->in(['option1', 'option2', 'option3']);
232+
233+
$this->assertSame($field, $result);
234+
$this->assertTrue($field->hasRule('in'));
235+
$this->assertContains('in:option1,option2,option3', $field->rules());
236+
}
237+
238+
public function testFieldConstantsAreCorrect(): void
239+
{
240+
$this->assertSame(['map'], Field::MAPPING);
241+
$this->assertSame(['unavailable', 'available'], Field::VISIBILITY);
242+
}
243+
244+
public function testComplexScenarioWithMultipleOperations(): void
245+
{
246+
$field = new Field($this->specs, new Rules(), 'user_email');
247+
248+
$field->required()
249+
->string()
250+
->email()
251+
->max(255)
252+
->map('email_address')
253+
->setSource('user_email_field');
254+
255+
$this->assertSame('user_email', $field->name);
256+
$this->assertTrue($field->isAvailable());
257+
$this->assertSame('email_address', $field->mapping());
258+
$this->assertSame('user_email_field', $field->getSource());
259+
260+
$rules = $field->rules();
261+
$this->assertContains('required', $rules);
262+
$this->assertContains('string', $rules);
263+
$this->assertContains('email', $rules);
264+
$this->assertContains('max:255', $rules);
265+
266+
$this->assertTrue($field->hasRule('required'));
267+
$this->assertTrue($field->hasRule('string'));
268+
$this->assertTrue($field->hasRule('email'));
269+
$this->assertTrue($field->hasRule('max'));
270+
}
271+
}

0 commit comments

Comments
 (0)