Compare commits

..

No commits in common. "main" and "v1.0.0-beta1" have entirely different histories.

12 changed files with 700 additions and 2890 deletions

1
.gitignore vendored
View File

@ -1,3 +1,2 @@
.idea .idea
vendor vendor
*-tests.txt

View File

@ -2,38 +2,29 @@
This project contains PHP utility classes whose functionality is inspired by their F# counterparts. This project contains PHP utility classes whose functionality is inspired by their F# counterparts.
The v2 series requires at least PHP 8.4. A similar API exists for PHP 8.2 - 8.3 in version 1 of this project; see its README for specifics.
## What It Provides ## What It Provides
This early-stage library currently provides two classes, both of which are designed to wrap values and indicate the state of the action that produced them. `Option<T>` represents a variable that may or may not have a value. `Result<TOK, TError>` represents the result of an action; the "ok" and "error" states both provide a value. This early-stage library currently provides two classes, both of which are designed to wrap values and indicate the state of the action that produced them. `Option<T>` represents a variable that may or may not have a value. `Result<TOK, TError>` represents the result of an action; the "ok" and "error" states both provide a value.
| | `Option<T>`<br>Replaces `null` checks | `Result<TOK, TError>`<br>Replaces exception-based error handling | | | `Option<T>`<br>Replaces `null` checks | `Result<TOK, TError>`<br>Replaces exception-based error handling |
|---------------------------------------------------|-------------------------------------------------------|-----------------------------------------------------------------------------| |----------------------------------------------------|--------------------------------------------------------------------------------|------------------------------------------------------------------|
| **Creating** | `::Some(T)` for Some | `::OK(TOK)` for OK | | **Creating** | `::Some(T)` for Some<br>`::None()` for None<br>`::of($value)` _None if `null`_ | `::OK(TOK)` for OK<br>`::Error(TError)` for Error |
| | `::None()` for None | `::Error(TError)` for Error | | **Querying** | `->isSome()`<br>`->isNone()` | `->isOK()`<br>`->isError()` |
| | `::of($value)` _None if `null`_ | | | **Reading**<br>_throws if called on missing value_ | `->get()` | `->getOK()`<br>`->getError()` |
| **Querying** | `->isSome: bool` | `->isOK: bool` | | **Transforming**<br>_still `Option` or `Result`_ | `->map(callable(T): U)` | `->map(callable(TOK): U)`<br>`->mapError(callable(TError): U)` |
| | `->isNone: bool` | `->isError: bool` | | **Iterating** | `->iter(callable(T): void)` | `->iter(callable(TOK): void)` |
| | `->contains(T, $strict = true): bool` | `->contains(TOK, $strict = true): bool` | | **Inspecting**<br>_returns the original instance_ | `->tap(callable(Option<T>): void)` | `->tap(callable(Result<TOK, TError>): void)` |
| | `->exists(callable(T): bool): bool` | `->exists(callable(TOK): bool): bool` |
| **Reading**<br> | `->value: T` | `->ok: TOK` |
| _all throw if called on missing value_ | | `->error: TError` |
| **Transforming**<br> | `->map(callable(T): TMapped): Option<TMapped>` | `->map(callable(TOK): TMapped): Result<TMapped, TError>` |
| _all still `Option` or `Result`_ | | `->mapError(callable(TError): TMapped): Result<TOK, TMapped>` |
| **Iterating** | `->iter(callable(T): void): void` | `->iter(callable(TOK): void): void` |
| **Inspecting**<br>_returns the original instance_ | `->tap(callable(Option<T>): void): Option<T>` | `->tap(callable(Result<TOK, TError>): void): Result<TOK, TError>` |
| **Continued Processing** | `->bind(callable(T): Option<TBound>): Option<TBound>` | `->bind(callable(TOK): Result<TBoundOK, TError>): Result<TBoundOK, TError>` |
| **Changing Types** | `->toArray(): T[]` | `->toArray(): TOK[]` |
| | | `->toOption(): Option<TOK>` |
In addition to this, `Option<T>` provides: In addition to this, `Option<T>` provides:
- `->getOrDefault(T)` will return the Some value if it exists or the given default if the option is None. - `->getOrDefault(T)` will return the Some value if it exists or the given default if the option is None.
- `->getOrCall(callable(): mixed)` will call the given function if the option is None. That function may return a value, or may be `void` or `never`. - `->getOrCall(callable(): mixed)` will call the given function if the option is None. That function may return a value, or may be `void` or `never`.
- `->getOrThrow(callable(): Exception)` will return the Some value if it exists, or throw the exception returned by the function if the option is None.
- `->filter(callable(T): bool)` will compare a Some value against the callable, and if it returns `true`, will remain Some; if it returns `false`, the value will become None. - `->filter(callable(T): bool)` will compare a Some value against the callable, and if it returns `true`, will remain Some; if it returns `false`, the value will become None.
- `->is(T, $strict = true)` will return `true` if the option is Some and the value matches. Strict equality (the default) uses `===` for the comparison; if strict is set to `false`, the comparison will use `==` instead.
- `->unwrap()` will return `null` for None options and the value for Some options. - `->unwrap()` will return `null` for None options and the value for Some options.
`Result<TOK, TError>` also provides:
- `toOption()` will transform an OK result to a Some option, and an Error result to a None option.
Finally, we would be remiss to not acknowledge some really cool prior art in this area - the [PhpOption](https://github.com/schmittjoh/php-option) project. `Option::of` recognizes their options and converts them properly, and `Option<T>` instances have a `->toPhpOption()` method that will convert these back into PhpOption's `Some<T>` and `None` instances. There is also a [ResultType](https://github.com/GrahamCampbell/Result-Type) project from the same team, though this project's result does not (yet) have any conversion methods for it. Finally, we would be remiss to not acknowledge some really cool prior art in this area - the [PhpOption](https://github.com/schmittjoh/php-option) project. `Option::of` recognizes their options and converts them properly, and `Option<T>` instances have a `->toPhpOption()` method that will convert these back into PhpOption's `Some<T>` and `None` instances. There is also a [ResultType](https://github.com/GrahamCampbell/Result-Type) project from the same team, though this project's result does not (yet) have any conversion methods for it.
## The Inspiration ## The Inspiration

View File

@ -17,11 +17,11 @@
"rss": "https://git.bitbadger.solutions/bit-badger/inspired-by-fsharp.rss" "rss": "https://git.bitbadger.solutions/bit-badger/inspired-by-fsharp.rss"
}, },
"require": { "require": {
"php": ">=8.4" "php": "^8.2"
}, },
"require-dev": { "require-dev": {
"phpoption/phpoption": "^1", "phpunit/phpunit": "^11",
"pestphp/pest": "^3.5" "phpoption/phpoption": "^1"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
@ -30,15 +30,10 @@
}, },
"autoload-dev": { "autoload-dev": {
"psr-4": { "psr-4": {
"Tests\\": "./tests" "Test\\": "./tests"
} }
}, },
"archive": { "archive": {
"exclude": [ "/tests", "/.gitattributes", "/.gitignore", "/.git", "/composer.lock" ] "exclude": [ "/tests", "/.gitattributes", "/.gitignore", "/.git", "/composer.lock" ]
},
"config": {
"allow-plugins": {
"pestphp/pest-plugin": true
}
} }
} }

