diff --git a/composer.json b/composer.json index c45c119..cb677fd 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,7 @@ "rss": "https://git.bitbadger.solutions/bit-badger/inspired-by-fsharp.rss" }, "require": { - "php": "^8" + "php": "^8.2" }, "require-dev": { "phpunit/phpunit": "^11", diff --git a/src/Option.php b/src/Option.php index 8923d5a..52448e2 100644 --- a/src/Option.php +++ b/src/Option.php @@ -8,6 +8,7 @@ declare(strict_types=1); namespace BitBadger\InspiredByFSharp; +use Error; use InvalidArgumentException; /** @@ -25,7 +26,7 @@ use InvalidArgumentException; */ readonly class Option { - /** @var ?T $value The value for this option */ + /** @var T|null $value The value for this option */ private mixed $value; /** @@ -49,6 +50,138 @@ readonly class Option }; } + /** + * Does this option have a `None` value? + * + * @return bool True if the option is `None`, false if it is `Some` + */ + public function isNone(): bool + { + return is_null($this->value); + } + + /** + * Does this option have a `Some` value? + * + * @return bool True if the option is `Some`, false if it is `None` + */ + public function isSome(): bool + { + return !$this->isNone(); + } + + /** + * Get the value, or a default value, from an option + * + * @param T $default The default value to return if the option is `None` + * @return T The `Some` value, or the default value if the option is `None` + */ + public function getOrDefault(mixed $default): mixed + { + return $this->value ?? $default; + } + + /** + * Get the value, or return the value of a callable function + * + * @template U The return type of the callable provided + * @param callable(): U $f The callable function to use for `None` options + * @return T|mixed The value if `Some`, the result of the callable if `None` + */ + public function getOrCall(callable $f): mixed + { + return $this->value ?? $f(); + } + + /** + * Map this optional value to another value + * + * @template U The type of the mapping function + * @param callable(T): U $f The mapping function + * @return Option A `Some` instance with the transformed value if `Some`, `None` otherwise + */ + public function map(callable $f): self + { + return $this->isSome() ? self::Some($f($this->get())) : $this; + } + + /** + * Execute a function on the value (if it exists) + * + * @param callable(T): void $f The function to call + */ + public function iter(callable $f): void + { + if ($this->isSome()) { + $f($this->value); + } + } + + /** + * Transform an option into `None` if it does not match the given function + * + * @param callable(T): bool $f The filter function to run + * @return Option The option, if it was `Some` and the function returned true; `None` otherwise + */ + public function filter(callable $f): self + { + return match (true) { + $this->isNone() => $this, + default => $f($this->value) ? $this : self::None(), + }; + } + + /** + * Does the option have the given value? + * + * @param T $value The value to be checked + * @param bool $strict True for strict equality (`===`), false for loose equality (`==`) + * @return bool True if the value matches, false if not; `None` always returns false + */ + public function is(mixed $value, bool $strict = true): bool + { + return match (true) { + $this->isNone() => false, + default => $strict ? $this->value === $value : $this->value == $value, + }; + } + + /** + * Safely retrieve the optional value as a nullable value + * + * @return T|null The value for `Some` instances, `null` for `None` instances + */ + public function unwrap(): mixed + { + return $this->value; + } + + /** + * Tap into the `Result` for a secondary action, returning the result + * + * @param callable(Option): mixed $f The function to run (return value is ignored) + * @return Option The same option provided + */ + public function tap(callable $f): Option + { + $f($this); + return $this; + } + + /** + * Convert this to a PhpOption option + * + * @return mixed An option from the PhpOption library + */ + public function toPhpOption(): mixed + { + return match (true) { + $this->isNone() && class_exists('PhpOption\None') => call_user_func('PhpOption\None::create'), + class_exists('PhpOption\Some') => call_user_func('PhpOption\Some::create', $this->value), + default => throw new Error('PhpOption types could not be found'), + }; + } + /** * Create a `Some` option with the given value * @@ -83,136 +216,8 @@ readonly class Option { return match (true) { is_object($value) && is_a($value, 'PhpOption\Option') => - $value->isDefined() ? self::Some($value->get()) : self::None(), + $value->isDefined() ? self::Some($value->get()) : self::None(), default => new self($value), }; } - - /** - * Does this option have a `None` value? - * - * @param Option $it The option in question - * @return bool True if the option is `None`, false if it is `Some` - */ - public static function isNone(Option $it): bool - { - return is_null($it->value); - } - - /** - * Does this option have a `Some` value? - * - * @param Option $it The option in question - * @return bool True if the option is `Some`, false if it is `None` - */ - public static function isSome(Option $it): bool - { - return !self::isNone($it); - } - - /** - * Get the value, or a default value, from an option - * - * @param T $default The default value to return if the option is `None` - * @param Option $it The option in question - * @return T The `Some` value, or the default value if the option is `None` - */ - public static function defaultValue(mixed $default, Option $it): mixed - { - return self::isSome($it) ? $it->get() : $default; - } - - /** - * Get the value, or return the value of a callable function - * - * @template U The return type of the callable provided - * @param callable(): U $f The callable function to use for `None` options - * @param Option $it The option in question - * @return T|mixed The value if `Some`, the result of the callable if `None` - */ - public static function getOrCall(callable $f, Option $it): mixed - { - return self::isSome($it) ? $it->get() : $f(); - } - - /** - * Map this optional value to another value - * - * @template U The type of the mapping function - * @param callable(T): U $f The mapping function - * @param Option $it The option in question - * @return Option A `Some` instance with the transformed value if `Some`, `None` otherwise - */ - public static function map(callable $f, Option $it): self - { - return self::isSome($it) ? self::Some($f($it->get())) : self::None(); - } - - /** - * Execute a function on the value (if it exists) - * - * @param callable(T): void $f The function to call - * @param Option $it The option in question - */ - public static function iter(callable $f, Option $it): void - { - if (self::isSome($it)) { - $f($it->get()); - } - } - - /** - * Transform an option into `None` if it does not match the given function - * - * @param callable(T): bool $f The filter function to run - * @param Option $it The option in question - * @return Option The option, if it was `Some` and the function returned true; `None` otherwise - */ - public static function filter(callable $f, Option $it): self - { - return match (true) { - self::isNone($it) => self::None(), - default => $f($it->get()) ? self::Some($it->get()) : self::None(), - }; - } - - /** - * Does the option have the given value? - * - * @param T $value The value to be checked - * @param Option $it The option in question - * @param bool $strict True for strict equality (`===`), false for loose equality (`==`) - * @return bool True if the value matches, false if not; `None` always returns false - */ - public static function is(mixed $value, Option $it, bool $strict = true): bool - { - return match (true) { - self::isNone($it) => false, - default => $strict ? $it->value === $value : $it->value == $value, - }; - } - - /** - * Safely retrieve the optional value as a nullable value - * - * @param Option $it The option in question - * @return ?T The value for `Some` instances, `null` for `None` instances - */ - public static function unwrap(Option $it): mixed - { - return self::isSome($it) ? $it->get() : null; - } - - /** - * Tap into the `Result` for a secondary action, returning the result - * - * @param callable(Option): mixed $f The function to run (return value is ignored) - * @param Option $it The option against which the function should be run - * @return Option The same option provided - */ - public static function tap(callable $f, Option $it): Option - { - $f($it); - return $it; - } } diff --git a/src/Result.php b/src/Result.php index df44dac..e626c23 100644 --- a/src/Result.php +++ b/src/Result.php @@ -66,6 +66,84 @@ readonly class Result return $this->errorValue->get(); } + /** + * Is this result `OK`? + * + * @return bool True if the result is `OK`, false if it is `Error` + */ + public function isOK(): bool + { + return $this->okValue->isSome(); + } + + /** + * Is this result `Error`? + * + * @return bool True if the result is `Error`, false if it is `OK` + */ + public function isError(): bool + { + return $this->errorValue->isSome(); + } + + /** + * Map an `OK` result to another, leaving an `Error` result unmodified + * + * @template U The type of the mapping function + * @param callable(TOK): U $f The mapping function + * @return Result A transformed `OK` instance or the original `Error` instance + */ + public function map(callable $f): self + { + return $this->isOK() ? self::OK($f($this->getOK())) : $this; + } + + /** + * Map an `Error` result to another, leaving an `OK` result unmodified + * + * @template U The type of the mapping function + * @param callable(TError): U $f The mapping function + * @return Result A transformed `Error` instance or the original `OK` instance + */ + public function mapError(callable $f): self + { + return $this->isError() ? self::Error($f($this->getError())) : $this; + } + + /** + * Execute a function on an `OK` value (if it exists) + * + * @param callable(TOK): void $f The function to call + */ + public function iter(callable $f): void + { + if ($this->isOK()) { + $f($this->getOK()); + } + } + + /** + * Transform a `Result`'s `OK` value to an `Option` + * + * @return Option A `Some` option with the OK value if `OK`, `None` if `Error` + */ + public function toOption(): Option + { + return $this->isOK() ? Option::Some($this->getOK()) : Option::None(); + } + + /** + * Tap into the `Result` for a secondary action, returning the result + * + * @param callable(Result): mixed $f The function to run (return value is ignored) + * @return Result The same result provided + */ + public function tap(callable $f): Result + { + $f($this); + return $this; + } + /** * Create an `OK` result * @@ -93,89 +171,4 @@ readonly class Result } return new self(errorValue: $value); } - - /** - * Is the given result `OK`? - * - * @param Result $it The result in question - * @return bool True if the result is `OK`, false if it is `Error` - */ - public static function isOK(Result $it): bool - { - return Option::isSome($it->okValue); - } - - /** - * Is the given result `Error`? - * - * @param Result $it The result in question - * @return bool True if the result is `Error`, false if it is `OK` - */ - public static function isError(Result $it): bool - { - return Option::isSome($it->errorValue); - } - - /** - * Map an `OK` result to another, leaving an `Error` result unmodified - * - * @template U The type of the mapping function - * @param callable(TOK): U $f The mapping function - * @param Result $it The result in question - * @return Result A transformed `OK` instance, or an `Error` instance with the same value - */ - public static function map(callable $f, Result $it): self - { - return self::isOK($it) ? self::OK($f($it->getOK())) : self::Error($it->getError()); - } - - /** - * Map an `Error` result to another, leaving an `OK` result unmodified - * - * @template U The type of the mapping function - * @param callable(TError): U $f The mapping function - * @param Result $it The result in question - * @return Result A transformed `Error` instance, or an `OK` instance with the same value - */ - public static function mapError(callable $f, Result $it): self - { - return self::isError($it) ? self::Error($f($it->getError())) : self::OK($it->getOK()); - } - - /** - * Execute a function on an `OK` value (if it exists) - * - * @param callable(TOK): void $f The function to call - * @param Result $it The result in question - */ - public static function iter(callable $f, Result $it): void - { - if (self::isOK($it)) { - $f($it->getOK()); - } - } - - /** - * Transform a `Result`'s `OK` value to an `Option` - * - * @param Result $it The result in question - * @return Option A `Some` option with the OK value if `OK`, `None` if `Error` - */ - public static function toOption(Result $it): Option - { - return Result::isOK($it) ? Option::Some($it->getOK()) : Option::None(); - } - - /** - * Tap into the `Result` for a secondary action, returning the result - * - * @param callable(Result): mixed $f The function to run (return value is ignored) - * @param Result $it The result against which the function should be run - * @return Result The same result provided - */ - public static function tap(callable $f, Result $it): Result - { - $f($it); - return $it; - } } diff --git a/tests/OptionTest.php b/tests/OptionTest.php index 19c4b7f..1999360 100644 --- a/tests/OptionTest.php +++ b/tests/OptionTest.php @@ -23,7 +23,7 @@ class OptionTest extends TestCase public function testGetSucceedsForSome(): void { $it = Option::Some(9); - $this->assertTrue(Option::isSome($it), 'The option should have been "Some"'); + $this->assertTrue($it->isSome(), 'The option should have been "Some"'); $this->assertEquals(9, $it->get(), 'The value was incorrect'); } @@ -34,10 +34,223 @@ class OptionTest extends TestCase Option::None()->get(); } + #[TestDox('IsNone succeeds with None')] + public function testIsNoneSucceedsWithNone(): void + { + $this->assertTrue(Option::None()->isNone(), '"None" should return true'); + } + + #[TestDox('IsNone succeeds with Some')] + public function testIsNoneSucceedsWithSome(): void + { + $this->assertFalse(Option::Some(8)->isNone(), '"Some" should return false'); + } + + #[TestDox('IsSome succeeds with None')] + public function testIsSomeSucceedsWithNone(): void + { + $this->assertFalse(Option::None()->isSome(), '"None" should return false'); + } + + #[TestDox('IsSome succeeds with Some')] + public function testIsSomeSucceedsWithSome(): void + { + $this->assertTrue(Option::Some('boo')->isSome(), '"Some" should return true'); + } + + #[TestDox('GetOrDefault succeeds with None')] + public function testGetOrDefaultSucceedsWithNone(): void + { + $this->assertEquals('yes', Option::None()->getOrDefault('yes'), 'Value should have been default'); + } + + #[TestDox('GetOrDefault succeeds with Some')] + public function testGetOrDefaultSucceedsWithSome(): void + { + $this->assertEquals('no', Option::Some('no')->getOrDefault('yes'), 'Value should have been from option'); + } + + #[TestDox('GetOrCall succeeds with None')] + public function testGetOrCallSucceedsWithNone(): void + { + $value = Option::None()->getOrCall(new class { public function __invoke(): string { return 'called'; } }); + $this->assertEquals('called', $value, 'The value should have been obtained from the callable'); + } + + #[TestDox('GetOrCall succeeds with Some')] + public function testGetOrCallSucceedsWithSome(): void + { + $value = Option::Some('passed')->getOrCall( + new class { public function __invoke(): string { return 'called'; } }); + $this->assertEquals('passed', $value, 'The value should have been obtained from the option'); + } + + #[TestDox('Map succeeds with None')] + public function testMapSucceedsWithNone(): void + { + $tattle = new class { public bool $called = false; }; + $none = Option::None(); + $mapped = $none->map(function () use ($tattle) + { + $tattle->called = true; + return 'hello'; + }); + $this->assertTrue($mapped->isNone(), 'The mapped option should be "None"'); + $this->assertFalse($tattle->called, 'The mapping function should not have been called'); + $this->assertSame($none, $mapped, 'The same "None" instance should have been returned'); + } + + #[TestDox('Map succeeds with Some')] + public function testMapSucceedsWithSome(): void + { + $mapped = Option::Some('abc ')->map(fn($it) => str_repeat($it, 2)); + $this->assertTrue($mapped->isSome(), 'The mapped option should be "Some"'); + $this->assertEquals('abc abc ', $mapped->get(), 'The mapping function was not called correctly'); + } + + #[TestDox('Map fails with Some when mapping is null')] + public function testMapFailsWithSomeWhenMappingIsNull(): void + { + $this->expectException(InvalidArgumentException::class); + Option::Some('oof')->map(fn($it) => null); + } + + #[TestDox('Iter succeeds with None')] + public function testIterSucceedsWithNone(): void + { + $target = new class { public mixed $called = null; }; + Option::None()->iter(function () use ($target) { $target->called = 'uh oh'; }); + $this->assertNull($target->called, 'The function should not have been called'); + } + + #[TestDox('Iter succeeds with Some')] + public function testIterSucceedsWithSome(): void + { + $target = new class { public mixed $called = null; }; + Option::Some(33)->iter(function ($it) use ($target) { $target->called = $it; }); + $this->assertEquals(33, $target->called, 'The function should have been called with the "Some" value'); + } + + #[TestDox('Filter succeeds with None')] + public function testFilterSucceedsWithNone(): void + { + $tattle = new class { public bool $called = false; }; + $none = Option::None(); + $filtered = $none->filter(function () use ($tattle) + { + $tattle->called = true; + return true; + }); + $this->assertTrue($filtered->isNone(), 'The filtered option should have been "None"'); + $this->assertFalse($tattle->called, 'The callable should not have been called'); + $this->assertSame($none, $filtered, 'The "None" instance returned should have been the one passed'); + } + + #[TestDox('Filter succeeds with Some when true')] + public function testFilterSucceedsWithSomeWhenTrue(): void + { + $some = Option::Some(12); + $filtered = $some->filter(fn($it) => $it % 2 === 0); + $this->assertTrue($filtered->isSome(), 'The filtered option should have been "Some"'); + $this->assertEquals(12, $filtered->get(), 'The filtered option value is incorrect'); + $this->assertSame($some, $filtered, 'The same "Some" instance should have been returned'); + } + + #[TestDox('Filter succeeds with Some when false')] + public function testFilterSucceedsWithSomeWhenFalse(): void + { + $some = Option::Some(23); + $filtered = $some->filter(fn($it) => $it % 2 === 0); + $this->assertTrue($filtered->isNone(), 'The filtered option should have been "None"'); + } + + #[TestDox('Is succeeds with None')] + public function testIsSucceedsWithNone(): void + { + $this->assertFalse(Option::None()->is(null), '"None" should always return false'); + } + + #[TestDox('Is succeeds with Some when strictly equal')] + public function testIsSucceedsWithSomeWhenStrictlyEqual(): void + { + $this->assertTrue(Option::Some(3)->is(3), '"Some" with strict equality should be true'); + } + + #[TestDox('Is succeeds with Some when strictly unequal')] + public function testIsSucceedsWithSomeWhenStrictlyUnequal(): void + { + $this->assertFalse(Option::Some('3')->is(3), '"Some" with strict equality should be false'); + } + + #[TestDox('Is succeeds with Some when loosely equal')] + public function testIsSucceedsWithSomeWhenLooselyEqual(): void + { + $this->assertTrue(Option::Some('3')->is(3, strict: false), '"Some" with loose equality should be true'); + } + + #[TestDox('Is succeeds with Some when loosely unequal')] + public function testIsSucceedsWithSomeWhenLooselyUnequal(): void + { + $this->assertFalse(Option::Some('3')->is(4, strict: false), '"Some" with loose equality should be false'); + } + + #[TestDox('Unwrap succeeds with None')] + public function testUnwrapSucceedsWithNone(): void + { + $this->assertNull(Option::None()->unwrap(), '"None" should return null'); + } + + #[TestDox('Unwrap succeeds with Some')] + public function testUnwrapSucceedsWithSome(): void + { + $this->assertEquals('boy howdy', Option::Some('boy howdy')->unwrap(), '"Some" should return its value'); + } + + #[TestDox('Tap succeeds with Some')] + public function testTapSucceedsWithSome(): void + { + $value = ''; + $original = Option::Some('testing'); + $tapped = $original->tap( + function (Option $it) use (&$value) { $value = $it->isSome() ? $it->get() : 'none'; }); + $this->assertEquals('testing', $value, 'The tapped function was not called'); + $this->assertSame($original, $tapped, 'The same option should have been returned'); + } + + #[TestDox('Tap succeeds with None')] + public function testTapSucceedsWithNone(): void + { + $value = ''; + $original = Option::None(); + $tapped = $original->tap( + function (Option $it) use (&$value) { $value = $it->isSome() ? $it->get() : 'none'; }); + $this->assertEquals('none', $value, 'The tapped function was not called'); + $this->assertSame($original, $tapped, 'The same option should have been returned'); + } + + #[TestDox('ToPhpOption succeeds for Some')] + public function testToPhpOptionSucceedsForSome(): void + { + $opt = Option::Some('php')->toPhpOption(); + $this->assertNotNull($opt, 'The PhpOption should not have been null'); + $this->assertInstanceOf('PhpOption\Some', $opt, 'The PhpOption should have been "Some"'); + $this->assertTrue($opt->isDefined(), 'There should have been a value for the PhpOption'); + $this->assertEquals('php', $opt->get(), 'The value was not correct'); + } + + #[TestDox('ToPhpOption succeeds for None')] + public function testToPhpOptionSucceedsForNone(): void + { + $opt = Option::None()->toPhpOption(); + $this->assertNotNull($opt, 'The PhpOption should not have been null'); + $this->assertInstanceOf('PhpOption\None', $opt, 'The PhpOption should have been "None"'); + $this->assertFalse($opt->isDefined(), 'There should not have been a value for the PhpOption'); + } + public function testSomeSucceedsWithValue(): void { $it = Option::Some('hello'); - $this->assertTrue(Option::isSome($it), 'The option should have been "Some"'); + $this->assertTrue($it->isSome(), 'The option should have been "Some"'); } public function testSomeFailsWithNull(): void @@ -49,19 +262,19 @@ class OptionTest extends TestCase public function testNoneSucceeds(): void { $it = Option::None(); - $this->assertTrue(Option::isNone($it), 'The option should have been "None"'); + $this->assertTrue($it->isNone(), 'The option should have been "None"'); } public function testOfSucceedsWithNull(): void { $it = Option::of(null); - $this->assertTrue(Option::isNone($it), '"null" should have created a "None" option'); + $this->assertTrue($it->isNone(), '"null" should have created a "None" option'); } public function testOfSucceedsWithNonNull(): void { $it = Option::of('test'); - $this->assertTrue(Option::isSome($it), 'A non-null value should have created a "Some" option'); + $this->assertTrue($it->isSome(), 'A non-null value should have created a "Some" option'); $this->assertEquals('test', $it->get(), 'The value was not assigned correctly'); } @@ -69,7 +282,7 @@ class OptionTest extends TestCase public function testOfSucceedsWithPhpOptionSome(): void { $it = Option::of(Some::create('something')); - $this->assertTrue(Option::isSome($it), 'A "Some" PhpOption should have created a "Some" option'); + $this->assertTrue($it->isSome(), 'A "Some" PhpOption should have created a "Some" option'); $this->assertEquals('something', $it->get(), 'The value was not assigned correctly'); } @@ -77,202 +290,6 @@ class OptionTest extends TestCase public function testOfSucceedsWithPhpOptionNone(): void { $it = Option::of(None::create()); - $this->assertTrue(Option::isNone($it), 'A "None" PhpOption should have created a "None" option'); - } - - #[TestDox('IsNone succeeds with None')] - public function testIsNoneSucceedsWithNone(): void - { - $this->assertTrue(Option::isNone(Option::None()), '"None" should return true'); - } - - #[TestDox('IsNone succeeds with Some')] - public function testIsNoneSucceedsWithSome(): void - { - $this->assertFalse(Option::isNone(Option::Some(8)), '"Some" should return false'); - } - - #[TestDox('IsSome succeeds with None')] - public function testIsSomeSucceedsWithNone(): void - { - $this->assertFalse(Option::isSome(Option::None()), '"None" should return false'); - } - - #[TestDox('IsSome succeeds with Some')] - public function testIsSomeSucceedsWithSome(): void - { - $this->assertTrue(Option::isSome(Option::Some('boo')), '"Some" should return true'); - } - - #[TestDox('DefaultValue succeeds with None')] - public function testDefaultValueSucceedsWithNone(): void - { - $this->assertEquals('yes', Option::defaultValue('yes', Option::None()), 'Value should have been default'); - } - - #[TestDox('DefaultValue succeeds with Some')] - public function testDefaultValueSucceedsWithSome(): void - { - $this->assertEquals('no', Option::defaultValue('yes', Option::Some('no')), - 'Value should have been from option'); - } - - #[TestDox('GetOrCall succeeds with None')] - public function testGetOrCallSucceedsWithNone(): void - { - $value = Option::getOrCall(new class { public function __invoke(): string { return 'called'; } }, - Option::None()); - $this->assertEquals('called', $value, 'The value should have been obtained from the callable'); - } - - #[TestDox('GetOrCall succeeds with Some')] - public function testGetOrCallSucceedsWithSome(): void - { - $value = Option::getOrCall(new class { public function __invoke(): string { return 'called'; } }, - Option::Some('passed')); - $this->assertEquals('passed', $value, 'The value should have been obtained from the option'); - } - - #[TestDox('Map succeeds with None')] - public function testMapSucceedsWithNone(): void - { - $tattle = new class { public bool $called = false; }; - $none = Option::None(); - $mapped = Option::map(function ($ignored) use ($tattle) - { - $tattle->called = true; - return 'hello'; - }, $none); - $this->assertTrue(Option::isNone($mapped), 'The mapped option should be "None"'); - $this->assertFalse($tattle->called, 'The mapping function should not have been called'); - $this->assertNotSame($none, $mapped, 'There should have been a new "None" instance returned'); - } - - #[TestDox('Map succeeds with Some')] - public function testMapSucceedsWithSome(): void - { - $mapped = Option::map(fn($it) => str_repeat($it, 2), Option::Some('abc ')); - $this->assertTrue(Option::isSome($mapped), 'The mapped option should be "Some"'); - $this->assertEquals('abc abc ', $mapped->get(), 'The mapping function was not called correctly'); - } - - #[TestDox('Map fails with Some when mapping is null')] - public function testMapFailsWithSomeWhenMappingIsNull(): void - { - $this->expectException(InvalidArgumentException::class); - Option::map(fn($it) => null, Option::Some('oof')); - } - - #[TestDox('Iter succeeds with None')] - public function testIterSucceedsWithNone(): void - { - $target = new class { public mixed $called = null; }; - Option::iter(function ($ignored) use ($target) { $target->called = 'uh oh'; }, Option::None()); - $this->assertNull($target->called, 'The function should not have been called'); - } - - #[TestDox('Iter succeeds with Some')] - public function testIterSucceedsWithSome(): void - { - $target = new class { public mixed $called = null; }; - Option::iter(function ($it) use ($target) { $target->called = $it; }, Option::Some(33)); - $this->assertEquals(33, $target->called, 'The function should have been called with the "Some" value'); - } - - #[TestDox('Filter succeeds with None')] - public function testFilterSucceedsWithNone(): void - { - $tattle = new class { public bool $called = false; }; - $none = Option::None(); - $filtered = Option::filter(function ($ignored) use ($tattle) - { - $tattle->called = true; - return true; - }, $none); - $this->assertTrue(Option::isNone($filtered), 'The filtered option should have been "None"'); - $this->assertFalse($tattle->called, 'The callable should not have been called'); - } - - #[TestDox('Filter succeeds with Some when true')] - public function testFilterSucceedsWithSomeWhenTrue(): void - { - $some = Option::Some(12); - $filtered = Option::filter(fn($it) => $it % 2 === 0, $some); - $this->assertTrue(Option::isSome($filtered), 'The filtered option should have been "Some"'); - $this->assertEquals(12, $filtered->get(), 'The filtered option value is incorrect'); - $this->assertNotSame($some, $filtered, 'There should have been a new option instance returned'); - } - - #[TestDox('Filter succeeds with Some when false')] - public function testFilterSucceedsWithSomeWhenFalse(): void - { - $some = Option::Some(23); - $filtered = Option::filter(fn($it) => $it % 2 === 0, $some); - $this->assertTrue(Option::isNone($filtered), 'The filtered option should have been "None"'); - } - - #[TestDox('Is succeeds with None')] - public function testIsSucceedsWithNone(): void - { - $this->assertFalse(Option::is(null, Option::None()), '"None" should always return false'); - } - - #[TestDox('Is succeeds with Some when strictly equal')] - public function testIsSucceedsWithSomeWhenStrictlyEqual(): void - { - $this->assertTrue(Option::is(3, Option::Some(3)), '"Some" with strict equality should be true'); - } - - #[TestDox('Is succeeds with Some when strictly unequal')] - public function testIsSucceedsWithSomeWhenStrictlyUnequal(): void - { - $this->assertFalse(Option::is(3, Option::Some('3')), '"Some" with strict equality should be false'); - } - - #[TestDox('Is succeeds with Some when loosely equal')] - public function testIsSucceedsWithSomeWhenLooselyEqual(): void - { - $this->assertTrue(Option::is(3, Option::Some('3'), strict: false), '"Some" with loose equality should be true'); - } - - #[TestDox('Is succeeds with Some when loosely unequal')] - public function testIsSucceedsWithSomeWhenLooselyUnequal(): void - { - $this->assertFalse(Option::is(4, Option::Some('3'), strict: false), - '"Some" with loose equality should be false'); - } - - #[TestDox('Unwrap succeeds with None')] - public function testUnwrapSucceedsWithNone(): void - { - $this->assertNull(Option::unwrap(Option::None()), '"None" should return null'); - } - - #[TestDox('Unwrap succeeds with Some')] - public function testUnwrapSucceedsWithSome(): void - { - $this->assertEquals('boy howdy', Option::unwrap(Option::Some('boy howdy')), '"Some" should return its value'); - } - - #[TestDox('Tap succeeds with Some')] - public function testTapSucceedsWithSome(): void - { - $value = ''; - $original = Option::Some('testing'); - $tapped = Option::tap( - function (Option $it) use (&$value) { $value = Option::isSome($it) ? $it->get() : 'none'; }, $original); - $this->assertEquals('testing', $value, 'The tapped function was not called'); - $this->assertSame($original, $tapped, 'The same option should have been returned'); - } - - #[TestDox('Tap succeeds with None')] - public function testTapSucceedsWithNone(): void - { - $value = ''; - $original = Option::None(); - $tapped = Option::tap( - function (Option $it) use (&$value) { $value = Option::isSome($it) ? $it->get() : 'none'; }, $original); - $this->assertEquals('none', $value, 'The tapped function was not called'); - $this->assertSame($original, $tapped, 'The same option should have been returned'); + $this->assertTrue($it->isNone(), 'A "None" PhpOption should have created a "None" option'); } } diff --git a/tests/ResultTest.php b/tests/ResultTest.php index cad5151..732578d 100644 --- a/tests/ResultTest.php +++ b/tests/ResultTest.php @@ -8,7 +8,6 @@ declare(strict_types=1); namespace Test; -use BitBadger\InspiredByFSharp\Option; use BitBadger\InspiredByFSharp\Result; use InvalidArgumentException; use PHPUnit\Framework\Attributes\TestDox; @@ -47,11 +46,156 @@ class ResultTest extends TestCase Result::OK('yeah')->getError(); } + #[TestDox('IsOK succeeds for OK result')] + public function testIsOKSucceedsForOKResult(): void + { + $result = Result::OK('ok'); + $this->assertTrue($result->isOK(), 'The check for "OK" should have returned true'); + } + + #[TestDox('IsOK succeeds for Error result')] + public function testIsOKSucceedsForErrorResult(): void + { + $result = Result::Error('error'); + $this->assertFalse($result->isOK(), 'The check for "OK" should have returned false'); + } + + #[TestDox('IsError succeeds for Error result')] + public function testIsErrorSucceedsForErrorResult(): void + { + $result = Result::Error('not ok'); + $this->assertTrue($result->isError(), 'The check for "Error" should have returned true'); + } + + #[TestDox('IsError succeeds for OK result')] + public function testIsErrorSucceedsForOKResult(): void + { + $result = Result::OK('fine'); + $this->assertFalse($result->isError(), 'The check for "Error" should have returned false'); + } + + #[TestDox('Map succeeds for OK result')] + public function testMapSucceedsForOKResult(): void + { + $ok = Result::OK('yard'); + $mapped = $ok->map(fn($it) => strrev($it)); + $this->assertTrue($mapped->isOK(), 'The mapped result should be "OK"'); + $this->assertEquals('dray', $mapped->getOK(), 'The mapping function was not called correctly'); + } + + #[TestDox('Map fails for OK result when mapping is null')] + public function testMapFailsForOKResultWhenMappingIsNull(): void + { + $this->expectException(InvalidArgumentException::class); + Result::OK('not null')->map(fn($it) => null); + } + + #[TestDox('Map succeeds for Error result')] + public function testMapSucceedsForErrorResult(): void + { + $tattle = new class { public bool $called = false; }; + $error = Result::Error('nope'); + $mapped = $error->map(function () use ($tattle) + { + $tattle->called = true; + return 'hello'; + }); + $this->assertTrue($mapped->isError(), 'The mapped result should be "Error"'); + $this->assertFalse($tattle->called, 'The mapping function should not have been called'); + $this->assertSame($error, $mapped, 'The same "Error" instance should have been returned'); + } + + #[TestDox('MapError succeeds for OK result')] + public function testMapErrorSucceedsForOKResult(): void + { + $tattle = new class { public bool $called = false; }; + $ok = Result::OK('sure'); + $mapped = $ok->mapError(function () use ($tattle) + { + $tattle->called = true; + return 'hello'; + }); + $this->assertTrue($mapped->isOK(), 'The mapped result should be "OK"'); + $this->assertFalse($tattle->called, 'The mapping function should not have been called'); + $this->assertSame($ok, $mapped, 'The same "OK" instance should have been returned'); + } + + #[TestDox('MapError succeeds for Error result')] + public function testMapErrorSucceedsForErrorResult(): void + { + $error = Result::Error('taco'); + $mapped = $error->mapError(fn($it) => str_repeat('*', strlen($it))); + $this->assertTrue($mapped->isError(), 'The mapped result should be "Error"'); + $this->assertEquals('****', $mapped->getError(), 'The mapping function was not called correctly'); + } + + #[TestDox('MapError fails for Error result when mapping is null')] + public function testMapErrorFailsForErrorResultWhenMappingIsNull(): void + { + $this->expectException(InvalidArgumentException::class); + Result::Error('pizza')->mapError(fn($it) => null); + } + + #[TestDox('Iter succeeds for OK result')] + public function testIterSucceedsForOKResult(): void + { + $target = new class { public mixed $called = null; }; + Result::OK(77)->iter(function ($it) use ($target) { $target->called = $it; }); + $this->assertEquals(77, $target->called, 'The function should have been called with the "OK" value'); + } + + #[TestDox('Iter succeeds for Error result')] + public function testIterSucceedsForErrorResult(): void + { + $target = new class { public mixed $called = null; }; + Result::Error('')->iter(function () use ($target) { $target->called = 'uh oh'; }); + $this->assertNull($target->called, 'The function should not have been called'); + } + + #[TestDox('ToOption succeeds for OK result')] + public function testToOptionSucceedsForOKResult() + { + $value = Result::OK(99)->toOption(); + $this->assertTrue($value->isSome(), 'An "OK" result should map to a "Some" option'); + $this->assertEquals(99, $value->get(), 'The value is not correct'); + } + + #[TestDox('ToOption succeeds for Error result')] + public function testToOptionSucceedsForErrorResult() + { + $value = Result::Error('file not found')->toOption(); + $this->assertTrue($value->isNone(), 'An "Error" result should map to a "None" option'); + } + + #[TestDox('Tap succeeds for OK result')] + public function testTapSucceedsForOKResult(): void + { + $value = ''; + $original = Result::OK('working'); + $tapped = $original->tap(function (Result $it) use (&$value) { + $value = $it->isOK() ? 'OK: ' . $it->getOK() : 'Error: ' . $it->getError(); + }); + $this->assertEquals('OK: working', $value, 'The tapped function was not called'); + $this->assertSame($original, $tapped, 'The same result should have been returned'); + } + + #[TestDox('Tap succeeds for Error result')] + public function testTapSucceedsForErrorResult(): void + { + $value = ''; + $original = Result::Error('failed'); + $tapped = $original->tap(function (Result $it) use (&$value) { + $value = $it->isOK() ? 'OK: ' . $it->getOK() : 'Error: ' . $it->getError(); + }); + $this->assertEquals('Error: failed', $value, 'The tapped function was not called'); + $this->assertSame($original, $tapped, 'The same result should have been returned'); + } + #[TestDox('OK succeeds for non null result')] public function testOKSucceedsForNonNullResult(): void { $result = Result::OK('something'); - $this->assertTrue(Result::isOK($result), 'The result should have been "OK"'); + $this->assertTrue($result->isOK(), 'The result should have been "OK"'); $this->assertEquals('something', $result->getOK(), 'The "OK" value was incorrect'); } @@ -66,7 +210,7 @@ class ResultTest extends TestCase public function testErrorSucceedsForNonNullResult(): void { $result = Result::Error('sad trombone'); - $this->assertTrue(Result::isError($result), 'The result should have been "Error"'); + $this->assertTrue($result->isError(), 'The result should have been "Error"'); $this->assertEquals('sad trombone', $result->getError(), 'The "Error" value was incorrect'); } @@ -76,149 +220,4 @@ class ResultTest extends TestCase $this->expectException(InvalidArgumentException::class); Result::Error(null); } - - #[TestDox('IsOK succeeds for OK result')] - public function testIsOKSucceedsForOKResult(): void - { - $result = Result::OK('ok'); - $this->assertTrue(Result::isOK($result), 'The check for "OK" should have returned true'); - } - - #[TestDox('IsOK succeeds for Error result')] - public function testIsOKSucceedsForErrorResult(): void - { - $result = Result::Error('error'); - $this->assertFalse(Result::isOK($result), 'The check for "OK" should have returned false'); - } - - #[TestDox('IsError succeeds for Error result')] - public function testIsErrorSucceedsForErrorResult(): void - { - $result = Result::Error('not ok'); - $this->assertTrue(Result::isError($result), 'The check for "Error" should have returned true'); - } - - #[TestDox('IsError succeeds for OK result')] - public function testIsErrorSucceedsForOKResult(): void - { - $result = Result::OK('fine'); - $this->assertFalse(Result::isError($result), 'The check for "Error" should have returned false'); - } - - #[TestDox('Map succeeds for OK result')] - public function testMapSucceedsForOKResult(): void - { - $ok = Result::OK('yard'); - $mapped = Result::map(fn($it) => strrev($it), $ok); - $this->assertTrue(Result::isOK($mapped), 'The mapped result should be "OK"'); - $this->assertEquals('dray', $mapped->getOK(), 'The mapping function was not called correctly'); - } - - #[TestDox('Map fails for OK result when mapping is null')] - public function testMapFailsForOKResultWhenMappingIsNull(): void - { - $this->expectException(InvalidArgumentException::class); - Result::map(fn($it) => null, Result::OK('not null')); - } - - #[TestDox('Map succeeds for Error result')] - public function testMapSucceedsForErrorResult(): void - { - $tattle = new class { public bool $called = false; }; - $error = Result::Error('nope'); - $mapped = Result::map(function ($ignored) use ($tattle) - { - $tattle->called = true; - return 'hello'; - }, $error); - $this->assertTrue(Result::isError($mapped), 'The mapped result should be "Error"'); - $this->assertFalse($tattle->called, 'The mapping function should not have been called'); - $this->assertNotSame($error, $mapped, 'There should have been a new "Error" instance returned'); - } - - #[TestDox('MapError succeeds for OK result')] - public function testMapErrorSucceedsForOKResult(): void - { - $tattle = new class { public bool $called = false; }; - $ok = Result::OK('sure'); - $mapped = Result::mapError(function ($ignored) use ($tattle) - { - $tattle->called = true; - return 'hello'; - }, $ok); - $this->assertTrue(Result::isOK($mapped), 'The mapped result should be "OK"'); - $this->assertFalse($tattle->called, 'The mapping function should not have been called'); - $this->assertNotSame($ok, $mapped, 'There should have been a new "OK" instance returned'); - } - - #[TestDox('MapError succeeds for Error result')] - public function testMapErrorSucceedsForErrorResult(): void - { - $error = Result::Error('taco'); - $mapped = Result::mapError(fn($it) => str_repeat('*', strlen($it)), $error); - $this->assertTrue(Result::isError($mapped), 'The mapped result should be "Error"'); - $this->assertEquals('****', $mapped->getError(), 'The mapping function was not called correctly'); - } - - #[TestDox('MapError fails for Error result when mapping is null')] - public function testMapErrorFailsForErrorResultWhenMappingIsNull(): void - { - $this->expectException(InvalidArgumentException::class); - Result::mapError(fn($it) => null, Result::Error('pizza')); - } - - #[TestDox('Iter succeeds for OK result')] - public function testIterSucceedsForOKResult(): void - { - $target = new class { public mixed $called = null; }; - Result::iter(function ($it) use ($target) { $target->called = $it; }, Result::OK(77)); - $this->assertEquals(77, $target->called, 'The function should have been called with the "OK" value'); - } - - #[TestDox('Iter succeeds for Error result')] - public function testIterSucceedsForErrorResult(): void - { - $target = new class { public mixed $called = null; }; - Result::iter(function ($ignored) use ($target) { $target->called = 'uh oh'; }, Result::Error('')); - $this->assertNull($target->called, 'The function should not have been called'); - } - - #[TestDox('ToOption succeeds for OK result')] - public function testToOptionSucceedsForOKResult() - { - $value = Result::toOption(Result::OK(99)); - $this->assertTrue(Option::isSome($value), 'An "OK" result should map to a "Some" option'); - $this->assertEquals(99, $value->get(), 'The value is not correct'); - } - - #[TestDox('ToOption succeeds for Error result')] - public function testToOptionSucceedsForErrorResult() - { - $value = Result::toOption(Result::Error('file not found')); - $this->assertTrue(Option::isNone($value), 'An "Error" result should map to a "None" option'); - } - - #[TestDox('Tap succeeds for OK result')] - public function testTapSucceedsForOKResult(): void - { - $value = ''; - $original = Result::OK('working'); - $tapped = Result::tap(function (Result $it) use (&$value) { - $value = Result::isOK($it) ? 'OK: ' . $it->getOK() : 'Error: ' . $it->getError(); - }, $original); - $this->assertEquals('OK: working', $value, 'The tapped function was not called'); - $this->assertSame($original, $tapped, 'The same result should have been returned'); - } - - #[TestDox('Tap succeeds for Error result')] - public function testTapSucceedsForErrorResult(): void - { - $value = ''; - $original = Result::Error('failed'); - $tapped = Result::tap(function (Result $it) use (&$value) { - $value = Result::isOK($it) ? 'OK: ' . $it->getOK() : 'Error: ' . $it->getError(); - }, $original); - $this->assertEquals('Error: failed', $value, 'The tapped function was not called'); - $this->assertSame($original, $tapped, 'The same result should have been returned'); - } }