Rust-style Option and Result Classes for PHP
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}