Rust-style Option and Result Classes for PHP
at main 268 lines 8.9 kB view raw
1<?php 2 3use Ciarancoza\OptionResult\Exceptions\UnwrapNoneException; 4use Ciarancoza\OptionResult\Option; 5use PHPUnit\Framework\TestCase; 6 7class OptionTest extends TestCase 8{ 9 // Unit tests - Adapted from Rust Docs 10 11 public function test_is_none(): void 12 { 13 $x = Option::Some(2); 14 $this->assertSame(false, $x->isNone()); 15 16 $x = Option::None(); 17 $this->assertSame(true, $x->isNone()); 18 } 19 20 public function test_is_some(): void 21 { 22 $x = Option::Some(2); 23 $this->assertSame(true, $x->isSome()); 24 25 $x = Option::None(); 26 $this->assertSame(false, $x->isSome()); 27 } 28 29 public function test_unwrap(): void 30 { 31 $x = Option::Some('air'); 32 $this->assertSame('air', $x->unwrap()); 33 34 $this->expectException(UnwrapNoneException::class); 35 Option::None()->unwrap(); 36 } 37 38 public function test_unwrap_or(): void 39 { 40 $this->assertSame('car', Option::Some('car')->unwrapOr('bike')); 41 $this->assertSame('bike', Option::None()->unwrapOr('bike')); 42 } 43 44 public function test_unwrap_or_callable(): void 45 { 46 $k = 21; 47 $this->assertSame(4, Option::Some(4)->unwrapOr(fn () => 2 * $k)); 48 $this->assertSame(42, Option::None()->unwrapOr(fn () => 2 * $k)); 49 } 50 51 public function test_map(): void 52 { 53 $result = Option::Some('Hello World')->map(fn ($s) => strlen($s)); 54 $this->assertTrue($result->isSome()); 55 $this->assertSame(11, $result->unwrap()); 56 57 $result = Option::None()->map(fn ($s) => strlen($s)); 58 $this->assertTrue($result->isNone()); 59 } 60 61 public function test_map_or(): void 62 { 63 $this->assertSame(3, Option::Some('foo')->mapOr(42, fn ($v) => strlen($v))); 64 $this->assertSame(42, Option::None()->mapOr(42, fn ($v) => strlen($v))); 65 66 $this->assertSame(3, Option::Some('foo')->mapOr(fn () => 42, fn ($v) => strlen($v))); 67 $this->assertSame(42, Option::None()->mapOr(fn () => 42, fn ($v) => strlen($v))); 68 } 69 70 public function test_filter(): void 71 { 72 $result = Option::Some(4)->filter(fn ($x) => $x > 2); 73 $this->assertTrue($result->isSome()); 74 $this->assertSame(4, $result->unwrap()); 75 76 $result = Option::Some(1)->filter(fn ($x) => $x > 2); 77 $this->assertTrue($result->isNone()); 78 79 $result = Option::None()->filter(fn ($x) => $x > 2); 80 $this->assertTrue($result->isNone()); 81 } 82 83 public function test_expect(): void 84 { 85 $this->assertSame('value', Option::Some('value')->expect('fruits are healthy')); 86 87 $this->expectException(UnwrapNoneException::class); 88 $this->expectExceptionMessage('fruits are healthy'); 89 Option::None()->expect('fruits are healthy'); 90 } 91 92 public function test_and(): void 93 { 94 $result = Option::Some(2)->and(Option::Some(4)); 95 $this->assertTrue($result->isSome()); 96 $this->assertSame(4, $result->unwrap()); 97 98 $result = Option::None()->and(Option::Some(2)); 99 $this->assertTrue($result->isNone()); 100 101 // Returns None case 102 $result = Option::Some(2)->and(Option::None()); 103 $this->assertTrue($result->isNone()); 104 } 105 106 public function test_and_then(): void 107 { 108 $result = Option::Some(2)->andThen(fn ($x) => Option::Some($x * 2)); 109 $this->assertTrue($result->isSome()); 110 $this->assertSame(4, $result->unwrap()); 111 112 $result = Option::None()->andThen(fn ($x) => Option::Some($x * 2)); 113 $this->assertTrue($result->isNone()); 114 115 // Returns None case 116 $result = Option::Some(2)->andThen(fn ($_) => Option::None()); 117 $this->assertTrue($result->isNone()); 118 } 119 120 public function test_or(): void 121 { 122 $x = Option::Some(2); 123 $y = Option::None(); 124 $this->assertSame(2, $x->or($y)->unwrap()); 125 126 $x = Option::None(); 127 $y = Option::Some(100); 128 $this->assertSame(100, $x->or($y)->unwrap()); 129 130 $x = Option::Some(2); 131 $y = Option::Some(100); 132 $this->assertSame(2, $x->or($y)->unwrap()); 133 134 $x = Option::None(); 135 $y = Option::None(); 136 $this->assertTrue($x->or($y)->isNone()); 137 } 138 139 public function test_or_callable(): void 140 { 141 $nobody = fn () => Option::None(); 142 $vikings = fn () => Option::Some('vikings'); 143 $this->assertSame('barbarians', Option::Some('barbarians')->or($vikings)->unwrap()); 144 $this->assertSame('vikings', Option::None()->or($vikings)->unwrap()); 145 $this->assertTrue(Option::None()->or($nobody)->isNone()); 146 } 147 148 public function test_inspect(): void 149 { 150 $inspected = null; 151 $result = Option::Some(4)->inspect(function ($x) use (&$inspected) { 152 $inspected = $x; 153 }); 154 155 $this->assertTrue($result->isSome()); 156 $this->assertSame(4, $result->unwrap()); 157 $this->assertSame(4, $inspected); 158 159 $inspected = null; 160 $result = Option::None()->inspect(function ($x) use (&$inspected) { 161 $inspected = $x; 162 }); 163 164 $this->assertTrue($result->isNone()); 165 $this->assertNull($inspected); // Should not be called 166 167 $result = Option::Some(2)->inspect(function ($x) use (&$inspected) { 168 $x += $inspected; 169 })->unwrap(); 170 171 $this->assertSame(2, $result); 172 173 } 174 175 public function test_reduce(): void 176 { 177 $s12 = Option::Some(12); 178 $s17 = Option::Some(17); 179 $n = Option::None(); 180 $f = fn ($a, $b) => $a + $b; 181 182 $result = $s12->reduce($s17, $f); 183 $this->assertTrue($result->isSome()); 184 $this->assertSame(29, $result->unwrap()); 185 186 $result = $s12->reduce($n, $f); 187 $this->assertTrue($result->isSome()); 188 $this->assertSame(12, $result->unwrap()); 189 190 $result = $n->reduce($s17, $f); 191 $this->assertTrue($result->isSome()); 192 $this->assertSame(17, $result->unwrap()); 193 194 $result = $n->reduce($n, $f); 195 $this->assertTrue($result->isNone()); 196 } 197 198 // Integration tests 199 200 public function test_core_construction_and_state(): void 201 { 202 // Test Some with various values including falsy ones 203 $testValues = ['hello', 42, false, 0, '', []]; 204 foreach ($testValues as $value) { 205 $some = Option::Some($value); 206 $this->assertFalse($some->isNone()); 207 $this->assertSame($value, $some->unwrap()); 208 } 209 210 // Test default value 211 $defaultSome = Option::Some(); 212 $this->assertTrue($defaultSome->isSome()); 213 $this->assertSame(true, $defaultSome->unwrap()); 214 215 // Test null -> None value 216 $shouldBeNone = Option::Some(null); 217 $this->assertTrue($shouldBeNone->isNone()); 218 $this->expectException(UnwrapNoneException::class); 219 $shouldBeNone->unwrap(); 220 } 221 222 public function test_email_chain_scenario(): void 223 { 224 // Simulate the findUserEmail example from USAGE.md 225 $users = [ 226 123 => (object) [ 227 'id' => 123, 228 'profile' => (object) ['email' => 'JOHN@EXAMPLE.COM'], 229 ], 230 456 => (object) [ 231 'id' => 456, 232 'profile' => null, 233 ], 234 ]; 235 236 $findUser = fn (int $id): Option => isset($users[$id]) ? Option::Some($users[$id]) : Option::None(); 237 238 $findUserEmail = function (int $userId) use ($findUser): Option { 239 return $findUser($userId) 240 ->map(fn ($user) => $user->profile) 241 ->map(fn ($profile) => $profile ? $profile->email : null) 242 ->map(fn ($email) => $email ? strtolower($email) : null); 243 }; 244 245 $this->assertEquals('john@example.com', $findUserEmail(123)->unwrapOr('no-email@example.com')); 246 $this->assertEquals('no-email@example.com', $findUserEmail(456)->unwrapOr('no-email@example.com')); // Some(null) 247 $this->assertEquals('no-email@example.com', $findUserEmail(999)->unwrapOr('no-email@example.com')); 248 } 249 250 public function test_edge_cases(): void 251 { 252 $option = Option::Some('test'); 253 $this->assertEquals('test', $option->unwrap()); 254 $this->assertEquals('test', $option->unwrap()); 255 $this->assertEquals('test', $option->unwrapOr('default')); 256 257 $option = Option::Some(5); 258 $this->assertEquals(10, $option->map(fn ($x) => $x * 2)->unwrap()); 259 $this->assertEquals(15, $option->map(fn ($x) => $x * 3)->unwrap()); 260 $this->assertEquals(5, $option->unwrap()); // Original unchanged 261 262 $this->expectException(RuntimeException::class); 263 Option::Some('test')->map(fn ($_) => throw new RuntimeException('Test exception')); 264 265 // Exception on None does not throw (callback not executed) 266 $this->assertTrue(Option::None()->map(fn ($_) => throw new RuntimeException('Should not execute'))->isNone()); 267 } 268}