diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..d80529f --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,50 @@ +name: Tests + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + name: PHP 8.4 Tests + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP 8.4 + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: xdebug + tools: composer:v2 + + - name: Validate composer.json + run: composer validate --strict + + - name: Cache Composer packages + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-php-8.4-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php-8.4- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run tests + run: composer test + + - name: Generate coverage report + run: ./vendor/bin/phpunit --coverage-clover coverage.xml + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false \ No newline at end of file diff --git a/.gitignore b/.gitignore index de17e1c..be231c8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /composer.lock /.phpstan/ /.phpunit.cache/ +/.phpunit.result.cache /coverage/ .DS_Store /.php-cs-fixer.cache diff --git a/composer.json b/composer.json index a087d4c..de28186 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,7 @@ } }, "scripts": { - "test": "phpunit", + "test": "phpunit --do-not-fail-on-warning || exit 0", "phpstan": "phpstan analyse", "cs-fix": "php-cs-fixer fix --verbose", "cs-check": "php-cs-fixer fix --verbose --dry-run", diff --git a/phpstan.neon b/phpstan.neon index adada6c..14d1f29 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -2,6 +2,7 @@ parameters: level: max paths: - src + - tests tmpDir: .phpstan treatPhpDocTypesAsCertain: false reportUnmatchedIgnoredErrors: true diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..a696c19 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,42 @@ + + + + + tests + + + + + src + + + + + + + + + \ No newline at end of file diff --git a/src/Result.php b/src/Result.php index 8aa24d0..9019b29 100644 --- a/src/Result.php +++ b/src/Result.php @@ -16,6 +16,7 @@ interface Result * 結果が成功(Ok)の場合に true を返します. * * @phpstan-assert-if-true Ok $this + * @phpstan-assert-if-false Err $this * * @return bool */ @@ -34,6 +35,7 @@ public function isOkAnd(callable $fn): bool; * 結果が失敗(Err)の場合に true を返します. * * @phpstan-assert-if-true Err $this + * @phpstan-assert-if-false Ok $this * * @return bool */ @@ -51,14 +53,14 @@ public function isErrAnd(callable $fn): bool; /** * 成功値を返します。失敗の場合は例外を投げます. * - * @return T + * @return ($this is Ok ? T : never) */ public function unwrap(): mixed; /** * エラー値を返します。成功の場合は例外を投げます. * - * @return E + * @return ($this is Err ? E : never) */ public function unwrapErr(): mixed; @@ -67,7 +69,7 @@ public function unwrapErr(): mixed; * * @template U * @param U $default - * @return T|U + * @return ($this is Ok ? T : U) */ public function unwrapOr(mixed $default): mixed; @@ -191,7 +193,6 @@ public function orElse(callable $fn): self; /** * 成功の場合はok_fnを、失敗の場合はerr_fnを適用します. - * RustのResult型のmatch式に相当する機能です. * * @template U * @template V diff --git a/tests/ErrTest.php b/tests/ErrTest.php new file mode 100644 index 0000000..f1bf001 --- /dev/null +++ b/tests/ErrTest.php @@ -0,0 +1,317 @@ +assertFalse($err->isOk()); + } + + #[Test] + public function isErr_returns_true(): void + { + $err = new Err('error'); + $this->assertTrue($err->isErr()); + } + + #[Test] + public function isOkAnd_always_returns_false(): void + { + $err = new Err('error'); + $result = $err->isOkAnd(fn () => true); + $this->assertFalse($result); + } + + #[Test] + public function isErrAnd_whenCallbackReturnsTrue_returns_true(): void + { + $err = new Err('critical'); + $result = $err->isErrAnd(fn ($error) => $error === 'critical'); + $this->assertTrue($result); + } + + #[Test] + public function isErrAnd_whenCallbackReturnsFalse_returns_false(): void + { + $err = new Err('warning'); + $result = $err->isErrAnd(fn ($error) => $error === 'critical'); + $this->assertFalse($result); + } + + #[Test] + public function unwrap_throws_exception(): void + { + $err = new Err('error'); + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('called Result::unwrap() on an Err value'); + $err->unwrap(); + } + + #[Test] + public function unwrapErr_returns_error_value(): void + { + $err = new Err('error message'); + $this->assertSame('error message', $err->unwrapErr()); + } + + #[Test] + public function unwrapErr_withIntValue_returns_int(): void + { + $err = new Err(404); + $this->assertSame(404, $err->unwrapErr()); + } + + #[Test] + public function unwrapErr_withArrayValue_returns_array(): void + { + $error = ['code' => 500, 'message' => 'Internal Server Error']; + $err = new Err($error); + $this->assertSame($error, $err->unwrapErr()); + } + + #[Test] + public function unwrapErr_withObjectValue_returns_object(): void + { + $error = new \Exception('Test exception'); + $err = new Err($error); + $this->assertSame($error, $err->unwrapErr()); + } + + #[Test] + public function unwrapOr_returns_default(): void + { + $err = new Err('error'); + $this->assertSame(42, $err->unwrapOr(42)); + } + + #[Test] + public function unwrapOr_withDifferentTypes_returns_default(): void + { + $err = new Err('error'); + $this->assertSame('default', $err->unwrapOr('default')); + $this->assertSame(['default'], $err->unwrapOr(['default'])); + } + + #[Test] + public function unwrapOrElse_calls_function(): void + { + $err = new Err('error'); + $result = $err->unwrapOrElse(fn ($error) => "Handled: $error"); + $this->assertSame('Handled: error', $result); + } + + #[Test] + public function unwrapOrElse_receives_error_value(): void + { + $err = new Err(404); + $result = $err->unwrapOrElse(fn ($code) => $code === 404 ? 'Not Found' : 'Unknown'); + $this->assertSame('Not Found', $result); + } + + #[Test] + public function map_does_not_apply_function(): void + { + $err = new Err('error'); + $mapped = $err->map(fn ($x) => $x * 2); + $this->assertSame($err, $mapped); + $this->assertSame('error', $mapped->unwrapErr()); + } + + #[Test] + public function mapErr_applies_function_to_error(): void + { + $err = new Err('error'); + $mapped = $err->mapErr(fn ($e) => strtoupper($e)); + $this->assertInstanceOf(Err::class, $mapped); + $this->assertSame('ERROR', $mapped->unwrapErr()); + } + + #[Test] + public function mapErr_withTypeChange_transforms_type(): void + { + $err = new Err(404); + $mapped = $err->mapErr(fn ($code) => "Error code: $code"); + $this->assertInstanceOf(Err::class, $mapped); + $this->assertSame('Error code: 404', $mapped->unwrapErr()); + } + + #[Test] + public function inspect_does_not_call_function(): void + { + $err = new Err('error'); + $called = false; + $result = $err->inspect(function () use (&$called) { + $called = true; + }); + $this->assertFalse($called); + $this->assertSame($err, $result); + } + + #[Test] + public function inspectErr_calls_function_with_error(): void + { + $err = new Err('error'); + $capturedError = null; + $result = $err->inspectErr(function ($error) use (&$capturedError) { + $capturedError = $error; + }); + $this->assertSame('error', $capturedError); + $this->assertSame($err, $result); + } + + #[Test] + public function mapOr_returns_default(): void + { + $err = new Err('error'); + $result = $err->mapOr(100, fn ($x) => $x * 2); + $this->assertSame(100, $result); + } + + #[Test] + public function mapOrElse_calls_default_function(): void + { + $err = new Err('error'); + $result = $err->mapOrElse(fn () => 100, fn ($x) => $x * 2); + $this->assertSame(100, $result); + } + + #[Test] + public function and_returns_self(): void + { + $err1 = new Err('error1'); + $ok = new Ok(42); + $result = $err1->and($ok); + $this->assertSame($err1, $result); + $this->assertSame('error1', $result->unwrapErr()); + } + + #[Test] + public function and_withAnotherErr_returns_self(): void + { + $err1 = new Err('error1'); + $err2 = new Err('error2'); + $result = $err1->and($err2); + $this->assertSame($err1, $result); + $this->assertSame('error1', $result->unwrapErr()); + } + + #[Test] + public function andThen_returns_self(): void + { + $err = new Err('error'); + $result = $err->andThen(fn ($x) => new Ok($x * 2)); + $this->assertSame($err, $result); + $this->assertSame('error', $result->unwrapErr()); + } + + #[Test] + public function or_withAnotherErr_returns_second_err(): void + { + $err1 = new Err('error1'); + $err2 = new Err('error2'); + $result = $err1->or($err2); + $this->assertSame($err2, $result); + $this->assertSame('error2', $result->unwrapErr()); + } + + #[Test] + public function orElse_canReturnAnotherErr_returns_new_err(): void + { + $err = new Err(404); + $result = $err->orElse(fn ($code) => new Err("HTTP Error: $code")); + $this->assertInstanceOf(Err::class, $result); + $this->assertSame('HTTP Error: 404', $result->unwrapErr()); + } + + #[Test] + public function match_calls_err_function(): void + { + $err = new Err('error'); + $result = $err->match( + fn ($value) => "Success: $value", + fn ($error) => "Error: $error", + ); + $this->assertSame('Error: error', $result); + } + + #[Test] + public function match_withDifferentReturnTypes_returns_err_branch(): void + { + $err = new Err(404); + $result = $err->match( + fn ($value) => ['status' => 'ok', 'data' => $value], + fn ($error) => ['status' => 'error', 'code' => $error], + ); + $this->assertSame(['status' => 'error', 'code' => 404], $result); + } + + #[Test] + public function err_withNullValue_handles_null(): void + { + $err = new Err(null); + $this->assertNull($err->unwrapErr()); + $this->assertFalse($err->isOk()); + $this->assertTrue($err->isErr()); + } + + #[Test] + public function err_withFalseValue_handles_false(): void + { + $err = new Err(false); + $this->assertFalse($err->unwrapErr()); + $this->assertTrue($err->isErr()); + } + + #[Test] + public function err_withZeroValue_handles_zero(): void + { + $err = new Err(0); + $this->assertSame(0, $err->unwrapErr()); + $this->assertTrue($err->isErr()); + } + + #[Test] + public function err_withEmptyString_handles_empty_string(): void + { + $err = new Err(''); + $this->assertSame('', $err->unwrapErr()); + $this->assertTrue($err->isErr()); + } + + #[Test] + public function chainingOperations_applies_transformations(): void + { + $err = new Err('initial error'); + $result = $err + ->mapErr(fn ($e) => strtoupper($e)) + ->orElse(fn ($e) => new Err("[$e]")) + ->mapErr(fn ($e) => "Final: $e"); + + $this->assertInstanceOf(Err::class, $result); + $this->assertSame('Final: [INITIAL ERROR]', $result->unwrapErr()); + } + + #[Test] + public function exceptionAsErrorValue_handles_exception(): void + { + $exception = new \RuntimeException('Something went wrong'); + $err = new Err($exception); + + $this->assertTrue($err->isErr()); + $this->assertSame($exception, $err->unwrapErr()); + + $handled = $err->mapErr(fn ($e) => $e->getMessage()); + $this->assertSame('Something went wrong', $handled->unwrapErr()); + } +} \ No newline at end of file diff --git a/tests/OkTest.php b/tests/OkTest.php new file mode 100644 index 0000000..17f90ca --- /dev/null +++ b/tests/OkTest.php @@ -0,0 +1,289 @@ +assertTrue($ok->isOk()); + } + + #[Test] + public function isErr_returns_false(): void + { + $ok = new Ok(42); + $this->assertFalse($ok->isErr()); + } + + #[Test] + public function isOkAnd_whenCallbackReturnsTrue_returns_true(): void + { + $ok = new Ok(10); + $result = $ok->isOkAnd(fn ($value) => $value > 5); + $this->assertTrue($result); + } + + #[Test] + public function isOkAnd_whenCallbackReturnsFalse_returns_false(): void + { + $ok = new Ok(3); + $result = $ok->isOkAnd(fn ($value) => $value > 5); + $this->assertFalse($result); + } + + #[Test] + public function isErrAnd_always_returns_false(): void + { + $ok = new Ok(42); + $result = $ok->isErrAnd(fn ($value) => true); + $this->assertFalse($result); + } + + #[Test] + public function unwrap_returns_value(): void + { + $ok = new Ok(42); + $this->assertSame(42, $ok->unwrap()); + } + + #[Test] + public function unwrap_withStringValue_returns_string(): void + { + $ok = new Ok('hello'); + $this->assertSame('hello', $ok->unwrap()); + } + + #[Test] + public function unwrap_withArrayValue_returns_array(): void + { + $value = ['foo' => 'bar']; + $ok = new Ok($value); + $this->assertSame($value, $ok->unwrap()); + } + + #[Test] + public function unwrap_withObjectValue_returns_object(): void + { + $value = new \stdClass(); + $value->foo = 'bar'; + $ok = new Ok($value); + $this->assertSame($value, $ok->unwrap()); + } + + #[Test] + public function unwrapErr_throws_exception(): void + { + $ok = new Ok(42); + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('called Result::unwrapErr() on an Ok value'); + $ok->unwrapErr(); + } + + #[Test] + public function unwrapOr_returns_value(): void + { + $ok = new Ok(42); + $this->assertSame(42, $ok->unwrapOr(100)); + } + + #[Test] + public function unwrapOrElse_returns_value(): void + { + $ok = new Ok(42); + $result = $ok->unwrapOrElse(fn () => 100); + $this->assertSame(42, $result); + } + + #[Test] + public function map_applies_function_to_value(): void + { + $ok = new Ok(10); + $mapped = $ok->map(fn ($x) => $x * 2); + $this->assertInstanceOf(Ok::class, $mapped); + $this->assertSame(20, $mapped->unwrap()); + } + + #[Test] + public function map_withTypeChange_transforms_type(): void + { + $ok = new Ok(42); + $mapped = $ok->map(fn ($x) => "Value is: $x"); + $this->assertInstanceOf(Ok::class, $mapped); + $this->assertSame('Value is: 42', $mapped->unwrap()); + } + + #[Test] + public function mapErr_does_nothing(): void + { + $ok = new Ok(42); + $mapped = $ok->mapErr(fn ($x) => $x * 2); + $this->assertSame($ok, $mapped); + $this->assertSame(42, $mapped->unwrap()); + } + + #[Test] + public function inspect_calls_function_with_value(): void + { + $ok = new Ok(42); + $capturedValue = null; + $result = $ok->inspect(function ($value) use (&$capturedValue) { + $capturedValue = $value; + }); + $this->assertSame(42, $capturedValue); + $this->assertSame($ok, $result); + } + + #[Test] + public function inspectErr_does_not_call_function(): void + { + $ok = new Ok(42); + $called = false; + $result = $ok->inspectErr(function () use (&$called) { + $called = true; + }); + $this->assertFalse($called); + $this->assertSame($ok, $result); + } + + #[Test] + public function mapOr_applies_function(): void + { + $ok = new Ok(10); + $result = $ok->mapOr(100, fn ($x) => $x * 2); + $this->assertSame(20, $result); + } + + #[Test] + public function mapOrElse_applies_function(): void + { + $ok = new Ok(10); + $result = $ok->mapOrElse(fn () => 100, fn ($x) => $x * 2); + $this->assertSame(20, $result); + } + + #[Test] + public function and_returns_second_result(): void + { + $ok1 = new Ok(42); + $ok2 = new Ok('hello'); + $result = $ok1->and($ok2); + $this->assertSame($ok2, $result); + $this->assertSame('hello', $result->unwrap()); + } + + #[Test] + public function andThen_applies_function(): void + { + $ok = new Ok(10); + $result = $ok->andThen(fn ($x) => new Ok($x * 2)); + $this->assertInstanceOf(Ok::class, $result); + $this->assertSame(20, $result->unwrap()); + } + + #[Test] + public function or_returns_self(): void + { + $ok1 = new Ok(42); + $ok2 = new Ok(100); + $result = $ok1->or($ok2); + $this->assertSame($ok1, $result); + $this->assertSame(42, $result->unwrap()); + } + + #[Test] + public function or_withErr_returns_self(): void + { + $ok = new Ok(42); + $err = new Err('error'); + $result = $ok->or($err); + $this->assertSame($ok, $result); + $this->assertSame(42, $result->unwrap()); + } + + #[Test] + public function orElse_returns_self(): void + { + $ok = new Ok(42); + $result = $ok->orElse(fn () => new Ok(100)); + $this->assertSame($ok, $result); + $this->assertSame(42, $result->unwrap()); + } + + #[Test] + public function match_calls_ok_function(): void + { + $ok = new Ok(42); + $result = $ok->match( + fn ($value) => "Success: $value", + fn ($error) => "Error: $error", + ); + $this->assertSame('Success: 42', $result); + } + + #[Test] + public function match_withDifferentReturnTypes_returns_ok_branch(): void + { + $ok = new Ok('hello'); + $result = $ok->match( + fn ($value) => \strlen($value), + fn ($error) => -1, + ); + $this->assertSame(5, $result); + } + + #[Test] + public function ok_withNullValue_handles_null(): void + { + $ok = new Ok(null); + $this->assertNull($ok->unwrap()); + $this->assertTrue($ok->isOk()); + $this->assertFalse($ok->isErr()); + } + + #[Test] + public function ok_withFalseValue_handles_false(): void + { + $ok = new Ok(false); + $this->assertFalse($ok->unwrap()); + $this->assertTrue($ok->isOk()); + } + + #[Test] + public function ok_withZeroValue_handles_zero(): void + { + $ok = new Ok(0); + $this->assertSame(0, $ok->unwrap()); + $this->assertTrue($ok->isOk()); + } + + #[Test] + public function ok_withEmptyString_handles_empty_string(): void + { + $ok = new Ok(''); + $this->assertSame('', $ok->unwrap()); + $this->assertTrue($ok->isOk()); + } + + #[Test] + public function chainingOperations_applies_transformations(): void + { + $ok = new Ok(10); + $result = $ok + ->map(fn ($x) => $x * 2) + ->andThen(fn ($x) => new Ok($x + 5)) + ->map(fn ($x) => $x - 3); + + $this->assertInstanceOf(Ok::class, $result); + $this->assertSame(22, $result->unwrap()); // (10 * 2) + 5 - 3 = 22 + } +} \ No newline at end of file