2247
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.3/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Test Suite">
<directory suffix="Test.php">./tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">./src</directory>
</include>
</source>
</phpunit>

View File

@ -10,7 +10,6 @@ declare(strict_types=1);
namespace BitBadger\InspiredByFSharp; namespace BitBadger\InspiredByFSharp;
use Error; use Error;
use Exception;
use InvalidArgumentException; use InvalidArgumentException;
/** /**
@ -25,38 +24,50 @@ use InvalidArgumentException;
* *
* @template T The type of value represented by this option * @template T The type of value represented by this option
*/ */
class Option readonly class Option
{ {
/** @var T|null $value The value for this option */ /** @var T|null $value The value for this option */
private mixed $val; private mixed $value;
/** /**
* @param T|null $value The possibly null value for this option * @param T|null $value The possibly null value for this option
*/ */
private function __construct(mixed $value = null) private function __construct(mixed $value = null)
{ {
$this->val = $value; $this->value = $value;
} }
/** /**
* @var T The value of this option (read-only) * Get the value of this option
* @throws InvalidArgumentException If called on a `None` option *
* @return T The value of the option
*/ */
public mixed $value { public function get(): mixed
get => match ($this->val) { {
null => throw new InvalidArgumentException('Cannot get the value of a None option'), return match (true) {
default => $this->val, $this->isSome() => $this->value,
default => throw new InvalidArgumentException('Cannot get the value of a None option'),
}; };
} }
/** @var bool True if the option is `None`, false if it is `Some` */ /**
public bool $isNone { * Does this option have a `None` value?
get => is_null($this->val); *
* @return bool True if the option is `None`, false if it is `Some`
*/
public function isNone(): bool
{
return is_null($this->value);
} }
/** @var bool True if the option is `Some`, false if it is `None` */ /**
public bool $isSome{ * Does this option have a `Some` value?
get => !$this->isNone; *
* @return bool True if the option is `Some`, false if it is `None`
*/
public function isSome(): bool
{
return !$this->isNone();
} }
/** /**
@ -67,84 +78,31 @@ class Option
*/ */
public function getOrDefault(mixed $default): mixed public function getOrDefault(mixed $default): mixed
{ {
return $this->val ?? $default; return $this->value ?? $default;
} }
/** /**
* Get the value, or return the value of a callable function * Get the value, or return the value of a callable function
* *
* @template TCalled The return type of the callable provided * @template U The return type of the callable provided
* @param callable(): TCalled $f The callable function to use for `None` options * @param callable(): U $f The callable function to use for `None` options
* @return T|TCalled The value if `Some`, the result of the callable if `None` * @return T|mixed The value if `Some`, the result of the callable if `None`
*/ */
public function getOrCall(callable $f): mixed public function getOrCall(callable $f): mixed
{ {
return $this->val ?? $f(); return $this->value ?? $f();
}
/**
* Get the value, or throw the exception using the given function
*
* @param callable(): Exception $exFunc A function to construct the exception to throw
* @return T The value of the option if `Some`
* @throws Exception If the option is `None`
*/
public function getOrThrow(callable $exFunc): mixed
{
return $this->val ?? throw $exFunc();
}
/**
* Bind a function to this option (railway processing)
*
* If this option is Some, the function will be called with the option's value. If this option is None, it will be
* immediately returned.
*
* @template TBound The type returned by Some in the bound function
* @param callable(T): Option<TBound> $f The function that will receive the Some value; can return a different type
* @return Option<TBound> The updated option if the starting value was Some, None otherwise
*/
public function bind(callable $f): Option
{
return $this->isNone ? $this : $f($this->val);
}
/**
* Does the option contain the given value?
*
* @param T $value The value to be checked
* @param bool $strict True for strict equality (`===`), false for loose equality (`==`); optional, default is true
* @return bool True if the value matches, false if not; `None` always returns false
*/
public function contains(mixed $value, bool $strict = true): bool
{
return match (true) {
$this->isNone => false,
default => $strict ? $this->val === $value : $this->val == $value,
};
}
/**
* Does the value of the option match the given predicate function?
*
* @param callable(T): bool $f The function to determine whether the value matches
* @return bool True if the `Some` value matches the function, false otherwise
*/
public function exists(callable $f): bool
{
return $this->isSome ? $f($this->val) : false;
} }
/** /**
* Map this optional value to another value * Map this optional value to another value
* *
* @template TMapped The type of the mapping function * @template U The type of the mapping function
* @param callable(T): TMapped $f The mapping function * @param callable(T): U $f The mapping function
* @return Option<TMapped> A `Some` instance with the transformed value if `Some`, `None` otherwise * @return Option<U> A `Some` instance with the transformed value if `Some`, `None` otherwise
*/ */
public function map(callable $f): self public function map(callable $f): self
{ {
return $this->isSome ? self::Some($f($this->val)) : $this; return $this->isSome() ? self::Some($f($this->get())) : $this;
} }
/** /**
@ -154,8 +112,8 @@ class Option
*/ */
public function iter(callable $f): void public function iter(callable $f): void
{ {
if ($this->isSome) { if ($this->isSome()) {
$f($this->val); $f($this->value);
} }
} }
@ -167,7 +125,25 @@ class Option
*/ */
public function filter(callable $f): self public function filter(callable $f): self
{ {
return $this->isNone || $this->exists($f) ? $this : self::None(); 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,
};
} }
/** /**
@ -177,7 +153,7 @@ class Option
*/ */
public function unwrap(): mixed public function unwrap(): mixed
{ {
return $this->val; return $this->value;
} }
/** /**
@ -192,16 +168,6 @@ class Option
return $this; return $this;
} }
/**
* Convert this option into a 0 or 1 item array
*
* @return T[] An empty array for `None`, a 1-item array for `Some`
*/
public function toArray(): array
{
return $this->isSome ? [$this->val] : [];
}
/** /**
* Convert this to a PhpOption option * Convert this to a PhpOption option
* *
@ -210,8 +176,8 @@ class Option
public function toPhpOption(): mixed public function toPhpOption(): mixed
{ {
return match (true) { return match (true) {
$this->isNone && class_exists('PhpOption\None') => call_user_func('PhpOption\None::create'), $this->isNone() && class_exists('PhpOption\None') => call_user_func('PhpOption\None::create'),
class_exists('PhpOption\Some') => call_user_func('PhpOption\Some::create', $this->val), class_exists('PhpOption\Some') => call_user_func('PhpOption\Some::create', $this->value),
default => throw new Error('PhpOption types could not be found'), default => throw new Error('PhpOption types could not be found'),
}; };
} }

View File

@ -25,7 +25,7 @@ use InvalidArgumentException;
* @template TOK The type of the OK result * @template TOK The type of the OK result
* @template TError The type of the error result * @template TError The type of the error result
*/ */
class Result readonly class Result
{ {
/** @var Option<TOK> The OK value for this result */ /** @var Option<TOK> The OK value for this result */
private Option $okValue; private Option $okValue;
@ -45,90 +45,70 @@ class Result
$this->errorValue = Option::of($errorValue); $this->errorValue = Option::of($errorValue);
} }
/** @var TOK The OK value (will throw if result is not OK) */ /**
public mixed $ok { * Get the value for an `OK` result
get => $this->okValue->value; *
} * @return TOK The OK value for this result
* @throws InvalidArgumentException If the result is an `Error` result
/** @var TError The error value (will throw if result is not Error) */ */
public mixed $error { public function getOK(): mixed
get => $this->errorValue->value; {
} return $this->okValue->get();
/** @var bool True if the result is `OK`, false if it is `Error` */
public bool $isOK {
get => $this->okValue->isSome;
}
/** @var bool True if the result is `Error`, false if it is `OK` */
public bool $isError {
get => $this->errorValue->isSome;
} }
/** /**
* Bind a function to this result (railway processing) * Get the value for an `Error` result
* *
* If this result is OK, the function will be called with the OK value of the result. If this result is Error, it * @return TError The error value for this result
* will be immediately returned. This allows for a sequence of functions to proceed on the happy path (OK all the * @throws InvalidArgumentException If the result is an `OK` result
* way), or be shunted off to the exit ramp once an error occurs.
*
* @template TBoundOK The type returned by OK in the bound function
* @param callable(TOK): Result<TBoundOK, TError> $f The function that will receive the OK value; can return a different type
* @return Result<TBoundOK, TError> The updated result if the function was successful, an error otherwise
*/ */
public function bind(callable $f): Result public function getError(): mixed
{ {
return $this->isError ? $this : $f($this->ok); return $this->errorValue->get();
} }
/** /**
* Does this result's "OK" value match the given value? * Is this result `OK`?
* *
* @param TOK $value The value to be matched * @return bool True if the result is `OK`, false if it is `Error`
* @param bool $strict True for strict equality (`===`), false for loose equality (`==`); optional, default is true
* @return bool True if the "OK" value matches the one provided, false otherwise
*/ */
public function contains(mixed $value, bool $strict = true): bool public function isOK(): bool
{ {
return match (true) { return $this->okValue->isSome();
$this->isError => false,
default => $this->okValue->contains($value, $strict),
};
} }
/** /**
* Does the "OK" value of this result match the given predicate function? * Is this result `Error`?
* *
* @param callable(TOK): bool $f The function to determine whether the value matches * @return bool True if the result is `Error`, false if it is `OK`
* @return bool True if the OK value matches the function, false otherwise
*/ */
public function exists(callable $f): bool public function isError(): bool
{ {
return $this->isOK ? $f($this->ok) : false; return $this->errorValue->isSome();
} }
/** /**
* Map an `OK` result to another, leaving an `Error` result unmodified * Map an `OK` result to another, leaving an `Error` result unmodified
* *
* @template TMapped The type of the mapping function * @template U The type of the mapping function
* @param callable(TOK): TMapped $f The mapping function * @param callable(TOK): U $f The mapping function
* @return Result<TMapped, TError> A transformed `OK` instance or the original `Error` instance * @return Result<U, TError> A transformed `OK` instance or the original `Error` instance
*/ */
public function map(callable $f): self public function map(callable $f): self
{ {
return $this->isOK ? self::OK($f($this->ok)) : $this; return $this->isOK() ? self::OK($f($this->getOK())) : $this;
} }
/** /**
* Map an `Error` result to another, leaving an `OK` result unmodified * Map an `Error` result to another, leaving an `OK` result unmodified
* *
* @template TMapped The type of the mapping function * @template U The type of the mapping function
* @param callable(TError): TMapped $f The mapping function * @param callable(TError): U $f The mapping function
* @return Result<TOK, TMapped> A transformed `Error` instance or the original `OK` instance * @return Result<TOK, U> A transformed `Error` instance or the original `OK` instance
*/ */
public function mapError(callable $f): self public function mapError(callable $f): self
{ {
return $this->isError ? self::Error($f($this->error)) : $this; return $this->isError() ? self::Error($f($this->getError())) : $this;
} }
/** /**
@ -138,21 +118,11 @@ class Result
*/ */
public function iter(callable $f): void public function iter(callable $f): void
{ {
if ($this->isOK) { if ($this->isOK()) {
$f($this->ok); $f($this->getOK());
} }
} }
/**
* Convert this result into a 0 or 1 item array
*
* @return TOK[] An empty array for `Error`, a 1-item array for `OK`
*/
public function toArray(): array
{
return $this->okValue->toArray();
}
/** /**
* Transform a `Result`'s `OK` value to an `Option` * Transform a `Result`'s `OK` value to an `Option`
* *
@ -160,7 +130,7 @@ class Result
*/ */
public function toOption(): Option public function toOption(): Option
{ {
return $this->isOK ? Option::Some($this->ok) : Option::None(); return $this->isOK() ? Option::Some($this->getOK()) : Option::None();
} }
/** /**

295
tests/OptionTest.php Normal file
View File

@ -0,0 +1,295 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
namespace Test;
use BitBadger\InspiredByFSharp\Option;
use InvalidArgumentException;
use PhpOption\{None, Some};
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
/**
* Unit tests for the Option class
*/
class OptionTest extends TestCase
{
#[TestDox('Get succeeds for Some')]
public function testGetSucceedsForSome(): void
{
$it = Option::Some(9);
$this->assertTrue($it->isSome(), 'The option should have been "Some"');
$this->assertEquals(9, $it->get(), 'The value was incorrect');
}
#[TestDox('Get fails for None')]
public function testGetFailsForNone(): void
{
$this->expectException(InvalidArgumentException::class);
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($it->isSome(), 'The option should have been "Some"');
}
public function testSomeFailsWithNull(): void
{
$this->expectException(InvalidArgumentException::class);
Option::Some(null);
}
public function testNoneSucceeds(): void
{
$it = Option::None();
$this->assertTrue($it->isNone(), 'The option should have been "None"');
}
public function testOfSucceedsWithNull(): void
{
$it = Option::of(null);
$this->assertTrue($it->isNone(), '"null" should have created a "None" option');
}
public function testOfSucceedsWithNonNull(): void
{
$it = Option::of('test');
$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');
}
#[TestDox('Of succeeds with PhpOption\Some')]
public function testOfSucceedsWithPhpOptionSome(): void
{
$it = Option::of(Some::create('something'));
$this->assertTrue($it->isSome(), 'A "Some" PhpOption should have created a "Some" option');
$this->assertEquals('something', $it->get(), 'The value was not assigned correctly');
}
#[TestDox('Of succeeds with PhpOption\None')]
public function testOfSucceedsWithPhpOptionNone(): void
{
$it = Option::of(None::create());
$this->assertTrue($it->isNone(), 'A "None" PhpOption should have created a "None" option');
}
}

