Skip to content

Commit 431dc4c

Browse files
committed
Add tests for sealed and unsealed array shapes; improve type error handling
1 parent 032a2e9 commit 431dc4c

2 files changed

Lines changed: 126 additions & 82 deletions

File tree

tests/TypeChecking/ArraysAndShapes/ArrayAndListTypesTest.php

Lines changed: 89 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,27 @@ function testKeylessImplicitTupleShape(array $tuple): bool
116116
return true;
117117
}
118118

119+
/**
120+
* 1. Sealed Shape (Default)
121+
*
122+
* @param array{id: positive-int, username: non-empty-string} $sealedPayload
123+
*/
124+
function testSealedShape(array $sealedPayload): bool
125+
{
126+
return true;
127+
}
128+
129+
/**
130+
* 2. Unsealed Typed Shape
131+
* Requires 'id' to be positive-int, but permits additional string-string pairs
132+
*
133+
* @param array{id: positive-int, ...<string, string>} $unsealedPayload
134+
*/
135+
function testUnsealedTypedShape(array $unsealedPayload): bool
136+
{
137+
return true;
138+
}
139+
119140
/**
120141
* Helpers for Issue #20 Edge Cases
121142
*
@@ -161,9 +182,8 @@ function testReturnKeylessTuple(bool $valid): array
161182
});
162183

163184
test('throws TypeError when array contains an invalid class object', function () {
164-
expect(fn () => testDogArrayParam([new Dog(), new Car()]))
165-
->toThrow(TypeError::class)
166-
;
185+
expect(fn() => testDogArrayParam([new Dog(), new Car()]))
186+
->toThrow(TypeError::class);
167187
});
168188
});
169189

@@ -174,16 +194,14 @@ function testReturnKeylessTuple(bool $valid): array
174194

175195
test('throws TypeError when key type is invalid', function () {
176196
// Integer key 0 instead of string key
177-
expect(fn () => testAssocScoreArrayParam([0 => 100]))
178-
->toThrow(TypeError::class, 'key')
179-
;
197+
expect(fn() => testAssocScoreArrayParam([0 => 100]))
198+
->toThrow(TypeError::class, 'key');
180199
});
181200

182201
test('throws TypeError when value type is invalid', function () {
183202
// Negative integer -10 instead of positive-int
184-
expect(fn () => testAssocScoreArrayParam(['alice' => -10]))
185-
->toThrow(TypeError::class, "['alice']")
186-
;
203+
expect(fn() => testAssocScoreArrayParam(['alice' => -10]))
204+
->toThrow(TypeError::class, "['alice']");
187205
});
188206
});
189207

@@ -193,23 +211,20 @@ function testReturnKeylessTuple(bool $valid): array
193211
});
194212

195213
test('throws TypeError when list contains associative keys', function () {
196-
expect(fn () => testTagListParam(['tag' => 'php']))
197-
->toThrow(TypeError::class, 'must be a list')
198-
;
214+
expect(fn() => testTagListParam(['tag' => 'php']))
215+
->toThrow(TypeError::class, 'must be a list');
199216
});
200217

201218
test('throws TypeError when list contains an empty string', function () {
202-
expect(fn () => testTagListParam(['php', '']))
203-
->toThrow(TypeError::class, 'non-empty-string')
204-
;
219+
expect(fn() => testTagListParam(['php', '']))
220+
->toThrow(TypeError::class, 'non-empty-string');
205221
});
206222

207223
test('accepts valid non-empty list and rejects empty array', function () {
208224
expect(testNonEmptyNumberListParam([1, 2, 3]))->toBe(3);
209225

210-
expect(fn () => testNonEmptyNumberListParam([]))
211-
->toThrow(TypeError::class, 'non-empty list')
212-
;
226+
expect(fn() => testNonEmptyNumberListParam([]))
227+
->toThrow(TypeError::class, 'non-empty list');
213228
});
214229
});
215230

@@ -228,19 +243,17 @@ function testReturnKeylessTuple(bool $valid): array
228243
'math' => [100, -50], // -50 is not positive-int
229244
];
230245

231-
expect(fn () => testNestedMatrixParam($invalidMatrix))
232-
->toThrow(TypeError::class)
233-
;
246+
expect(fn() => testNestedMatrixParam($invalidMatrix))
247+
->toThrow(TypeError::class);
234248
});
235249

236250
test('throws TypeError when nested list is associative', function () {
237251
$invalidMatrix = [
238252
'math' => ['score' => 100], // Not a list
239253
];
240254

241-
expect(fn () => testNestedMatrixParam($invalidMatrix))
242-
->toThrow(TypeError::class)
243-
;
255+
expect(fn() => testNestedMatrixParam($invalidMatrix))
256+
->toThrow(TypeError::class);
244257
});
245258
});
246259

@@ -260,9 +273,8 @@ function testReturnKeylessTuple(bool $valid): array
260273
new Producer(new Car()), // Car is not an Animal
261274
];
262275

263-
expect(fn () => testGenericProducerListParam($producers))
264-
->toThrow(TypeError::class)
265-
;
276+
expect(fn() => testGenericProducerListParam($producers))
277+
->toThrow(TypeError::class);
266278
});
267279
});
268280

@@ -273,14 +285,12 @@ function testReturnKeylessTuple(bool $valid): array
273285

274286
test('throws TypeError on invalid tuple element', function () {
275287
// First item -5 is not positive-int
276-
expect(fn () => testTupleShapeParam([-5, 'alice']))
277-
->toThrow(TypeError::class, "['0']")
278-
;
288+
expect(fn() => testTupleShapeParam([-5, 'alice']))
289+
->toThrow(TypeError::class, "['0']");
279290

280291
// Second item '' is not non-empty-string
281-
expect(fn () => testTupleShapeParam([10, '']))
282-
->toThrow(TypeError::class, "['1']")
283-
;
292+
expect(fn() => testTupleShapeParam([10, '']))
293+
->toThrow(TypeError::class, "['1']");
284294
});
285295
});
286296

@@ -301,9 +311,8 @@ function testReturnKeylessTuple(bool $valid): array
301311
'invalid_extra' => 999, // int given, but string expected by unsealed type
302312
];
303313

304-
expect(fn () => testUnsealedShapeParam($payload))
305-
->toThrow(TypeError::class)
306-
;
314+
expect(fn() => testUnsealedShapeParam($payload))
315+
->toThrow(TypeError::class);
307316
});
308317
});
309318

@@ -316,7 +325,7 @@ function testReturnKeylessTuple(bool $valid): array
316325
});
317326

318327
test('throws TypeError when any variadic argument violates the array shape', function () {
319-
expect(fn () => testVariadicArrayShapeParam(
328+
expect(fn() => testVariadicArrayShapeParam(
320329
['id' => 1, 'name' => 'Alice'],
321330
['id' => -2, 'name' => 'Bob'] // -2 is not positive-int
322331
))->toThrow(TypeError::class);
@@ -340,9 +349,8 @@ function testReturnKeylessTuple(bool $valid): array
340349
'tags' => ['php', ''], // Empty string violates list<non-empty-string>
341350
];
342351

343-
expect(fn () => testComplexNestedShapeParam($payload))
344-
->toThrow(TypeError::class)
345-
;
352+
expect(fn() => testComplexNestedShapeParam($payload))
353+
->toThrow(TypeError::class);
346354
});
347355
});
348356

@@ -353,12 +361,12 @@ function testReturnKeylessTuple(bool $valid): array
353361
['status_ok', 'code' => 200, [1, 2, 3]]
354362
))->toBeTrue();
355363

356-
expect(fn () => testLocalTupleAliasParam(
364+
expect(fn() => testLocalTupleAliasParam(
357365
[[10, -5], ['a', 'b']],
358366
['status_ok', 'code' => 200, [1, 2, 3]]
359367
))->toThrow(TypeError::class, "Argument \$payload['0'][1] must be of type positive-int");
360368

361-
expect(fn () => testLocalTupleAliasParam(
369+
expect(fn() => testLocalTupleAliasParam(
362370
[[10, 20], ['a', 'b']],
363371
['status_ok', 'code' => -100, [1, 2, 3]]
364372
))->toThrow(TypeError::class, "Argument \$mixedPayload['code'] must be of type positive-int");
@@ -367,16 +375,49 @@ function testReturnKeylessTuple(bool $valid): array
367375
test('resolves keyless tuple shapes imported via @phpstan-import-type', function () {
368376
expect(testImportedTupleAliasParam([[100, 200], 'valid_string']))->toBeTrue();
369377

370-
expect(fn () => testImportedTupleAliasParam([[100, 200], '']))
371-
->toThrow(TypeError::class, "Argument \$tuple['1'] must be of type non-empty-string")
372-
;
378+
expect(fn() => testImportedTupleAliasParam([[100, 200], '']))
379+
->toThrow(TypeError::class, "Argument \$tuple['1'] must be of type non-empty-string");
373380
});
374381

375382
test('validates keyless tuple shapes returned from functions', function () {
376383
expect(testReturnKeylessTuple(true))->toBe([[10, 20], 'bundle']);
377384

378-
expect(fn () => testReturnKeylessTuple(false))
379-
->toThrow(TypeError::class, "Return value['0'][1] must be of type positive-int")
380-
;
385+
expect(fn() => testReturnKeylessTuple(false))
386+
->toThrow(TypeError::class, "Return value['0'][1] must be of type positive-int");
387+
});
388+
});
389+
390+
describe('Sealed vs Unsealed Array Shapes', function () {
391+
describe('Sealed Shapes (array{id: int})', function () {
392+
test('accepts exact declared shape keys', function () {
393+
expect(testSealedShape(['id' => 10, 'username' => 'Alice']))->toBeTrue();
394+
});
395+
396+
test('throws TypeError when sealed shape receives unexpected extra key', function () {
397+
expect(fn() => testSealedShape(['id' => 10, 'username' => 'Alice', 'extra_key' => 'bar']))
398+
->toThrow(TypeError::class, "contains unsealed unexpected key 'extra_key'");
399+
});
400+
});
401+
402+
describe('Unsealed Typed Shapes (array{id: int, ...<string, string>})', function () {
403+
test('accepts required keys plus additional string-string pairs', function () {
404+
$payload = [
405+
'id' => 42,
406+
'category' => 'admin_user',
407+
'department' => 'engineering',
408+
];
409+
410+
expect(testUnsealedTypedShape($payload))->toBeTrue();
411+
});
412+
413+
test('throws TypeError when unsealed extra value violates unsealed type contract', function () {
414+
$payload = [
415+
'id' => 42,
416+
'code' => 999, // 999 is int, but unsealed type requires string value!
417+
];
418+
419+
expect(fn() => testUnsealedTypedShape($payload))
420+
->toThrow(TypeError::class, "['code'] must be of type string, int (999) given");
421+
});
381422
});
382423
});

tests/TypeChecking/InheritanceAndAttributes/ParameterShiftInheritanceTest.php

Lines changed: 37 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -43,25 +43,22 @@
4343
test('throws TypeError when renamed $userId parameter fails parent inherited positive-int contract', function () {
4444
$service = new ChildShiftedMethodService();
4545

46-
expect(fn () => $service->updateUser(-5, 'Alice', ['active' => true]))
47-
->toThrow(TypeError::class, 'Argument $userId must be of type positive-int')
48-
;
46+
expect(fn() => $service->updateUser(-5, 'Alice', ['active' => true]))
47+
->toThrow(TypeError::class, 'Argument $userId must be of type positive-int');
4948
});
5049

5150
test('throws TypeError when renamed $userName parameter fails parent inherited non-empty-string contract', function () {
5251
$service = new ChildShiftedMethodService();
5352

54-
expect(fn () => $service->updateUser(42, '', ['active' => true]))
55-
->toThrow(TypeError::class, 'Argument $userName must be of type non-empty-string')
56-
;
53+
expect(fn() => $service->updateUser(42, '', ['active' => true]))
54+
->toThrow(TypeError::class, 'Argument $userName must be of type non-empty-string');
5755
});
5856

5957
test('throws TypeError when renamed $userOptions parameter fails parent inherited shape contract', function () {
6058
$service = new ChildShiftedMethodService();
6159

62-
expect(fn () => $service->updateUser(42, 'Alice', ['active' => 'not_bool']))
63-
->toThrow(TypeError::class, "Argument \$userOptions['active'] must be of type bool")
64-
;
60+
expect(fn() => $service->updateUser(42, 'Alice', ['active' => 'not_bool']))
61+
->toThrow(TypeError::class, "Argument \$userOptions['active'] must be of type bool");
6562
});
6663
});
6764

@@ -71,15 +68,13 @@
7168
});
7269

7370
test('throws TypeError when renamed $itemBatch parameter fails parent contract on static method', function () {
74-
expect(fn () => ChildShiftedMethodService::processBatch([10, -5], 'json'))
75-
->toThrow(TypeError::class, 'Argument $itemBatch[1] must be of type positive-int')
76-
;
71+
expect(fn() => ChildShiftedMethodService::processBatch([10, -5], 'json'))
72+
->toThrow(TypeError::class, 'Argument $itemBatch[1] must be of type positive-int');
7773
});
7874

7975
test('throws TypeError when renamed $outputFormat parameter fails parent contract on static method', function () {
80-
expect(fn () => ChildShiftedMethodService::processBatch([10, 20], ''))
81-
->toThrow(TypeError::class, 'Argument $outputFormat must be of type non-empty-string')
82-
;
76+
expect(fn() => ChildShiftedMethodService::processBatch([10, 20], ''))
77+
->toThrow(TypeError::class, 'Argument $outputFormat must be of type non-empty-string');
8378
});
8479
});
8580

@@ -89,50 +84,58 @@
8984

9085
expect($service->execute(200, 'valid_token'))->toBeTrue();
9186

92-
expect(fn () => $service->execute(-10, 'valid_token'))
93-
->toThrow(TypeError::class, 'Argument $statusCode must be of type positive-int')
94-
;
87+
expect(fn() => $service->execute(-10, 'valid_token'))
88+
->toThrow(TypeError::class, 'Argument $statusCode must be of type positive-int');
9589

96-
expect(fn () => $service->execute(200, ''))
97-
->toThrow(TypeError::class, 'Argument $authToken must be of type non-empty-string')
98-
;
90+
expect(fn() => $service->execute(200, ''))
91+
->toThrow(TypeError::class, 'Argument $authToken must be of type non-empty-string');
9992
});
10093

10194
test('inherits and validates contracts from Abstract Classes with renamed parameters', function () {
10295
$service = new ChildShiftedOopService();
10396

10497
expect($service->processItems([10, 20, 30]))->toBeTrue();
10598

106-
expect(fn () => $service->processItems([10, -5, 30]))
107-
->toThrow(TypeError::class, 'Argument $itemList[1] must be of type positive-int')
108-
;
99+
expect(fn() => $service->processItems([10, -5, 30]))
100+
->toThrow(TypeError::class, 'Argument $itemList[1] must be of type positive-int');
109101
});
110102

111103
test('inherits and validates contracts from Traits with renamed parameters', function () {
112104
$service = new ChildShiftedOopService();
113105

114106
expect($service->logEvent(1, 'info_message'))->toBeTrue();
115107

116-
expect(fn () => $service->logEvent(-1, 'info_message'))
117-
->toThrow(TypeError::class, 'Argument $logLevel must be of type positive-int')
118-
;
108+
expect(fn() => $service->logEvent(-1, 'info_message'))
109+
->toThrow(TypeError::class, 'Argument $logLevel must be of type positive-int');
119110

120-
expect(fn () => $service->logEvent(1, ''))
121-
->toThrow(TypeError::class, 'Argument $logMessage must be of type non-empty-string')
122-
;
111+
expect(fn() => $service->logEvent(1, ''))
112+
->toThrow(TypeError::class, 'Argument $logMessage must be of type non-empty-string');
123113
});
124114

125115
test('inherits Interface contracts when method is fulfilled by Trait with renamed parameters', function () {
126116
$service = new ChildShiftedTraitAppService();
127117

128118
expect($service->runAction(100, 'valid_token'))->toBeTrue();
129119

130-
expect(fn () => $service->runAction(-5, 'valid_token'))
131-
->toThrow(TypeError::class, 'Argument $actionCode must be of type positive-int')
132-
;
120+
expect(fn() => $service->runAction(-5, 'valid_token'))
121+
->toThrow(TypeError::class, 'Argument $actionCode must be of type positive-int');
133122

134-
expect(fn () => $service->runAction(100, ''))
123+
expect(fn() => $service->runAction(100, ''))
135124
->toThrow(TypeError::class, 'Argument $actionToken must be of type non-empty-string');
136125
});
137126
});
138127
});
128+
129+
describe('PHP 8.0+ Named Arguments on Subtype Methods with Renamed Parameters', function () {
130+
test('validates named arguments passed in swapped order on subtype method with renamed parameters from Interface', function () {
131+
$service = new ChildShiftedOopService();
132+
133+
expect($service->execute(authToken: 'valid_token', statusCode: 200))->toBeTrue();
134+
135+
expect(fn() => $service->execute(authToken: 'valid_token', statusCode: -10))
136+
->toThrow(TypeError::class, 'Argument $statusCode must be of type positive-int');
137+
138+
expect(fn() => $service->execute(authToken: '', statusCode: 200))
139+
->toThrow(TypeError::class, 'Argument $authToken must be of type non-empty-string');
140+
});
141+
});

0 commit comments

Comments
 (0)