Rust-style Option and Result Classes for PHP

feat(tests): ai generated / human reviewed unit tests

Ciaran bf5d5121 1959e306

+1292 -3
+3
composer.json
··· 16 16 ], 17 17 "require-dev": { 18 18 "phpunit/phpunit": "^12.4" 19 + }, 20 + "scripts": { 21 + "test": "./vendor/bin/phpunit --testdox tests" 19 22 } 20 23 }
+561 -1
tests/OptionTest.php
··· 1 1 <?php 2 2 3 3 use PHPUnit\Framework\TestCase; 4 + use Ciarancoza\OptionResult\Option; 5 + use Ciarancoza\OptionResult\Exceptions\UnwrapNoneException; 4 6 5 - class OptionTest extends TestCase { 7 + class OptionTest extends TestCase 8 + { 9 + // ========================================================================= 10 + // STATIC CONSTRUCTOR TESTS 11 + // ========================================================================= 12 + 13 + public function testSomeCreatesOptionWithValue(): void 14 + { 15 + $option = Option::Some("hello"); 16 + $this->assertTrue($option->isSome()); 17 + $this->assertFalse($option->isNone()); 18 + $this->assertEquals("hello", $option->unwrap()); 19 + } 20 + 21 + public function testSomeCreatesOptionWithNumber(): void 22 + { 23 + $option = Option::Some(42); 24 + $this->assertTrue($option->isSome()); 25 + $this->assertFalse($option->isNone()); 26 + $this->assertEquals(42, $option->unwrap()); 27 + } 28 + 29 + public function testSomeCanExplicitlyContainNull(): void 30 + { 31 + $option = Option::Some(null); 32 + $this->assertTrue($option->isSome()); 33 + $this->assertFalse($option->isNone()); 34 + $this->assertNull($option->unwrap()); 35 + } 36 + 37 + public function testNoneCreatesEmptyOption(): void 38 + { 39 + $option = Option::None(); 40 + $this->assertFalse($option->isSome()); 41 + $this->assertTrue($option->isNone()); 42 + } 43 + 44 + public function testSomeWithFalsyValues(): void 45 + { 46 + $falsyValues = [ 47 + false, 48 + 0, 49 + 0.0, 50 + '', 51 + '0', 52 + [], 53 + ]; 54 + 55 + foreach ($falsyValues as $falsyValue) { 56 + $option = Option::Some($falsyValue); 57 + $this->assertTrue($option->isSome()); 58 + $this->assertFalse($option->isNone()); 59 + $this->assertSame($falsyValue, $option->unwrap()); 60 + } 61 + } 62 + 63 + // ========================================================================= 64 + // STATE CHECKING TESTS 65 + // ========================================================================= 66 + 67 + public function testIsSomeReturnsTrueForSomeValue(): void 68 + { 69 + $option = Option::Some("value"); 70 + $this->assertTrue($option->isSome()); 71 + } 72 + 73 + public function testIsSomeReturnsFalseForNone(): void 74 + { 75 + $option = Option::None(); 76 + $this->assertFalse($option->isSome()); 77 + } 78 + 79 + public function testIsNoneReturnsFalseForSomeValue(): void 80 + { 81 + $option = Option::Some("value"); 82 + $this->assertFalse($option->isNone()); 83 + } 84 + 85 + public function testIsNoneReturnsTrueForNone(): void 86 + { 87 + $option = Option::None(); 88 + $this->assertTrue($option->isNone()); 89 + } 90 + 91 + // ========================================================================= 92 + // VALUE EXTRACTION TESTS - unwrap() 93 + // ========================================================================= 94 + 95 + public function testUnwrapReturnsValueFromSome(): void 96 + { 97 + $option = Option::Some("hello"); 98 + $this->assertEquals("hello", $option->unwrap()); 99 + } 100 + 101 + public function testUnwrapThrowsExceptionOnNone(): void 102 + { 103 + $this->expectException(UnwrapNoneException::class); 104 + 105 + $option = Option::None(); 106 + $option->unwrap(); 107 + } 108 + 109 + public function testUnwrapReturnsNullFromSomeNull(): void 110 + { 111 + $option = Option::Some(null); 112 + $this->assertNull($option->unwrap()); 113 + } 114 + 115 + public function testUnwrapReturnsComplexValues(): void 116 + { 117 + $complexValues = [ 118 + ['key' => 'value', 'nested' => ['data']], 119 + (object)['property' => 'value'], 120 + fn($x) => $x * 2, 121 + ]; 122 + 123 + foreach ($complexValues as $complexValue) { 124 + $option = Option::Some($complexValue); 125 + $this->assertSame($complexValue, $option->unwrap()); 126 + } 127 + } 128 + 129 + // ========================================================================= 130 + // VALUE EXTRACTION TESTS - unwrapOr() 131 + // ========================================================================= 132 + 133 + public function testUnwrapOrReturnsValueFromSome(): void 134 + { 135 + $option = Option::Some("hello"); 136 + $result = $option->unwrapOr("default"); 137 + $this->assertEquals("hello", $result); 138 + } 139 + 140 + public function testUnwrapOrReturnsDefaultFromNone(): void 141 + { 142 + $option = Option::None(); 143 + $result = $option->unwrapOr("default"); 144 + $this->assertEquals("default", $result); 145 + } 146 + 147 + public function testUnwrapOrWithNullValue(): void 148 + { 149 + // Some(null) should return null, not the default 150 + $option = Option::Some(null); 151 + $result = $option->unwrapOr("default"); 152 + $this->assertNull($result); 153 + } 154 + 155 + public function testUnwrapOrWithFalsyDefaults(): void 156 + { 157 + $option = Option::None(); 158 + 159 + $this->assertFalse($option->unwrapOr(false)); 160 + $this->assertEquals(0, $option->unwrapOr(0)); 161 + $this->assertEquals('', $option->unwrapOr('')); 162 + $this->assertEquals([], $option->unwrapOr([])); 163 + } 164 + 165 + public function testUnwrapOrWithComplexDefaults(): void 166 + { 167 + $option = Option::None(); 168 + $defaultObject = (object)['default' => true]; 169 + $defaultArray = ['default' => 'value']; 170 + 171 + $this->assertSame($defaultObject, $option->unwrapOr($defaultObject)); 172 + $this->assertSame($defaultArray, $option->unwrapOr($defaultArray)); 173 + } 174 + 175 + // ========================================================================= 176 + // TRANSFORMATION TESTS - map() 177 + // ========================================================================= 178 + 179 + public function testMapTransformsValueInSome(): void 180 + { 181 + $option = Option::Some(5); 182 + $doubled = $option->map(fn($x) => $x * 2); 183 + 184 + $this->assertTrue($doubled->isSome()); 185 + $this->assertEquals(10, $doubled->unwrap()); 186 + } 187 + 188 + public function testMapOnNoneRetrunsNone(): void 189 + { 190 + $option = Option::None(); 191 + $result = $option->map(fn($x) => $x * 2); 192 + 193 + $this->assertTrue($result->isNone()); 194 + $this->assertFalse($result->isSome()); 195 + } 196 + 197 + public function testMapDoesNotExecuteCallbackOnNone(): void 198 + { 199 + $callbackExecuted = false; 200 + 201 + $option = Option::None(); 202 + $option->map(function($x) use (&$callbackExecuted) { 203 + $callbackExecuted = true; 204 + return $x * 2; 205 + }); 206 + 207 + $this->assertFalse($callbackExecuted, 'Callback should not execute on None'); 208 + } 209 + 210 + public function testMapStringTransformation(): void 211 + { 212 + $option = Option::Some("hello"); 213 + $upper = $option->map(fn($s) => strtoupper($s)); 214 + 215 + $this->assertTrue($upper->isSome()); 216 + $this->assertEquals("HELLO", $upper->unwrap()); 217 + } 218 + 219 + public function testMapWithTypeChanges(): void 220 + { 221 + $option = Option::Some("hello"); 222 + $length = $option->map(fn($s) => strlen($s)); 223 + 224 + $this->assertTrue($length->isSome()); 225 + $this->assertEquals(5, $length->unwrap()); 226 + 227 + $bool = $length->map(fn($n) => $n > 3); 228 + $this->assertTrue($bool->isSome()); 229 + $this->assertTrue($bool->unwrap()); 230 + } 231 + 232 + public function testMapCanReturnNull(): void 233 + { 234 + $option = Option::Some("test"); 235 + $nullResult = $option->map(fn($s) => null); 236 + 237 + $this->assertTrue($nullResult->isSome()); 238 + $this->assertNull($nullResult->unwrap()); 239 + } 240 + 241 + public function testMapCanReturnFalsyValues(): void 242 + { 243 + $option = Option::Some("test"); 244 + 245 + $falseResult = $option->map(fn($s) => false); 246 + $this->assertTrue($falseResult->isSome()); 247 + $this->assertFalse($falseResult->unwrap()); 248 + 249 + $zeroResult = $option->map(fn($s) => 0); 250 + $this->assertTrue($zeroResult->isSome()); 251 + $this->assertEquals(0, $zeroResult->unwrap()); 252 + 253 + $emptyResult = $option->map(fn($s) => ''); 254 + $this->assertTrue($emptyResult->isSome()); 255 + $this->assertEquals('', $emptyResult->unwrap()); 256 + } 257 + 258 + // ========================================================================= 259 + // METHOD CHAINING TESTS 260 + // ========================================================================= 261 + 262 + public function testBasicChaining(): void 263 + { 264 + $result = Option::Some("hello") 265 + ->map(fn($s) => strtoupper($s)) // "HELLO" 266 + ->map(fn($s) => strlen($s)) // 5 267 + ->map(fn($n) => $n > 3); // true 268 + 269 + $this->assertTrue($result->isSome()); 270 + $this->assertTrue($result->unwrap()); 271 + } 272 + 273 + public function testChainingWithNoneSkipsAllTransformations(): void 274 + { 275 + $callbackCount = 0; 276 + 277 + $result = Option::None() 278 + ->map(function($s) use (&$callbackCount) { 279 + $callbackCount++; 280 + return strtoupper($s); 281 + }) 282 + ->map(function($s) use (&$callbackCount) { 283 + $callbackCount++; 284 + return strlen($s); 285 + }) 286 + ->unwrapOr(0); 287 + 288 + $this->assertEquals(0, $result); 289 + $this->assertEquals(0, $callbackCount, 'No callbacks should execute on None'); 290 + } 291 + 292 + public function testLongChainWithMixedTransformations(): void 293 + { 294 + $result = Option::Some(" hello world ") 295 + ->map(fn($s) => trim($s)) // "hello world" 296 + ->map(fn($s) => explode(' ', $s)) // ["hello", "world"] 297 + ->map(fn($arr) => count($arr)) // 2 298 + ->map(fn($n) => $n * 10) // 20 299 + ->map(fn($n) => $n > 15); // true 300 + 301 + $this->assertTrue($result->isSome()); 302 + $this->assertTrue($result->unwrap()); 303 + } 304 + 305 + public function testChainingWithUnwrapOr(): void 306 + { 307 + $someResult = Option::Some("hello") 308 + ->map(fn($s) => strtoupper($s)) 309 + ->map(fn($s) => strlen($s)) 310 + ->unwrapOr(0); 311 + 312 + $this->assertEquals(5, $someResult); 313 + 314 + $noneResult = Option::None() 315 + ->map(fn($s) => strtoupper($s)) 316 + ->map(fn($s) => strlen($s)) 317 + ->unwrapOr(0); 318 + 319 + $this->assertEquals(0, $noneResult); 320 + } 321 + 322 + // ========================================================================= 323 + // EDGE CASES AND ERROR SCENARIOS 324 + // ========================================================================= 325 + 326 + public function testMapWithExceptionInCallback(): void 327 + { 328 + $this->expectException(RuntimeException::class); 329 + $this->expectExceptionMessage("Test exception"); 330 + 331 + $option = Option::Some("test"); 332 + $option->map(function($s) { 333 + throw new RuntimeException("Test exception"); 334 + }); 335 + } 336 + 337 + public function testMapWithExceptionOnNoneDoesNotThrow(): void 338 + { 339 + // Exception should not be thrown because callback is not executed on None 340 + $option = Option::None(); 341 + $result = $option->map(function($s) { 342 + throw new RuntimeException("This should not execute"); 343 + }); 344 + 345 + $this->assertTrue($result->isNone()); 346 + } 347 + 348 + public function testMultipleUnwrapCallsOnSame(): void 349 + { 350 + $option = Option::Some("test"); 351 + 352 + // Multiple unwrap calls should return the same value 353 + $this->assertEquals("test", $option->unwrap()); 354 + $this->assertEquals("test", $option->unwrap()); 355 + $this->assertEquals("test", $option->unwrapOr("default")); 356 + } 357 + 358 + public function testMultipleMapCallsOnSame(): void 359 + { 360 + $option = Option::Some(5); 361 + 362 + // Multiple map calls should create independent new Options 363 + $doubled = $option->map(fn($x) => $x * 2); 364 + $tripled = $option->map(fn($x) => $x * 3); 365 + 366 + $this->assertEquals(5, $option->unwrap()); 367 + $this->assertEquals(10, $doubled->unwrap()); 368 + $this->assertEquals(15, $tripled->unwrap()); 369 + } 370 + 371 + // ========================================================================= 372 + // REAL-WORLD SCENARIO TESTS (Based on USAGE.md examples) 373 + // ========================================================================= 374 + 375 + public function testUserFindingScenario(): void 376 + { 377 + // Simulate the findUser example from USAGE.md 378 + $findUser = function(int $id): Option { 379 + $users = [ 380 + 123 => (object)['id' => 123, 'name' => 'john doe'], 381 + 456 => (object)['id' => 456, 'name' => 'jane smith'], 382 + ]; 383 + 384 + return isset($users[$id]) ? Option::Some($users[$id]) : Option::None(); 385 + }; 386 + 387 + $getUserName = function(int $userId) use ($findUser): string { 388 + return $findUser($userId) 389 + ->map(fn($user) => $user->name) 390 + ->map(fn($name) => ucfirst($name)) 391 + ->unwrapOr('Unknown User'); 392 + }; 393 + 394 + $this->assertEquals('John doe', $getUserName(123)); 395 + $this->assertEquals('Jane smith', $getUserName(456)); 396 + $this->assertEquals('Unknown User', $getUserName(999)); 397 + } 398 + 399 + public function testEmailChainScenario(): void 400 + { 401 + // Simulate the findUserEmail example from USAGE.md 402 + $users = [ 403 + 123 => (object)[ 404 + 'id' => 123, 405 + 'profile' => (object)['email' => 'JOHN@EXAMPLE.COM'] 406 + ], 407 + 456 => (object)[ 408 + 'id' => 456, 409 + 'profile' => null 410 + ], 411 + ]; 412 + 413 + $findUser = fn(int $id): Option => 414 + isset($users[$id]) ? Option::Some($users[$id]) : Option::None(); 415 + 416 + $findUserEmail = function(int $userId) use ($findUser): Option { 417 + return $findUser($userId) 418 + ->map(fn($user) => $user->profile) 419 + ->map(fn($profile) => $profile ? $profile->email : null) 420 + ->map(fn($email) => $email ? strtolower($email) : null); 421 + }; 422 + 423 + // User 123 has email 424 + $email1 = $findUserEmail(123)->unwrapOr('no-email@example.com'); 425 + $this->assertEquals('john@example.com', $email1); 426 + 427 + // User 456 has null profile - the map chain continues but returns null 428 + $email2 = $findUserEmail(456)->unwrapOr('no-email@example.com'); 429 + $this->assertNull($email2); // Option contains Some(null), so unwrapOr returns null 430 + 431 + // Non-existent user - this should return the default 432 + $email3 = $findUserEmail(999)->unwrapOr('no-email@example.com'); 433 + $this->assertEquals('no-email@example.com', $email3); 434 + } 435 + 436 + public function testComplexDataProcessingPipeline(): void 437 + { 438 + $processUserData = function(array $rawData): Option { 439 + return Option::Some($rawData) 440 + ->map(function($data) { 441 + // Validate required fields 442 + if (empty($data['name']) || empty($data['email'])) { 443 + return null; 444 + } 445 + return $data; 446 + }) 447 + ->map(function($data) { 448 + if ($data === null) return null; 449 + // Normalize data 450 + return [ 451 + 'name' => trim(ucwords(strtolower($data['name']))), 452 + 'email' => trim(strtolower($data['email'])), 453 + 'age' => isset($data['age']) ? (int)$data['age'] : null, 454 + ]; 455 + }) 456 + ->map(function($data) { 457 + if ($data === null) return null; 458 + // Add computed fields 459 + $data['initials'] = strtoupper(substr($data['name'], 0, 1)); 460 + $data['domain'] = explode('@', $data['email'])[1] ?? ''; 461 + return $data; 462 + }); 463 + }; 464 + 465 + // Valid data 466 + $validResult = $processUserData([ 467 + 'name' => ' JOHN DOE ', 468 + 'email' => ' John.Doe@EXAMPLE.COM ', 469 + 'age' => '25' 470 + ])->unwrapOr([]); 471 + 472 + $expected = [ 473 + 'name' => 'John Doe', 474 + 'email' => 'john.doe@example.com', 475 + 'age' => 25, 476 + 'initials' => 'J', 477 + 'domain' => 'example.com' 478 + ]; 479 + 480 + $this->assertEquals($expected, $validResult); 481 + 482 + // Invalid data (missing email) - Option will contain Some(null) 483 + $invalidResult = $processUserData([ 484 + 'name' => 'John Doe', 485 + // missing email 486 + ])->unwrapOr(['error' => 'Invalid data']); 487 + 488 + // Since the Option contains Some(null), unwrapOr returns null, not the default 489 + $this->assertNull($invalidResult); 490 + } 491 + 492 + // ========================================================================= 493 + // TYPE SAFETY AND CONSISTENCY TESTS 494 + // ========================================================================= 495 + 496 + public function testConsistentBehaviorAcrossDataTypes(): void 497 + { 498 + $testValues = [ 499 + 'string' => 'hello', 500 + 'integer' => 42, 501 + 'float' => 3.14, 502 + 'boolean_true' => true, 503 + 'boolean_false' => false, 504 + 'null' => null, 505 + 'array' => ['key' => 'value'], 506 + 'object' => (object)['prop' => 'value'], 507 + ]; 508 + 509 + foreach ($testValues as $type => $value) { 510 + $option = Option::Some($value); 511 + 512 + $this->assertTrue($option->isSome(), "isSome() should be true for {$type}"); 513 + $this->assertFalse($option->isNone(), "isNone() should be false for {$type}"); 514 + $this->assertSame($value, $option->unwrap(), "unwrap() should return original value for {$type}"); 515 + $this->assertSame($value, $option->unwrapOr('default'), "unwrapOr() should return original value for {$type}"); 516 + } 517 + } 518 + 519 + public function testMethodReturnTypes(): void 520 + { 521 + $some = Option::Some("test"); 522 + $none = Option::None(); 523 + 524 + // State methods should return boolean 525 + $this->assertIsBool($some->isSome()); 526 + $this->assertIsBool($some->isNone()); 527 + $this->assertIsBool($none->isSome()); 528 + $this->assertIsBool($none->isNone()); 529 + 530 + // Map should return Option instance 531 + $mapped = $some->map(fn($x) => $x); 532 + $this->assertInstanceOf(Option::class, $mapped); 533 + 534 + $mappedNone = $none->map(fn($x) => $x); 535 + $this->assertInstanceOf(Option::class, $mappedNone); 536 + } 537 + 538 + // ========================================================================= 539 + // PERFORMANCE AND MEMORY TESTS 540 + // ========================================================================= 541 + 542 + public function testLargeDataHandling(): void 543 + { 544 + $largeArray = range(1, 10000); 545 + $option = Option::Some($largeArray); 546 + 547 + $this->assertTrue($option->isSome()); 548 + $this->assertEquals(10000, count($option->unwrap())); 549 + 550 + $summed = $option->map(fn($arr) => array_sum($arr)); 551 + $this->assertEquals(50005000, $summed->unwrap()); // Sum of 1 to 10000 552 + } 553 + 554 + public function testDeepChaining(): void 555 + { 556 + $option = Option::Some(1); 557 + 558 + // Create a deep chain of 100 transformations 559 + for ($i = 0; $i < 100; $i++) { 560 + $option = $option->map(fn($x) => $x + 1); 561 + } 562 + 563 + $this->assertTrue($option->isSome()); 564 + $this->assertEquals(101, $option->unwrap()); 565 + } 6 566 }
+728 -2
tests/ResultTest.php
··· 1 1 <?php 2 2 3 + declare(strict_types=1); 4 + 5 + require_once __DIR__ . '/../vendor/autoload.php'; 6 + 3 7 use PHPUnit\Framework\TestCase; 8 + use Ciarancoza\OptionResult\Result; 9 + use Ciarancoza\OptionResult\Option; 10 + use Ciarancoza\OptionResult\Exceptions\UnwrapErrException; 11 + use Ciarancoza\OptionResult\Exceptions\UnwrapOkException; 4 12 5 - class ResultTest extends TestCase { 6 - } 13 + class ResultTest extends TestCase 14 + { 15 + // ============================================== 16 + // Static Constructor Tests 17 + // ============================================== 18 + 19 + public function testOkConstructor(): void 20 + { 21 + $result = Result::Ok("success"); 22 + $this->assertTrue($result->isOk()); 23 + $this->assertFalse($result->isErr()); 24 + $this->assertEquals("success", $result->unwrap()); 25 + } 26 + 27 + public function testOkConstructorWithNumber(): void 28 + { 29 + $result = Result::Ok(42); 30 + $this->assertTrue($result->isOk()); 31 + $this->assertEquals(42, $result->unwrap()); 32 + } 33 + 34 + public function testOkConstructorWithArray(): void 35 + { 36 + $data = ['key' => 'value']; 37 + $result = Result::Ok($data); 38 + $this->assertTrue($result->isOk()); 39 + $this->assertEquals($data, $result->unwrap()); 40 + } 41 + 42 + public function testOkConstructorWithObject(): void 43 + { 44 + $obj = (object) ['prop' => 'value']; 45 + $result = Result::Ok($obj); 46 + $this->assertTrue($result->isOk()); 47 + $this->assertEquals($obj, $result->unwrap()); 48 + } 49 + 50 + public function testErrConstructor(): void 51 + { 52 + $result = Result::Err("failure"); 53 + $this->assertFalse($result->isOk()); 54 + $this->assertTrue($result->isErr()); 55 + $this->assertEquals("failure", $result->unwrapErr()); 56 + } 57 + 58 + public function testErrConstructorWithNumber(): void 59 + { 60 + $result = Result::Err(404); 61 + $this->assertTrue($result->isErr()); 62 + $this->assertEquals(404, $result->unwrapErr()); 63 + } 64 + 65 + public function testErrConstructorWithArray(): void 66 + { 67 + $error = ['code' => 500, 'message' => 'Internal Error']; 68 + $result = Result::Err($error); 69 + $this->assertTrue($result->isErr()); 70 + $this->assertEquals($error, $result->unwrapErr()); 71 + } 72 + 73 + // ============================================== 74 + // Edge Cases with Falsy Values 75 + // ============================================== 76 + 77 + public function testOkWithNull(): void 78 + { 79 + $result = Result::Ok(null); 80 + $this->assertTrue($result->isOk()); 81 + $this->assertFalse($result->isErr()); 82 + $this->assertNull($result->unwrap()); 83 + } 84 + 85 + public function testOkWithFalse(): void 86 + { 87 + $result = Result::Ok(false); 88 + $this->assertTrue($result->isOk()); 89 + $this->assertFalse($result->unwrap()); 90 + } 91 + 92 + public function testOkWithZero(): void 93 + { 94 + $result = Result::Ok(0); 95 + $this->assertTrue($result->isOk()); 96 + $this->assertEquals(0, $result->unwrap()); 97 + } 98 + 99 + public function testOkWithEmptyString(): void 100 + { 101 + $result = Result::Ok(""); 102 + $this->assertTrue($result->isOk()); 103 + $this->assertEquals("", $result->unwrap()); 104 + } 105 + 106 + public function testOkWithEmptyArray(): void 107 + { 108 + $result = Result::Ok([]); 109 + $this->assertTrue($result->isOk()); 110 + $this->assertEquals([], $result->unwrap()); 111 + } 112 + 113 + public function testErrWithNull(): void 114 + { 115 + $result = Result::Err(null); 116 + $this->assertTrue($result->isErr()); 117 + $this->assertNull($result->unwrapErr()); 118 + } 119 + 120 + public function testErrWithFalse(): void 121 + { 122 + $result = Result::Err(false); 123 + $this->assertTrue($result->isErr()); 124 + $this->assertFalse($result->unwrapErr()); 125 + } 126 + 127 + public function testErrWithZero(): void 128 + { 129 + $result = Result::Err(0); 130 + $this->assertTrue($result->isErr()); 131 + $this->assertEquals(0, $result->unwrapErr()); 132 + } 133 + 134 + public function testErrWithEmptyString(): void 135 + { 136 + $result = Result::Err(""); 137 + $this->assertTrue($result->isErr()); 138 + $this->assertEquals("", $result->unwrapErr()); 139 + } 140 + 141 + // ============================================== 142 + // State Checking Tests 143 + // ============================================== 144 + 145 + public function testIsOkAndIsErr(): void 146 + { 147 + $testCases = [ 148 + ['value' => "success", 'isOk' => true, 'expectedIsOk' => true, 'expectedIsErr' => false], 149 + ['value' => 42, 'isOk' => true, 'expectedIsOk' => true, 'expectedIsErr' => false], 150 + ['value' => null, 'isOk' => true, 'expectedIsOk' => true, 'expectedIsErr' => false], 151 + ['value' => false, 'isOk' => true, 'expectedIsOk' => true, 'expectedIsErr' => false], 152 + ['value' => "", 'isOk' => true, 'expectedIsOk' => true, 'expectedIsErr' => false], 153 + ['value' => 0, 'isOk' => true, 'expectedIsOk' => true, 'expectedIsErr' => false], 154 + ['value' => "error", 'isOk' => false, 'expectedIsOk' => false, 'expectedIsErr' => true], 155 + ['value' => 500, 'isOk' => false, 'expectedIsOk' => false, 'expectedIsErr' => true], 156 + ['value' => null, 'isOk' => false, 'expectedIsOk' => false, 'expectedIsErr' => true], 157 + ['value' => false, 'isOk' => false, 'expectedIsOk' => false, 'expectedIsErr' => true], 158 + ]; 159 + 160 + foreach ($testCases as $case) { 161 + $result = $case['isOk'] ? Result::Ok($case['value']) : Result::Err($case['value']); 162 + $this->assertEquals($case['expectedIsOk'], $result->isOk()); 163 + $this->assertEquals($case['expectedIsErr'], $result->isErr()); 164 + } 165 + } 166 + 167 + // ============================================== 168 + // Value Extraction Tests 169 + // ============================================== 170 + 171 + public function testUnwrapOnOk(): void 172 + { 173 + $result = Result::Ok("data"); 174 + $this->assertEquals("data", $result->unwrap()); 175 + } 176 + 177 + public function testUnwrapOnErr(): void 178 + { 179 + $this->expectException(UnwrapErrException::class); 180 + $result = Result::Err("error"); 181 + $result->unwrap(); 182 + } 183 + 184 + public function testUnwrapErrOnOk(): void 185 + { 186 + $this->expectException(UnwrapOkException::class); 187 + $result = Result::Ok("data"); 188 + $result->unwrapErr(); 189 + } 190 + 191 + public function testUnwrapErrOnErr(): void 192 + { 193 + $result = Result::Err("error message"); 194 + $this->assertEquals("error message", $result->unwrapErr()); 195 + } 196 + 197 + public function testUnwrapOr(): void 198 + { 199 + $testCases = [ 200 + ['result' => Result::Ok("data"), 'default' => "default", 'expected' => "data"], 201 + ['result' => Result::Err("error"), 'default' => "default", 'expected' => "default"], 202 + ['result' => Result::Ok(null), 'default' => "default", 'expected' => null], 203 + ['result' => Result::Ok(false), 'default' => "default", 'expected' => false], 204 + ['result' => Result::Ok(0), 'default' => "default", 'expected' => 0], 205 + ['result' => Result::Ok(""), 'default' => "default", 'expected' => ""], 206 + ['result' => Result::Err("error"), 'default' => null, 'expected' => null], 207 + ['result' => Result::Err("error"), 'default' => false, 'expected' => false], 208 + ['result' => Result::Err("error"), 'default' => 0, 'expected' => 0], 209 + ]; 210 + 211 + foreach ($testCases as $case) { 212 + $this->assertEquals($case['expected'], $case['result']->unwrapOr($case['default'])); 213 + } 214 + } 215 + 216 + // ============================================== 217 + // Option Conversion Tests 218 + // ============================================== 219 + 220 + public function testGetOkOnOkResult(): void 221 + { 222 + $result = Result::Ok("data"); 223 + $option = $result->getOk(); 224 + 225 + $this->assertInstanceOf(Option::class, $option); 226 + $this->assertTrue($option->isSome()); 227 + $this->assertEquals("data", $option->unwrap()); 228 + } 229 + 230 + public function testGetOkOnErrResult(): void 231 + { 232 + $result = Result::Err("error"); 233 + $option = $result->getOk(); 234 + 235 + $this->assertInstanceOf(Option::class, $option); 236 + $this->assertTrue($option->isNone()); 237 + } 238 + 239 + public function testGetErrOnOkResult(): void 240 + { 241 + $result = Result::Ok("data"); 242 + $option = $result->getErr(); 243 + 244 + $this->assertInstanceOf(Option::class, $option); 245 + $this->assertTrue($option->isNone()); 246 + } 247 + 248 + public function testGetErrOnErrResult(): void 249 + { 250 + $result = Result::Err("error"); 251 + $option = $result->getErr(); 252 + 253 + $this->assertInstanceOf(Option::class, $option); 254 + $this->assertTrue($option->isSome()); 255 + $this->assertEquals("error", $option->unwrap()); 256 + } 257 + 258 + public function testGetOkWithFalsyValues(): void 259 + { 260 + $testCases = [null, false, 0, "", []]; 261 + 262 + foreach ($testCases as $value) { 263 + $result = Result::Ok($value); 264 + $option = $result->getOk(); 265 + 266 + $this->assertTrue($option->isSome()); 267 + $this->assertEquals($value, $option->unwrap()); 268 + } 269 + } 270 + 271 + public function testGetErrWithFalsyValues(): void 272 + { 273 + $testCases = [null, false, 0, "", []]; 274 + 275 + foreach ($testCases as $value) { 276 + $result = Result::Err($value); 277 + $option = $result->getErr(); 278 + 279 + $this->assertTrue($option->isSome()); 280 + $this->assertEquals($value, $option->unwrap()); 281 + } 282 + } 283 + 284 + // ============================================== 285 + // Map Transformation Tests 286 + // ============================================== 287 + 288 + public function testMapOnOkResult(): void 289 + { 290 + $result = Result::Ok(5); 291 + $mapped = $result->map(fn($x) => $x * 2); 292 + 293 + $this->assertTrue($mapped->isOk()); 294 + $this->assertEquals(10, $mapped->unwrap()); 295 + } 296 + 297 + public function testMapOnErrResult(): void 298 + { 299 + $result = Result::Err("database error"); 300 + $mapped = $result->map(fn($x) => $x * 2); 301 + 302 + $this->assertTrue($mapped->isErr()); 303 + $this->assertEquals("database error", $mapped->unwrapErr()); 304 + } 305 + 306 + public function testMapChaining(): void 307 + { 308 + $result = Result::Ok("hello") 309 + ->map(fn($s) => strtoupper($s)) // Result::Ok("HELLO") 310 + ->map(fn($s) => strlen($s)) // Result::Ok(5) 311 + ->map(fn($n) => $n > 3); // Result::Ok(true) 312 + 313 + $this->assertTrue($result->isOk()); 314 + $this->assertTrue($result->unwrap()); 315 + } 316 + 317 + public function testMapChainingWithError(): void 318 + { 319 + $result = Result::Err("connection failed") 320 + ->map(fn($s) => strtoupper($s)) // Result::Err("connection failed") 321 + ->map(fn($s) => strlen($s)) // Result::Err("connection failed") 322 + ->map(fn($n) => $n > 3); // Result::Err("connection failed") 323 + 324 + $this->assertTrue($result->isErr()); 325 + $this->assertEquals("connection failed", $result->unwrapErr()); 326 + } 327 + 328 + public function testMapWithFalsyValues(): void 329 + { 330 + // Test that falsy values are properly transformed 331 + $testCases = [ 332 + [null, fn($x) => "null", "null"], 333 + [false, fn($x) => "false", "false"], 334 + [0, fn($x) => $x + 1, 1], 335 + ["", fn($x) => "empty", "empty"], 336 + [[], fn($x) => count($x), 0], 337 + ]; 338 + 339 + foreach ($testCases as [$input, $mapper, $expected]) { 340 + $result = Result::Ok($input)->map($mapper); 341 + $this->assertTrue($result->isOk()); 342 + $this->assertEquals($expected, $result->unwrap()); 343 + } 344 + } 345 + 346 + public function testMapWithComplexTransformations(): void 347 + { 348 + $data = ['name' => 'john', 'age' => 30]; 349 + $result = Result::Ok($data) 350 + ->map(fn($user) => $user['name']) 351 + ->map(fn($name) => ucfirst($name)) 352 + ->map(fn($name) => "Hello, {$name}!"); 353 + 354 + $this->assertTrue($result->isOk()); 355 + $this->assertEquals("Hello, John!", $result->unwrap()); 356 + } 357 + 358 + // ============================================== 359 + // MapErr Transformation Tests 360 + // ============================================== 361 + 362 + public function testMapErrOnErrResult(): void 363 + { 364 + $result = Result::Err("database error"); 365 + $mapped = $result->mapErr(fn($e) => "Error: " . $e); 366 + 367 + $this->assertTrue($mapped->isErr()); 368 + $this->assertEquals("Error: database error", $mapped->unwrapErr()); 369 + } 370 + 371 + public function testMapErrOnOkResult(): void 372 + { 373 + $result = Result::Ok(5); 374 + $mapped = $result->mapErr(fn($e) => "Error: " . $e); 375 + 376 + $this->assertTrue($mapped->isOk()); 377 + $this->assertEquals(5, $mapped->unwrap()); 378 + } 379 + 380 + public function testMapErrChaining(): void 381 + { 382 + $result = Result::Err("connection") 383 + ->mapErr(fn($e) => $e . " failed") // "connection failed" 384 + ->mapErr(fn($e) => "Error: " . $e) // "Error: connection failed" 385 + ->mapErr(fn($e) => strtoupper($e)); // "ERROR: CONNECTION FAILED" 386 + 387 + $this->assertTrue($result->isErr()); 388 + $this->assertEquals("ERROR: CONNECTION FAILED", $result->unwrapErr()); 389 + } 390 + 391 + public function testMapErrWithFalsyValues(): void 392 + { 393 + $testCases = [ 394 + [null, fn($x) => "null error", "null error"], 395 + [false, fn($x) => "false error", "false error"], 396 + [0, fn($x) => "zero error", "zero error"], 397 + ["", fn($x) => "empty error", "empty error"], 398 + ]; 399 + 400 + foreach ($testCases as [$input, $mapper, $expected]) { 401 + $result = Result::Err($input)->mapErr($mapper); 402 + $this->assertTrue($result->isErr()); 403 + $this->assertEquals($expected, $result->unwrapErr()); 404 + } 405 + } 406 + 407 + public function testMixedMapAndMapErr(): void 408 + { 409 + // Test that map and mapErr can be used together 410 + $okResult = Result::Ok("success") 411 + ->map(fn($s) => strtoupper($s)) 412 + ->mapErr(fn($e) => "Error: " . $e); 413 + 414 + $this->assertTrue($okResult->isOk()); 415 + $this->assertEquals("SUCCESS", $okResult->unwrap()); 416 + 417 + $errResult = Result::Err("failure") 418 + ->map(fn($s) => strtoupper($s)) 419 + ->mapErr(fn($e) => "Error: " . $e); 420 + 421 + $this->assertTrue($errResult->isErr()); 422 + $this->assertEquals("Error: failure", $errResult->unwrapErr()); 423 + } 424 + 425 + // ============================================== 426 + // Exception Testing 427 + // ============================================== 428 + 429 + public function testUnwrapExceptionOnErr(): void 430 + { 431 + $this->expectException(UnwrapErrException::class); 432 + $result = Result::Err("something failed"); 433 + $result->unwrap(); 434 + } 435 + 436 + public function testUnwrapErrExceptionOnOk(): void 437 + { 438 + $this->expectException(UnwrapOkException::class); 439 + $result = Result::Ok("success"); 440 + $result->unwrapErr(); 441 + } 442 + 443 + public function testExceptionWithFalsyErrorValues(): void 444 + { 445 + $falsyValues = [null, false, 0, "", []]; 446 + 447 + foreach ($falsyValues as $value) { 448 + try { 449 + $result = Result::Err($value); 450 + $result->unwrap(); 451 + $this->fail("Expected UnwrapErrException was not thrown for value: " . var_export($value, true)); 452 + } catch (UnwrapErrException $e) { 453 + $this->assertInstanceOf(UnwrapErrException::class, $e); 454 + } 455 + } 456 + } 457 + 458 + public function testExceptionWithFalsyOkValues(): void 459 + { 460 + $falsyValues = [null, false, 0, "", []]; 461 + 462 + foreach ($falsyValues as $value) { 463 + try { 464 + $result = Result::Ok($value); 465 + $result->unwrapErr(); 466 + $this->fail("Expected UnwrapOkException was not thrown for value: " . var_export($value, true)); 467 + } catch (UnwrapOkException $e) { 468 + $this->assertInstanceOf(UnwrapOkException::class, $e); 469 + } 470 + } 471 + } 472 + 473 + // ============================================== 474 + // Real-World Scenario Tests 475 + // ============================================== 476 + 477 + public function testApiResponseScenario(): void 478 + { 479 + // Simulate successful API response processing 480 + $successResponse = ['user' => ['id' => 123, 'name' => 'john doe', 'email' => 'JOHN@EXAMPLE.COM']]; 481 + 482 + $result = Result::Ok($successResponse) 483 + ->map(fn($data) => $data['user']) 484 + ->map(fn($user) => [ 485 + 'id' => $user['id'], 486 + 'name' => ucfirst($user['name']), 487 + 'email' => strtolower($user['email']) 488 + ]) 489 + ->unwrapOr(['error' => 'User not found']); 490 + 491 + $expected = [ 492 + 'id' => 123, 493 + 'name' => 'John doe', 494 + 'email' => 'john@example.com' 495 + ]; 496 + 497 + $this->assertEquals($expected, $result); 498 + } 499 + 500 + public function testApiErrorScenario(): void 501 + { 502 + // Simulate failed API response processing 503 + $result = Result::Err("API request failed: 404") 504 + ->map(fn($data) => $data['user']) 505 + ->map(fn($user) => [ 506 + 'id' => $user['id'], 507 + 'name' => ucfirst($user['name']), 508 + 'email' => strtolower($user['email']) 509 + ]) 510 + ->mapErr(fn($error) => "Failed to process user: " . $error) 511 + ->unwrapOr(['error' => 'User not found']); 512 + 513 + $this->assertEquals(['error' => 'User not found'], $result); 514 + } 515 + 516 + public function testValidationChainScenario(): void 517 + { 518 + // Test a validation chain scenario 519 + $processInput = function(string $input) { 520 + if (empty(trim($input))) { 521 + return Result::Err("Input cannot be empty"); 522 + } 523 + 524 + $trimmed = trim($input); 525 + if (strlen($trimmed) > 10) { 526 + return Result::Err("Input too long"); 527 + } 528 + 529 + return Result::Ok(strtoupper($trimmed)); 530 + }; 531 + 532 + // Test successful validation 533 + $result1 = $processInput(" hello "); 534 + $this->assertTrue($result1->isOk()); 535 + $this->assertEquals("HELLO", $result1->unwrap()); 536 + 537 + // Test empty input 538 + $result2 = $processInput(" "); 539 + $this->assertTrue($result2->isErr()); 540 + $this->assertEquals("Input cannot be empty", $result2->unwrapErr()); 541 + 542 + // Test too long input 543 + $result3 = $processInput("this is way too long"); 544 + $this->assertTrue($result3->isErr()); 545 + $this->assertEquals("Input too long", $result3->unwrapErr()); 546 + } 547 + 548 + public function testDataPipelineScenario(): void 549 + { 550 + // Test a complex data transformation pipeline 551 + $data = [ 552 + 'users' => [ 553 + ['name' => ' alice ', 'score' => 85], 554 + ['name' => 'bob', 'score' => 92], 555 + ['name' => '', 'score' => 78] 556 + ] 557 + ]; 558 + 559 + $result = Result::Ok($data) 560 + ->map(fn($d) => $d['users']) 561 + ->map(fn($users) => array_filter($users, fn($u) => !empty(trim($u['name'])))) 562 + ->map(fn($users) => array_map(fn($u) => [ 563 + 'name' => ucfirst(trim($u['name'])), 564 + 'score' => $u['score'], 565 + 'grade' => $u['score'] >= 90 ? 'A' : ($u['score'] >= 80 ? 'B' : 'C') 566 + ], $users)); 567 + 568 + $this->assertTrue($result->isOk()); 569 + $users = $result->unwrap(); 570 + $this->assertCount(2, $users); // Empty name user filtered out 571 + $this->assertEquals('Alice', $users[0]['name']); 572 + $this->assertEquals('B', $users[0]['grade']); 573 + $this->assertEquals('Bob', $users[1]['name']); 574 + $this->assertEquals('A', $users[1]['grade']); 575 + } 576 + 577 + // ============================================== 578 + // Error Propagation Tests 579 + // ============================================== 580 + 581 + public function testErrorPropagationThroughLongChain(): void 582 + { 583 + $result = Result::Err("initial error") 584 + ->map(fn($x) => $x * 2) 585 + ->map(fn($x) => $x + 10) 586 + ->map(fn($x) => "Result: " . $x) 587 + ->mapErr(fn($e) => "Enhanced: " . $e) 588 + ->map(fn($x) => strtoupper($x)) 589 + ->mapErr(fn($e) => $e . " (final)"); 590 + 591 + $this->assertTrue($result->isErr()); 592 + $this->assertEquals("Enhanced: initial error (final)", $result->unwrapErr()); 593 + } 594 + 595 + public function testSuccessPropagationThroughLongChain(): void 596 + { 597 + $result = Result::Ok(5) 598 + ->map(fn($x) => $x * 2) // 10 599 + ->map(fn($x) => $x + 10) // 20 600 + ->map(fn($x) => "Result: " . $x) // "Result: 20" 601 + ->mapErr(fn($e) => "Enhanced: " . $e) // No effect on Ok 602 + ->map(fn($x) => strtoupper($x)) // "RESULT: 20" 603 + ->mapErr(fn($e) => $e . " (final)"); // No effect on Ok 604 + 605 + $this->assertTrue($result->isOk()); 606 + $this->assertEquals("RESULT: 20", $result->unwrap()); 607 + } 608 + 609 + // ============================================== 610 + // Type Safety and Identity Tests 611 + // ============================================== 612 + 613 + public function testResultIdentity(): void 614 + { 615 + $result1 = Result::Ok("data"); 616 + $result2 = Result::Ok("data"); 617 + $result3 = Result::Err("error"); 618 + 619 + // Different instances with same data should not be identical 620 + $this->assertNotSame($result1, $result2); 621 + 622 + // But their values should be equal 623 + $this->assertEquals($result1->unwrap(), $result2->unwrap()); 624 + 625 + // Different types should behave differently 626 + $this->assertNotEquals($result1->isOk(), $result3->isOk()); 627 + } 628 + 629 + public function testResultWithDifferentTypes(): void 630 + { 631 + $stringResult = Result::Ok("string"); 632 + $numberResult = Result::Ok(123); 633 + $arrayResult = Result::Ok(['key' => 'value']); 634 + $objectResult = Result::Ok((object)['prop' => 'value']); 635 + 636 + $this->assertIsString($stringResult->unwrap()); 637 + $this->assertIsInt($numberResult->unwrap()); 638 + $this->assertIsArray($arrayResult->unwrap()); 639 + $this->assertIsObject($objectResult->unwrap()); 640 + } 641 + 642 + // ============================================== 643 + // Integration with unwrapOr Tests 644 + // ============================================== 645 + 646 + public function testComplexChainWithUnwrapOr(): void 647 + { 648 + // Test a complete chain ending with unwrapOr for safe extraction 649 + $processData = function($input) { 650 + return Result::Ok($input) 651 + ->map(fn($data) => is_array($data) ? $data : []) 652 + ->map(fn($arr) => array_filter($arr, fn($x) => $x > 0)) 653 + ->map(fn($arr) => array_sum($arr)) 654 + ->unwrapOr(0); 655 + }; 656 + 657 + $this->assertEquals(15, $processData([1, 2, 3, 4, 5])); 658 + $this->assertEquals(9, $processData([1, -2, 3, -4, 5])); 659 + $this->assertEquals(0, $processData([])); 660 + $this->assertEquals(0, $processData("not an array")); 661 + 662 + // Test with error case 663 + $errorResult = Result::Err("processing failed") 664 + ->map(fn($x) => $x * 2) 665 + ->unwrapOr("default value"); 666 + 667 + $this->assertEquals("default value", $errorResult); 668 + } 669 + 670 + public function testUnwrapOrWithComplexDefaults(): void 671 + { 672 + $complexDefault = ['status' => 'error', 'data' => null, 'message' => 'Operation failed']; 673 + 674 + $result = Result::Err("network timeout") 675 + ->map(fn($data) => json_decode($data, true)) 676 + ->unwrapOr($complexDefault); 677 + 678 + $this->assertEquals($complexDefault, $result); 679 + } 680 + 681 + // ============================================== 682 + // Additional Edge Case Tests 683 + // ============================================== 684 + 685 + public function testResultMethodsPreserveTypeWithCallbacks(): void 686 + { 687 + // Test that transformations preserve the Result type through the chain 688 + $result = Result::Ok(['count' => 5]) 689 + ->map(fn($data) => $data['count']) 690 + ->map(fn($count) => $count * 2) 691 + ->map(fn($doubled) => $doubled > 5 ? "high" : "low"); 692 + 693 + $this->assertInstanceOf(Result::class, $result); 694 + $this->assertTrue($result->isOk()); 695 + $this->assertEquals("high", $result->unwrap()); 696 + } 697 + 698 + public function testChainedMethodsWithNullCallbackResults(): void 699 + { 700 + // Test behavior when callbacks return null 701 + $result = Result::Ok("input") 702 + ->map(fn($s) => null) // Callback returns null 703 + ->map(fn($n) => "processed"); 704 + 705 + $this->assertTrue($result->isOk()); 706 + $this->assertEquals("processed", $result->unwrap()); 707 + } 708 + 709 + public function testGetMethodsReturnCorrectOptionTypes(): void 710 + { 711 + $okResult = Result::Ok("success"); 712 + $errResult = Result::Err("failure"); 713 + 714 + // Test getOk returns Some for Ok and None for Err 715 + $okOption = $okResult->getOk(); 716 + $this->assertTrue($okOption->isSome()); 717 + $this->assertFalse($okOption->isNone()); 718 + 719 + $errOkOption = $errResult->getOk(); 720 + $this->assertFalse($errOkOption->isSome()); 721 + $this->assertTrue($errOkOption->isNone()); 722 + 723 + // Test getErr returns None for Ok and Some for Err 724 + $okErrOption = $okResult->getErr(); 725 + $this->assertFalse($okErrOption->isSome()); 726 + $this->assertTrue($okErrOption->isNone()); 727 + 728 + $errOption = $errResult->getErr(); 729 + $this->assertTrue($errOption->isSome()); 730 + $this->assertFalse($errOption->isNone()); 731 + } 732 + }