View File

@ -1,45 +0,0 @@
<?php
/*
|--------------------------------------------------------------------------
| Test Case
|--------------------------------------------------------------------------
|
| The closure you provide to your test functions is always bound to a specific PHPUnit test
| case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may
| need to change it using the "pest()" function to bind a different classes or traits.
|
*/
// pest()->extend(Tests\TestCase::class)->in('Feature');
/*
|--------------------------------------------------------------------------
| Expectations
|--------------------------------------------------------------------------
|
| When you're writing tests, you often need to check that values meet certain conditions. The
| "expect()" function gives you access to a set of "expectations" methods that you can use
| to assert different things. Of course, you may extend the Expectation API at any time.
|
*/
expect()->extend('toBeOne', function () {
return $this->toBe(1);
});
/*
|--------------------------------------------------------------------------
| Functions
|--------------------------------------------------------------------------
|
| While Pest is very powerful out-of-the-box, you may have some testing code specific to your
| project that you don't want to repeat in every file. Here you can also expose helpers as
| global functions to help you to reduce the number of lines of code in your test files.
|
*/
function something()
{
// ..
}

223
tests/ResultTest.php Normal file
View File

@ -0,0 +1,223 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
namespace Test;
use BitBadger\InspiredByFSharp\Result;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
/**
* Unit tests for the Result class
*/
class ResultTest extends TestCase
{
#[TestDox('GetOK succeeds for OK result')]
public function testGetOKSucceedsForOKResult(): void
{
$result = Result::OK('yay');
$this->assertEquals('yay', $result->getOK(), 'The OK result should have been returned');
}
#[TestDox('GetOK fails for Error result')]
public function testGetOKFailsForErrorResult(): void
{
$this->expectException(InvalidArgumentException::class);
Result::Error('whoops')->getOK();
}
#[TestDox('GetError succeeds for Error result')]
public function testGetErrorSucceedsForErrorResult(): void
{
$result = Result::Error('boo');
$this->assertEquals('boo', $result->getError(), 'The Error result should have been returned');
}
#[TestDox('GetError fails for OK result')]
public function testGetErrorFailsForOKResult(): void
{
$this->expectException(InvalidArgumentException::class);
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(), 'The result should have been "OK"');
$this->assertEquals('something', $result->getOK(), 'The "OK" value was incorrect');
}
#[TestDox('OK fails for null result')]
public function testOKFailsForNullResult(): void
{
$this->expectException(InvalidArgumentException::class);
Result::OK(null);
}
#[TestDox('Error succeeds for non null result')]
public function testErrorSucceedsForNonNullResult(): void
{
$result = Result::Error('sad trombone');
$this->assertTrue($result->isError(), 'The result should have been "Error"');
$this->assertEquals('sad trombone', $result->getError(), 'The "Error" value was incorrect');
}
#[TestDox('Error fails for null result')]
public function testErrorFailsForNullResult(): void
{
$this->expectException(InvalidArgumentException::class);
Result::Error(null);
}
}

