Rust-style Option and Result Classes for PHP

refactor(tests): redo units based on Rust examples, with a few integration / edge case tests

Ciaran 7ecfd6e4 74b56604

+275 -184
+109 -64
tests/OptionTest.php
··· 6 6 7 7 class OptionTest extends TestCase 8 8 { 9 - public function testCoreConstructionAndState(): void 9 + 10 + // Unit tests - Adapted from Rust Docs 11 + 12 + public function testIsNone(): void 10 13 { 11 - // Test Some with various values including falsy ones 12 - $testValues = ['hello', 42, null, false, 0, '', []]; 13 - foreach ($testValues as $value) { 14 - $some = Option::Some($value); 15 - $this->assertTrue($some->isSome()); 16 - $this->assertFalse($some->isNone()); 17 - $this->assertSame($value, $some->unwrap()); 18 - } 19 - 20 - // Test default value 21 - $defaultSome = Option::Some(); 22 - $this->assertTrue($defaultSome->isSome()); 23 - $this->assertSame(true, $defaultSome->unwrap()); 24 - 25 - // Test None 26 - $none = Option::None(); 27 - $this->assertFalse($none->isSome()); 28 - $this->assertTrue($none->isNone()); 14 + $x = Option::Some(2); 15 + $this->assertSame(false, $x->isNone()); 16 + 17 + $x = Option::None(); 18 + $this->assertSame(true, $x->isNone()); 29 19 } 30 20 31 - public function testValueExtraction(): void 21 + public function testIsSome(): void 32 22 { 33 - $this->assertEquals('value', Option::Some('value')->unwrap()); 23 + $x = Option::Some(2); 24 + $this->assertSame(true, $x->isSome()); 25 + 26 + $x = Option::None(); 27 + $this->assertSame(false, $x->isSome()); 28 + } 29 + 30 + public function testUnwrap(): void 31 + { 32 + $x = Option::Some("air"); 33 + $this->assertSame("air", $x->unwrap()); 34 + 34 35 $this->expectException(UnwrapNoneException::class); 35 36 Option::None()->unwrap(); 36 37 } 37 38 38 - public function testUnwrapOrBehavior(): void 39 + public function testUnwrapOr(): void 40 + { 41 + $this->assertSame("car", Option::Some("car")->unwrapOr("bike")); 42 + $this->assertSame("bike", Option::None()->unwrapOr("bike")); 43 + } 44 + 45 + public function testUnwrapOrElse(): void 46 + { 47 + $this->markTestIncomplete("TODO"); 48 + $k = 21; 49 + $this->assertSame(4, Option::Some(4)->unwrapOrElse(fn() => 2 * $k)); 50 + $this->assertSame(42, Option::None()->unwrapOrElse(fn() => 2 * $k)); 51 + } 52 + 53 + public function testMap(): void 54 + { 55 + $result = Option::Some("Hello World")->map(fn($s) => strlen($s)); 56 + $this->assertTrue($result->isSome()); 57 + $this->assertSame(11, $result->unwrap()); 58 + 59 + $result = Option::None()->map(fn($s) => strlen($s)); 60 + $this->assertTrue($result->isNone()); 61 + } 62 + 63 + public function testMapOr(): void 64 + { 65 + $this->markTestIncomplete("TODO"); 66 + $this->assertSame(3, Option::Some("foo")->mapOr(42, fn($v) => strlen($v))); 67 + $this->assertSame(42, Option::None()->mapOr(42, fn($v) => strlen($v))); 68 + } 69 + 70 + public function testMapOrElse(): void 71 + { 72 + $this->markTestIncomplete("TODO"); 73 + $k = 21; 74 + $this->assertSame(3, Option::Some("foo")->mapOrElse(fn() => 2 * $k, fn($v) => strlen($v))); 75 + $this->assertSame(42, Option::None()->mapOrElse(fn() => 2 * $k, fn($v) => strlen($v))); 76 + } 77 + 78 + public function testFilter(): void 79 + { 80 + $this->markTestIncomplete("TODO"); 81 + $result = Option::Some(4)->filter(fn($x) => $x > 2); 82 + $this->assertTrue($result->isSome()); 83 + $this->assertSame(4, $result->unwrap()); 84 + 85 + $result = Option::Some(1)->filter(fn($x) => $x > 2); 86 + $this->assertTrue($result->isNone()); 87 + 88 + $result = Option::None()->filter(fn($x) => $x > 2); 89 + $this->assertTrue($result->isNone()); 90 + } 91 + 92 + public function testExpect(): void 39 93 { 40 - $this->assertEquals('value', Option::Some('value')->unwrapOr('default')); 41 - $this->assertEquals('default', Option::None()->unwrapOr('default')); 42 - $this->assertNull(Option::Some(null)->unwrapOr('default')); 43 - $this->assertEquals(0, Option::None()->unwrapOr(0)); 44 - $defaultObject = (object)['default' => true]; 45 - $this->assertSame($defaultObject, Option::None()->unwrapOr($defaultObject)); 94 + $this->markTestIncomplete("TODO"); 95 + $this->assertSame("value", Option::Some("value")->expect("fruits are healthy")); 96 + 97 + $this->expectException(UnwrapNoneException::class); 98 + $this->expectExceptionMessage("fruits are healthy"); 99 + Option::None()->expect("fruits are healthy"); 46 100 } 47 101 48 - public function testMapTransformation(): void 102 + public function testAndThen(): void 49 103 { 50 - // Basic transformation on Some 51 - $some = Option::Some(5); 52 - $mapped = $some->map(fn($x) => $x * 2); 53 - $this->assertTrue($mapped->isSome()); 54 - $this->assertEquals(10, $mapped->unwrap()); 55 - 56 - // None short-circuits (callback not executed) 57 - $callbackExecuted = false; 58 - $noneResult = Option::None()->map(function($x) use (&$callbackExecuted) { 59 - $callbackExecuted = true; 60 - return $x * 2; 61 - }); 62 - $this->assertTrue($noneResult->isNone()); 63 - $this->assertFalse($callbackExecuted); 64 - 65 - // Map can return falsy values (they remain Some) 66 - $this->assertNull($some->map(fn($_) => null)->unwrap()); 67 - 68 - // Type transformations 69 - $stringToLength = Option::Some("hello")->map(fn($s) => strlen($s)); 70 - $this->assertEquals(5, $stringToLength->unwrap()); 71 - 72 - // String transformations 73 - $upperCase = Option::Some("hello")->map(fn($s) => strtoupper($s)); 74 - $this->assertEquals("HELLO", $upperCase->unwrap()); 104 + $this->markTestIncomplete("TODO"); 105 + $result = Option::Some(2)->andThen(fn($x) => Option::Some($x * 2)); 106 + $this->assertTrue($result->isSome()); 107 + $this->assertSame(4, $result->unwrap()); 108 + 109 + $result = Option::None()->andThen(fn($x) => Option::Some($x * 2)); 110 + $this->assertTrue($result->isNone()); 111 + 112 + // Returns None case 113 + $result = Option::Some(2)->andThen(fn($_) => Option::None()); 114 + $this->assertTrue($result->isNone()); 75 115 } 76 116 77 - public function testMethodChaining(): void 117 + // Integration tests 118 + 119 + public function testCoreConstructionAndState(): void 78 120 { 79 - $this->assertTrue(Option::Some("hello") 80 - ->map(fn($s) => strtoupper($s)) 81 - ->map(fn($s) => strlen($s)) 82 - ->map(fn($n) => $n > 3) 83 - ->unwrap()); 121 + // Test Some with various values including falsy ones 122 + $testValues = ['hello', 42, null, false, 0, '', []]; 123 + foreach ($testValues as $value) { 124 + $some = Option::Some($value); 125 + $this->assertTrue($some->isSome()); 126 + $this->assertFalse($some->isNone()); 127 + $this->assertSame($value, $some->unwrap()); 128 + } 84 129 85 - $this->assertEquals(0, Option::None() 86 - ->map(fn($s) => strtoupper($s)) 87 - ->map(fn($s) => strlen($s)) 88 - ->unwrapOr(0)); 130 + // Test default value 131 + $defaultSome = Option::Some(); 132 + $this->assertTrue($defaultSome->isSome()); 133 + $this->assertSame(true, $defaultSome->unwrap()); 89 134 } 90 135 91 136 public function testEmailChainScenario(): void
+166 -120
tests/ResultTest.php
··· 11 11 12 12 class ResultTest extends TestCase 13 13 { 14 + 15 + // Unit tests - Adapted from Rust Docs 16 + 17 + public function testIsOk(): void 18 + { 19 + $x = Result::Ok(-3); 20 + $this->assertSame(true, $x->isOk()); 21 + 22 + $x = Result::Err("Some error message"); 23 + $this->assertSame(false, $x->isOk()); 24 + } 25 + 26 + public function testIsErr(): void 27 + { 28 + $x = Result::Ok(-3); 29 + $this->assertSame(false, $x->isErr()); 30 + 31 + $x = Result::Err("Some error message"); 32 + $this->assertSame(true, $x->isErr()); 33 + } 34 + 35 + public function testUnwrap(): void 36 + { 37 + $x = Result::Ok(2); 38 + $this->assertSame(2, $x->unwrap()); 39 + 40 + $this->expectException(UnwrapErrException::class); 41 + Result::Err("emergency failure")->unwrap(); 42 + } 43 + 44 + public function testUnwrapErr(): void 45 + { 46 + $x = Result::Err("emergency failure"); 47 + $this->assertSame("emergency failure", $x->unwrapErr()); 48 + 49 + $this->expectException(UnwrapOkException::class); 50 + Result::Ok(2)->unwrapErr(); 51 + } 52 + 53 + public function testUnwrapOr(): void 54 + { 55 + $default = 2; 56 + $this->assertSame(9, Result::Ok(9)->unwrapOr($default)); 57 + $this->assertSame($default, Result::Err("error")->unwrapOr($default)); 58 + } 59 + 60 + public function testUnwrapOrElse(): void 61 + { 62 + $this->markTestIncomplete("TODO"); 63 + $this->assertSame(2, Result::Ok(2)->unwrapOrElse(fn($err) => strlen($err))); 64 + $this->assertSame(3, Result::Err("foo")->unwrapOrElse(fn($err) => strlen($err))); 65 + } 66 + 67 + public function testMap(): void 68 + { 69 + $result = Result::Ok(5)->map(fn($i) => $i * 2); 70 + $this->assertTrue($result->isOk()); 71 + $this->assertSame(10, $result->unwrap()); 72 + 73 + $result = Result::Err("parse error")->map(fn($i) => $i * 2); 74 + $this->assertTrue($result->isErr()); 75 + $this->assertSame("parse error", $result->unwrapErr()); 76 + } 77 + 78 + public function testMapErr(): void 79 + { 80 + $stringify = fn($x) => "error code: $x"; 81 + 82 + $result = Result::Ok(2)->mapErr($stringify); 83 + $this->assertTrue($result->isOk()); 84 + $this->assertSame(2, $result->unwrap()); 85 + 86 + $result = Result::Err(13)->mapErr($stringify); 87 + $this->assertTrue($result->isErr()); 88 + $this->assertSame("error code: 13", $result->unwrapErr()); 89 + } 90 + 91 + public function testMapOr(): void 92 + { 93 + $this->markTestIncomplete("TODO"); 94 + $this->assertSame(3, Result::Ok("foo")->mapOr(42, fn($v) => strlen($v))); 95 + $this->assertSame(42, Result::Err("bar")->mapOr(42, fn($v) => strlen($v))); 96 + } 97 + 98 + public function testMapOrElse(): void 99 + { 100 + $this->markTestIncomplete("TODO"); 101 + $this->assertSame(3, Result::Ok("foo")->mapOrElse(fn($err) => strlen($err), fn($v) => strlen($v))); 102 + $this->assertSame(3, Result::Err("bar")->mapOrElse(fn($err) => strlen($err), fn($v) => strlen($v))); 103 + } 104 + 105 + public function testGetOk(): void 106 + { 107 + $option = Result::Ok(2)->getOk(); 108 + $this->assertTrue($option->isSome()); 109 + $this->assertSame(2, $option->unwrap()); 110 + 111 + $option = Result::Err("Nothing here")->getOk(); 112 + $this->assertTrue($option->isNone()); 113 + } 114 + 115 + public function testGetErr(): void 116 + { 117 + $option = Result::Ok(2)->getErr(); 118 + $this->assertTrue($option->isNone()); 119 + 120 + $option = Result::Err("Nothing here")->getErr(); 121 + $this->assertTrue($option->isSome()); 122 + $this->assertSame("Nothing here", $option->unwrap()); 123 + } 124 + 125 + public function testExpect(): void 126 + { 127 + $this->markTestIncomplete("TODO"); 128 + $this->assertSame("value", Result::Ok("value")->expect("Testing expect")); 129 + 130 + $this->expectException(UnwrapErrException::class); 131 + $this->expectExceptionMessage("Testing expect"); 132 + Result::Err("error")->expect("Testing expect"); 133 + } 134 + 135 + public function testExpectErr(): void 136 + { 137 + $this->markTestIncomplete("TODO"); 138 + $this->assertSame("value", Result::Err("value")->expectErr("Testing expect_err")); 139 + 140 + $this->expectException(UnwrapOkException::class); 141 + $this->expectExceptionMessage("Testing expect_err"); 142 + Result::Ok("error")->expectErr("Testing expect_err"); 143 + } 144 + 145 + public function testAndThen(): void 146 + { 147 + $this->markTestIncomplete("TODO"); 148 + $result = Result::Ok(2)->andThen(fn($x) => Result::Ok($x * 2)); 149 + $this->assertTrue($result->isOk()); 150 + $this->assertSame(4, $result->unwrap()); 151 + 152 + $result = Result::Err("error")->andThen(fn($x) => Result::Ok($x * 2)); 153 + $this->assertTrue($result->isErr()); 154 + $this->assertSame("error", $result->unwrapErr()); 155 + 156 + // Returns Err case 157 + $result = Result::Ok(2)->andThen(fn($_) => Result::Err("new error")); 158 + $this->assertTrue($result->isErr()); 159 + $this->assertSame("new error", $result->unwrapErr()); 160 + } 161 + 162 + public function testTryCatch(): void 163 + { 164 + $this->markTestIncomplete("TODO"); 165 + $result = Result::tryCatch(fn() => "success", fn($e) => "Error: " . $e->getMessage()); 166 + $this->assertTrue($result->isOk()); 167 + $this->assertSame("success", $result->unwrap()); 168 + 169 + $result = Result::tryCatch( 170 + fn() => throw new Exception("something failed"), 171 + fn($e) => "Error: " . $e->getMessage() 172 + ); 173 + $this->assertTrue($result->isErr()); 174 + $this->assertSame("Error: something failed", $result->unwrapErr()); 175 + } 176 + 177 + // Integration tests 178 + 14 179 public function testCoreConstructionAndState(): void 15 180 { 16 181 $testValues = ['success', 42, null, false, 0, '', []]; ··· 29 194 $this->assertSame(true, Result::Ok()->unwrap()); 30 195 } 31 196 32 - public function testExceptionBehavior(): void 33 - { 34 - $this->expectException(UnwrapErrException::class); 35 - Result::Err('error')->unwrap(); 36 - 37 - $this->expectException(UnwrapOkException::class); 38 - Result::Ok('data')->unwrapErr(); 39 - 40 - foreach ([null, false, 0, '', []] as $value) { 41 - try { 42 - Result::Err($value)->unwrap(); 43 - $this->fail("Expected exception for falsy error value"); 44 - } catch (UnwrapErrException $e) { 45 - $this->assertInstanceOf(UnwrapErrException::class, $e); 46 - } 47 - } 48 - } 49 - 50 - public function testUnwrapOrBehavior(): void 51 - { 52 - $this->assertEquals('data', Result::Ok('data')->unwrapOr('default')); 53 - $this->assertEquals('default', Result::Err('error')->unwrapOr('default')); 54 - $this->assertNull(Result::Ok(null)->unwrapOr('default')); 55 - 56 - $this->assertEquals(0, Result::Err('error')->unwrapOr(0)); 57 - $this->assertFalse(Result::Ok(false)->unwrapOr('default')); 58 - } 59 - 60 197 public function testOptionConversion(): void 61 198 { 62 199 $ok = Result::Ok('data'); ··· 76 213 } 77 214 } 78 215 79 - public function testMapTransformation(): void 80 - { 81 - $this->assertEquals(10, Result::Ok(5)->map(fn($x) => $x * 2)->unwrap()); 82 - 83 - $callbackExecuted = false; 84 - $errResult = Result::Err('error')->map(function($x) use (&$callbackExecuted) { 85 - $callbackExecuted = true; 86 - return $x * 2; 87 - }); 88 - $this->assertTrue($errResult->isErr()); 89 - $this->assertEquals('error', $errResult->unwrapErr()); 90 - $this->assertFalse($callbackExecuted); 91 - 92 - $this->assertNull(Result::Ok(5)->map(fn($_) => null)->unwrap()); 93 - $this->assertEquals(5, Result::Ok("hello")->map(fn($s) => strlen($s))->unwrap()); 94 - 95 - $this->assertTrue(Result::Ok("hello") 96 - ->map(fn($s) => strtoupper($s)) 97 - ->map(fn($s) => strlen($s)) 98 - ->map(fn($n) => $n > 3) 99 - ->unwrap()); 100 - 101 - $this->assertEquals("connection failed", Result::Err("connection failed") 102 - ->map(fn($s) => strtoupper($s)) 103 - ->map(fn($s) => strlen($s)) 104 - ->unwrapErr()); 105 - } 106 - 107 - public function testMapErrTransformation(): void 108 - { 109 - $this->assertEquals("Error: database error", 110 - Result::Err("database error")->mapErr(fn($e) => "Error: " . $e)->unwrapErr()); 111 - 112 - $callbackExecuted = false; 113 - $okResult = Result::Ok('data')->mapErr(function($e) use (&$callbackExecuted) { 114 - $callbackExecuted = true; 115 - return "Error: " . $e; 116 - }); 117 - $this->assertEquals('data', $okResult->unwrap()); 118 - $this->assertFalse($callbackExecuted); 119 - 120 - $this->assertEquals("ERROR: CONNECTION FAILED", Result::Err("connection") 121 - ->mapErr(fn($e) => $e . " failed") 122 - ->mapErr(fn($e) => "Error: " . $e) 123 - ->mapErr(fn($e) => strtoupper($e)) 124 - ->unwrapErr()); 125 - 126 - $this->assertEquals("SUCCESS", Result::Ok("success") 127 - ->map(fn($s) => strtoupper($s)) 128 - ->mapErr(fn($e) => "Error: " . $e) 129 - ->unwrap()); 130 - } 131 - 132 - public function testMethodChaining(): void 133 - { 134 - $this->assertEquals("RESULT: 20", Result::Ok(5) 135 - ->map(fn($x) => $x * 2) 136 - ->map(fn($x) => $x + 10) 137 - ->map(fn($x) => "Result: " . $x) 138 - ->mapErr(fn($e) => "Enhanced: " . $e) 139 - ->map(fn($x) => strtoupper($x)) 140 - ->unwrap()); 141 - 142 - $this->assertEquals("Enhanced: initial error (final)", Result::Err("initial error") 143 - ->map(fn($x) => $x * 2) 144 - ->mapErr(fn($e) => "Enhanced: " . $e) 145 - ->map(fn($x) => strtoupper($x)) 146 - ->mapErr(fn($e) => $e . " (final)") 147 - ->unwrapErr()); 148 - } 149 - 150 216 public function testApiResponseScenario(): void 151 217 { 152 218 $successResponse = ['user' => ['id' => 123, 'name' => 'john doe', 'email' => 'JOHN@EXAMPLE.COM']]; ··· 202 268 $this->assertEquals(['name' => 'Bob', 'score' => 92, 'grade' => 'A'], $users[1]); 203 269 } 204 270 205 - public function testEdgeCases(): void 206 - { 207 - $ok = Result::Ok("test"); 208 - $this->assertEquals("test", $ok->unwrap()); 209 - $this->assertEquals("test", $ok->unwrapOr("default")); 210 - 211 - $err = Result::Err("error"); 212 - $this->assertEquals("error", $err->unwrapErr()); 213 - $this->assertEquals("default", $err->unwrapOr("default")); 214 - 215 - $complexDefault = ['status' => 'error', 'data' => null]; 216 - $this->assertEquals($complexDefault, Result::Err("network timeout") 217 - ->map(fn($data) => json_decode($data, true)) 218 - ->unwrapOr($complexDefault)); 219 - 220 - $this->assertEquals("processed", Result::Ok("input") 221 - ->map(fn($_) => null) 222 - ->map(fn($_) => "processed") 223 - ->unwrap()); 224 - } 225 - } 271 + }