View File

@ -1,256 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\InspiredByFSharp\Option;
use PhpOption\{None, Some};
describe('->value', function () {
test('retrieves the value for Some', function () {
expect(Option::Some(9)->value)->toBe(9);
});
test('throws an exception for None', function () {
expect(fn() => Option::None()->value)->toThrow(InvalidArgumentException::class);
});
});
describe('->isNone', function () {
test('returns true for None', function () {
expect(Option::None()->isNone)->toBeTrue();
});
test('returns false for Some', function () {
expect(Option::Some(8)->isNone)->toBeFalse();
});
});
describe('->isSome', function () {
test('returns false for None', function () {
expect(Option::None()->isSome)->toBeFalse();
});
test('returns true for Some', function () {
expect(Option::Some('boo')->isSome)->toBeTrue();
});
});
describe('->getOrDefault()', function () {
test('returns default value for None', function () {
expect(Option::None()->getOrDefault('yes'))->toBe('yes');
});
test('returns option value for Some', function () {
expect(Option::Some('no')->getOrDefault('yes'))->toBe('no');
});
});
describe('->getOrCall()', function () {
test('returns value from callable for None', function () {
expect(Option::None()->getOrCall(fn() => 'called'))->toBe('called');
});
test('returns option value for Some', function () {
expect(Option::Some('passed')->getOrCall(fn() => 'called'))->toBe('passed');
});
});
describe('->getOrThrow()', function () {
test('throws an exception for None', function () {
expect(fn() => Option::None()->getOrThrow(fn() => throw new BadMethodCallException()))
->toThrow(BadMethodCallException::class);
});
test('returns option value for Some', function () {
expect(Option::Some('no throw')->getOrThrow(fn() => throw new BadMethodCallException()))->toBe('no throw');
});
});
describe('->bind()', function () {
test('returns None when binding against None', function () {
$original = Option::None();
$bound = $original->bind(fn($it) => Option::Some('value'));
expect($bound)->isNone->toBeTrue()->and($bound)->toBe($original);
});
test('returns Some when binding against Some with Some', function () {
expect(Option::Some('hello')->bind(fn($it) => Option::Some('goodbye')))
->isSome->toBeTrue()->value->toBe('goodbye');
});
test('returns None when binding against Some with None', function () {
expect(Option::Some('greetings')->bind(fn($it) => Option::None()))->isNone->toBeTrue();
});
});
describe('->contains()', function () {
test('returns false for None', function () {
expect(Option::None()->contains(null))->toBeFalse();
});
test('returns true for Some when strict equality is matched', function () {
expect(Option::Some(3)->contains(3))->toBeTrue();
});
test('returns false for Some when strict equality is not matched', function () {
expect(Option::Some('3')->contains(3))->toBeFalse();
});
test('returns true for Some when loose equality is matched', function () {
expect(Option::Some('3')->contains(3, strict: false))->toBeTrue();
});
test('returns false for Some when loose equality is not matched', function () {
expect(Option::Some('3')->contains(4, strict: false))->toBeFalse();
});
});
describe('->exists()', function () {
test('returns false for None', function () {
expect(Option::None()->exists(fn($it) => true))->toBeFalse();
});
test('returns true for Some and matching condition', function () {
expect(Option::Some(14)->exists(fn($it) => $it < 100))->toBeTrue();
});
test('returns false for Some and non-matching condition', function () {
expect(Option::Some(14)->exists(fn($it) => $it > 100))->toBeFalse();
});
});
describe('->map()', function () {
test('does nothing for None', function () {
$tattle = new class { public bool $called = false; };
$none = Option::None();
$mapped = $none->map(function () use ($tattle)
{
$tattle->called = true;
return 'hello';
});
expect($mapped)
->isNone->toBeTrue()
->and($tattle->called)->toBeFalse()
->and($mapped)->toBe($none);
});
test('maps value for Some', function () {
expect(Option::Some('abc ')->map(fn($it) => str_repeat($it, 2)))
->isSome->toBeTrue()->value->toBe('abc abc ');
});
test('throws an exception if mapping returns null', function () {
expect(fn() => Option::Some('oof')->map(fn($it) => null))->toThrow(InvalidArgumentException::class);
});
});
describe('->iter()', function () {
test('does nothing for None', function () {
$target = new class { public mixed $called = null; };
Option::None()->iter(function () use ($target) { $target->called = 'uh oh'; });
expect($target->called)->toBeNull();
});
test('iterates for Some', function () {
$target = new class { public mixed $called = null; };
Option::Some(33)->iter(function ($it) use ($target) { $target->called = $it; });
expect($target->called)->toBe(33);
});
});
describe('->filter()', function () {
test('does nothing for None', function () {
$tattle = new class { public bool $called = false; };
$none = Option::None();
$filtered = $none->filter(function () use ($tattle)
{
$tattle->called = true;
return true;
});
expect($filtered)
->isNone->toBeTrue()
->and($tattle->called)->toBeFalse()
->and($filtered)->toBe($none);
});
test('returns Some when filter is matched', function () {
$some = Option::Some(12);
$filtered = $some->filter(fn($it) => $it % 2 === 0);
expect($filtered)
->isSome->toBeTrue()
->value->toBe(12)
->and($filtered)->toBe($some);
});
test('returns None when filter is not matched', function () {
expect(Option::Some(23)->filter(fn($it) => $it % 2 === 0)->isNone)->toBeTrue();
});
});
describe('->unwrap()', function () {
test('returns null for None', function () {
expect(Option::None()->unwrap())->toBeNull();
});
test('returns option value for Some', function () {
expect(Option::Some('boy howdy')->unwrap())->toBe('boy howdy');
});
});
describe('->tap()', function () {
test('is called for None', function () {
$value = '';
$original = Option::None();
$tapped = $original->tap(
function (Option $it) use (&$value) { $value = $it->isSome ? $it->value : 'none'; });
expect($value)->toBe('none')->and($original)->toBe($tapped);
});
test('is called for Some', function () {
$value = '';
$original = Option::Some('testing');
$tapped = $original->tap(
function (Option $it) use (&$value) { $value = $it->isSome ? $it->value : 'none'; });
expect($value)->toBe('testing')->and($original)->toBe($tapped);
});
});
describe('->toArray()', function () {
test('returns empty array for None', function () {
expect(Option::None()->toArray())->not->toBeNull()->toBeEmpty();
});
test('returns one-item array for Some', function () {
expect(Option::Some('15')->toArray())->not->toBeNull()->toBe(['15']);
});
});
describe('->toPhpOption()', function () {
test('converts None', function () {
expect(Option::None()->toPhpOption())
->toBeInstanceOf('PhpOption\None')
->not->toBeNull()
->isDefined()->toBeFalse();
});
test('converts Some', function () {
expect(Option::Some('php')->toPhpOption())
->toBeInstanceOf('PhpOption\Some')
->not->toBeNull()
->isDefined()->toBeTrue()
->get()->toBe('php');
});
});
describe('::Some()', function () {
test('creates a Some option when given a value', function () {
expect(Option::Some('hello'))->not->toBeNull()->isSome->toBeTrue();
});
test('throws an exception when given null', function () {
expect(fn() => Option::Some(null))->toThrow(InvalidArgumentException::class);
});
});
describe('::None()', function () {
test('creates a None option', function () {
expect(Option::None())->not->toBeNull()->isNone->toBeTrue();
});
});
describe('::of()', function () {
test('creates a None option when given null', function () {
expect(Option::of(null))->not->toBeNull()->isNone->toBeTrue();
});
test('creates a Some option when given a value', function () {
expect(Option::of('test'))->not->toBeNull()
->isSome->toBeTrue()
->value->toBe('test');
});
test('creates a None option when given PhpOption\None', function () {
expect(Option::of(None::create()))->isNone->toBeTrue();
});
test('creates a Some option when given PhpOption\Some', function () {
expect(Option::of(Some::create('something')))->isSome->toBeTrue()->value->toBe('something');
});
});

View File

@ -1,206 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use BitBadger\InspiredByFSharp\{Option, Result};
describe('->ok', function () {
test('returns OK value for OK result', function () {
expect(Result::OK('yay')->ok)->toBe('yay');
});
test('throws an exception for Error result', function () {
expect(fn() => Result::Error('whoops')->ok)->toThrow(InvalidArgumentException::class);
});
});
describe('->error', function () {
test('throws an exception for OK result', function () {
expect(fn() => Result::OK('yeah')->error)->toThrow(InvalidArgumentException::class);
});
test('returns Error value for Error result', function () {
expect(Result::Error('boo')->error)->toBe('boo');
});
});
describe('->isOK', function () {
test('returns true for OK result', function () {
expect(Result::OK('ok')->isOK)->toBeTrue();
});
test('returns false for Error result', function () {
expect(Result::Error('error')->isOK)->toBeFalse();
});
});
describe('->isError', function () {
test('returns false for OK result', function () {
expect(Result::OK('fine')->isError)->toBeFalse();
});
test('returns true for Error result', function () {
expect(Result::Error('not ok')->isError)->toBeTrue();
});
});
describe('->bind()', function () {
test('returns OK when binding against OK with OK', function () {
expect(Result::OK('one')->bind(fn($it) => Result::OK("$it two")))
->isOK->toBeTrue()->ok->toBe('one two');
});
test('returns Error when binding against OK with Error', function () {
expect(Result::OK('three')->bind(fn($it) => Result::Error('back to two')))
->isError->toBeTrue()->error->toBe('back to two');
});
test('returns Error when binding against Error', function () {
$original = Result::Error('oops');
$result = $original->bind(fn($it) => Result::OK('never mind - it worked!'));
expect($result->isError)->toBeTrue()
->and($result)->toBe($original);
});
});
describe('->contains()', function () {
test('returns true when OK is strictly equal', function () {
expect(Result::OK(18)->contains(18))->toBeTrue();
});
test('returns false when OK is not strictly equal', function () {
expect(Result::OK(18)->contains('18'))->toBeFalse();
});
test('returns true when OK is loosely equal', function () {
expect(Result::OK(18)->contains('18', strict: false))->toBeTrue();
});
test('returns false when OK is not loosely equal', function () {
expect(Result::OK(18)->contains(17, strict: false))->toBeFalse();
});
test('returns false for Error', function () {
expect(Result::Error('ouch')->contains('ouch'))->toBeFalse();
});
});
describe('->exists()', function () {
test('returns true for OK when condition matches', function () {
expect(Result::OK(14)->exists(fn($it) => $it < 100))->toBeTrue();
});
test('returns false for OK when condition does not match', function () {
expect(Result::OK(14)->exists(fn($it) => $it > 100))->toBeFalse();
});
test('returns false for Error', function () {
expect(Result::Error(true)->exists(fn($it) => true))->toBeFalse();
});
});
describe('->map()', function () {
test('maps value for OK', function () {
expect(Result::OK('yard')->map(fn($it) => strrev($it)))
->isOK->toBeTrue()->ok->toBe('dray');
});
test('throws an exception for OK when mapping result is null', function () {
expect(fn() => Result::OK('not null')->map(fn($it) => null))->toThrow(InvalidArgumentException::class);
});
test('does nothing for Error', function () {
$tattle = new class { public bool $called = false; };
$error = Result::Error('nope');
$mapped = $error->map(function () use ($tattle)
{
$tattle->called = true;
return 'hello';
});
expect($mapped)
->isError->toBeTrue()
->and($tattle->called)->toBeFalse()
->and($mapped)->toBe($error);
});
});
describe('->mapError()', function () {
test('does nothing for OK', function () {
$tattle = new class { public bool $called = false; };
$ok = Result::OK('sure');
$mapped = $ok->mapError(function () use ($tattle)
{
$tattle->called = true;
return 'hello';
});
expect($mapped)
->isOK->toBeTrue()
->and($tattle->called)->toBeFalse()
->and($mapped)->toBe($ok);
});
test('maps value for Error', function () {
expect(Result::Error('taco')->mapError(fn($it) => str_repeat('*', strlen($it))))
->isError->toBeTrue()->error->toBe('****');
});
test('throws an exception for Error when mapping result is null', function () {
expect(fn() => Result::Error('pizza')->mapError(fn($it) => null))->toThrow(InvalidArgumentException::class);
});
});
describe('->iter()', function () {
test('iterates for OK', function () {
$target = new class { public mixed $called = null; };
Result::OK(77)->iter(function ($it) use ($target) { $target->called = $it; });
expect($target->called)->toBe(77);
});
test('does nothing for Error', function () {
$target = new class { public mixed $called = null; };
Result::Error('')->iter(function () use ($target) { $target->called = 'uh oh'; });
expect($target->called)->toBeNull();
});
});
describe('->toArray()', function () {
test('returns a one-item array for OK', function () {
expect(Result::OK('yay')->toArray())->not->toBeNull()->toBe(['yay']);
});
test('returns an empty array for Error', function () {
expect(Result::Error('oh no')->toArray())->not->toBeNull()->toBeEmpty();
});
});
describe('->toOption()', function () {
test('returns a Some option for OK', function () {
expect(Result::OK(99)->toOption())->isSome->toBeTrue()->value->toBe(99);
});
test('returns a None option for Error', function () {
expect(Result::Error('file not found')->toOption())->isNone->toBeTrue();
});
});
describe('->tap()', function () {
test('is called for OK', function () {
$value = '';
$original = Result::OK('working');
$tapped = $original->tap(function (Result $it) use (&$value) {
$value = $it->isOK ? 'OK: ' . $it->ok : 'Error: ' . $it->error;
});
expect($value)->toBe('OK: working')->and($tapped)->toBe($original);
});
test('is called for Error', function () {
$value = '';
$original = Result::Error('failed');
$tapped = $original->tap(function (Result $it) use (&$value) {
$value = $it->isOK ? 'OK: ' . $it->ok : 'Error: ' . $it->error;
});
expect($value)->toBe('Error: failed')->and($tapped)->toBe($original);
});
});
describe('::OK()', function () {
test('creates an OK result for a non-null value', function () {
expect(Result::OK('something'))->isOK->toBeTrue()->ok->toBe('something');
});
test('throws an exception for OK with a null value', function () {
expect(fn() => Result::OK(null))->toThrow(InvalidArgumentException::class);
});
});
describe('::Error()', function () {
test('creates an Error result for a non-null value', function () {
expect(Result::Error('sad trombone'))->isError->toBeTrue()->error->toBe('sad trombone');
});
test('throws an exception for Error with a null value', function () {
expect(fn() => Result::Error(null))->toThrow(InvalidArgumentException::class);
});
});