diff --git a/.github/workflows/php-test.yml b/.github/workflows/php-test.yml new file mode 100644 index 0000000..bfdc56b --- /dev/null +++ b/.github/workflows/php-test.yml @@ -0,0 +1,61 @@ +name: Unit Tests + +on: + push: + branches: + - trunk + - 'feature/**' + - 'release/**' + # Only run if PHP-related files changed. + paths: + - '.github/workflows/php-test.yml' + - '**.php' + - 'phpunit.xml.dist' + - 'composer.json' + - 'composer.lock' + pull_request: + # Only run if PHP-related files changed. + paths: + - '.github/workflows/php-test.yml' + - '**.php' + - 'phpunit.xml.dist' + - 'composer.json' + - 'composer.lock' + types: + - opened + - reopened + - synchronize + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + php-version: ['7.4', '8.0', '8.4'] + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + coverage: xdebug + + - name: Validate composer.json and composer.lock + run: composer validate --strict + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-php-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php-${{ matrix.php-version }}- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run unit tests + run: composer phpunit diff --git a/.gitignore b/.gitignore index dfcbf4f..9a14a78 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,9 @@ vendor/ CLAUDE.md .cursor/ GEMINI.md + +############ +## PHPUnit +############ + +.phpunit.cache/ \ No newline at end of file diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..035bf01 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,27 @@ + + + + + tests/unit + + + + + + src + + + diff --git a/tests/mocks/Enums/InvalidNameTestEnum.php b/tests/mocks/Enums/InvalidNameTestEnum.php new file mode 100644 index 0000000..3023bde --- /dev/null +++ b/tests/mocks/Enums/InvalidNameTestEnum.php @@ -0,0 +1,17 @@ +assertInstanceOf(ValidTestEnum::class, $enum); + $this->assertSame('first', $enum->value); + $this->assertSame('FIRST_NAME', $enum->name); + } + + + /** + * Tests that from() throws an exception for invalid values. + */ + public function testFromWithInvalidValueThrowsException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('invalid is not a valid backing value for enum WordPress\AiClient\Tests\mocks\Enums\ValidTestEnum'); + ValidTestEnum::from('invalid'); + } + + /** + * Tests that tryFrom() returns an enum instance for valid values. + */ + public function testTryFromWithValidValue(): void + { + $enum = ValidTestEnum::tryFrom('first'); + $this->assertInstanceOf(ValidTestEnum::class, $enum); + $this->assertSame('first', $enum->value); + } + + /** + * Tests that tryFrom() returns null for invalid values. + */ + public function testTryFromWithInvalidValueReturnsNull(): void + { + $enum = ValidTestEnum::tryFrom('invalid'); + $this->assertNull($enum); + } + + /** + * Tests that cases() returns all enum instances. + */ + public function testCasesReturnsAllEnumInstances(): void + { + $cases = ValidTestEnum::cases(); + $this->assertCount(2, $cases); + + $values = array_map(fn($case) => $case->value, $cases); + $this->assertContains('first', $values); + $this->assertContains('last', $values); + + $names = array_map(fn($case) => $case->name, $cases); + $this->assertContains('FIRST_NAME', $names); + $this->assertContains('LAST_NAME', $names); + } + + /** + * Tests that enum instances are singletons. + */ + public function testSingletonBehavior(): void + { + $enum1 = ValidTestEnum::from('first'); + $enum2 = ValidTestEnum::from('first'); + $enum3 = ValidTestEnum::firstName(); + + $this->assertSame($enum1, $enum2); + $this->assertSame($enum1, $enum3); + } + + /** + * Tests static factory methods for creating enum instances. + */ + public function testStaticFactoryMethods(): void + { + $firstName = ValidTestEnum::firstName(); + $this->assertSame('first', $firstName->value); + $this->assertSame('FIRST_NAME', $firstName->name); + + $lastName = ValidTestEnum::lastName(); + $this->assertSame('last', $lastName->value); + $this->assertSame('LAST_NAME', $lastName->name); + } + + /** + * Tests that invalid static methods throw exceptions. + */ + public function testInvalidStaticMethodThrowsException(): void + { + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage( + 'Method WordPress\AiClient\Tests\mocks\Enums\ValidTestEnum::invalidMethod does not exist' + ); + ValidTestEnum::invalidMethod(); + } + + /** + * Tests the is* check methods. + */ + public function testIsCheckMethods(): void + { + $enum = ValidTestEnum::firstName(); + + $this->assertTrue($enum->isFirstName()); + $this->assertFalse($enum->isLastName()); + } + + /** + * Tests that invalid is* methods throw exceptions. + */ + public function testInvalidIsMethodThrowsException(): void + { + $enum = ValidTestEnum::firstName(); + + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage( + 'Method WordPress\AiClient\Tests\mocks\Enums\ValidTestEnum::isInvalidMethod does not exist' + ); + $enum->isInvalidMethod(); + } + + /** + * Tests the equals() method with various values. + */ + public function testEqualsWithSameValue(): void + { + $enum = ValidTestEnum::firstName(); + + $this->assertTrue($enum->equals('first')); + $this->assertTrue($enum->equals(ValidTestEnum::firstName())); + $this->assertFalse($enum->equals('last')); + $this->assertFalse($enum->equals(ValidTestEnum::lastName())); + } + + + /** + * Tests the is() method for identity comparison. + */ + public function testIsMethodForIdentityComparison(): void + { + $enum1 = ValidTestEnum::firstName(); + $enum2 = ValidTestEnum::firstName(); + $enum3 = ValidTestEnum::lastName(); + + $this->assertTrue($enum1->is($enum2)); // Same instance + $this->assertFalse($enum1->is($enum3)); // Different instance + } + + /** + * Tests that getValues() returns all valid enum values. + */ + public function testGetValuesReturnsAllValidValues(): void + { + $values = ValidTestEnum::getValues(); + + $this->assertSame([ + 'FIRST_NAME' => 'first', + 'LAST_NAME' => 'last', + ], $values); + } + + /** + * Tests the isValidValue() method. + */ + public function testIsValidValue(): void + { + $this->assertTrue(ValidTestEnum::isValidValue('first')); + $this->assertTrue(ValidTestEnum::isValidValue('last')); + + $this->assertFalse(ValidTestEnum::isValidValue('invalid')); + } + + /** + * Tests that enum properties are read-only. + */ + public function testPropertiesAreReadOnly(): void + { + $enum = ValidTestEnum::firstName(); + + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage( + 'Cannot modify property WordPress\AiClient\Tests\mocks\Enums\ValidTestEnum::value - enum properties are read-only' + ); + $enum->value = 'modified'; + } + + /** + * Tests that accessing invalid properties throws exceptions. + */ + public function testInvalidPropertyAccessThrowsException(): void + { + $enum = ValidTestEnum::firstName(); + + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage( + 'Property WordPress\AiClient\Tests\mocks\Enums\ValidTestEnum::invalid does not exist' + ); + $enum->invalid; + } + + /** + * Tests the __toString() method. + */ + public function testToString(): void + { + $stringEnum = ValidTestEnum::firstName(); + + $this->assertSame('first', (string) $stringEnum); + } + + /** + * Tests that invalid constant names throw exceptions. + */ + public function testInvalidConstantNameThrowsException(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'Invalid enum constant name "invalid_name" in ' . + 'WordPress\AiClient\Tests\mocks\Enums\InvalidNameTestEnum. Constants must be UPPER_SNAKE_CASE.' + ); + + InvalidNameTestEnum::cases(); + } + + /** + * Tests that invalid constant types throw exceptions. + */ + public function testInvalidConstantTypeThrowsException(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'Invalid enum value type for constant ' . + 'WordPress\AiClient\Tests\mocks\Enums\InvalidTypeTestEnum::INT_VALUE. ' . + 'Only string values are allowed, integer given.' + ); + + InvalidTypeTestEnum::cases(); + } +} diff --git a/tests/unit/EnumTestTrait.php b/tests/unit/EnumTestTrait.php new file mode 100644 index 0000000..3d282fd --- /dev/null +++ b/tests/unit/EnumTestTrait.php @@ -0,0 +1,133 @@ + The enum class name. + */ + abstract protected function getEnumClass(): string; + + /** + * Gets expected enum values and their constant names. + * + * @return array Array of CONSTANT_NAME => value. + */ + abstract protected function getExpectedValues(): array; + + /** + * Tests that the enum has expected values. + */ + public function testEnumHasExpectedValues(): void + { + $enumClass = $this->getEnumClass(); + $expectedValues = $this->getExpectedValues(); + + $actualValues = $enumClass::getValues(); + + $this->assertEquals($expectedValues, $actualValues); + } + + /** + * Tests that enum cases return correct instances. + */ + public function testEnumCasesReturnCorrectInstances(): void + { + $enumClass = $this->getEnumClass(); + $expectedValues = $this->getExpectedValues(); + + $cases = $enumClass::cases(); + + $this->assertCount(count($expectedValues), $cases); + + foreach ($cases as $case) { + $this->assertInstanceOf($enumClass, $case); + $this->assertContains($case->value, $expectedValues); + $this->assertArrayHasKey($case->name, $expectedValues); + $this->assertEquals($expectedValues[$case->name], $case->value); + } + } + + /** + * Tests that the from() method works correctly. + */ + public function testEnumFromMethodWorks(): void + { + $enumClass = $this->getEnumClass(); + $expectedValues = $this->getExpectedValues(); + + foreach ($expectedValues as $name => $value) { + $enum = $enumClass::from($value); + $this->assertInstanceOf($enumClass, $enum); + $this->assertEquals($value, $enum->value); + $this->assertEquals($name, $enum->name); + } + } + + /** + * Tests that the tryFrom() method works correctly. + */ + public function testEnumTryFromMethodWorks(): void + { + $enumClass = $this->getEnumClass(); + $expectedValues = $this->getExpectedValues(); + + foreach ($expectedValues as $value) { + $enum = $enumClass::tryFrom($value); + $this->assertInstanceOf($enumClass, $enum); + } + + // Test invalid value + $invalidEnum = $enumClass::tryFrom('definitely_not_a_valid_value_12345'); + $this->assertNull($invalidEnum); + } + + /** + * Tests enum singleton behavior. + */ + public function testEnumSingletonBehavior(): void + { + $enumClass = $this->getEnumClass(); + $expectedValues = $this->getExpectedValues(); + + if (empty($expectedValues)) { + $this->markTestSkipped('No enum values to test'); + } + + $firstValue = reset($expectedValues); + + $enum1 = $enumClass::from($firstValue); + $enum2 = $enumClass::from($firstValue); + + $this->assertSame($enum1, $enum2); + } + + /** + * Tests that enum properties are read-only. + */ + public function testEnumPropertiesAreReadOnly(): void + { + $enumClass = $this->getEnumClass(); + $expectedValues = $this->getExpectedValues(); + + if (empty($expectedValues)) { + $this->markTestSkipped('No enum values to test'); + } + + $firstValue = reset($expectedValues); + $enum = $enumClass::from($firstValue); + + $this->expectException(\BadMethodCallException::class); + $enum->value = 'modified'; + } +} diff --git a/tests/unit/Messages/Enums/MessagePartTypeEnumTest.php b/tests/unit/Messages/Enums/MessagePartTypeEnumTest.php new file mode 100644 index 0000000..937c6dc --- /dev/null +++ b/tests/unit/Messages/Enums/MessagePartTypeEnumTest.php @@ -0,0 +1,63 @@ + 'text', + 'INLINE_FILE' => 'inline_file', + 'REMOTE_FILE' => 'remote_file', + 'FUNCTION_CALL' => 'function_call', + 'FUNCTION_RESPONSE' => 'function_response', + ]; + } + + /** + * Tests the specific enum methods. + * + * @return void + */ + public function testSpecificEnumMethods(): void + { + $text = MessagePartTypeEnum::text(); + $this->assertTrue($text->isText()); + $this->assertFalse($text->isInlineFile()); + + $inlineFile = MessagePartTypeEnum::inlineFile(); + $this->assertTrue($inlineFile->isInlineFile()); + $this->assertFalse($inlineFile->isRemoteFile()); + + $functionCall = MessagePartTypeEnum::functionCall(); + $this->assertTrue($functionCall->isFunctionCall()); + $this->assertFalse($functionCall->isFunctionResponse()); + } +} diff --git a/tests/unit/Messages/Enums/MessageRoleEnumTest.php b/tests/unit/Messages/Enums/MessageRoleEnumTest.php new file mode 100644 index 0000000..9e35432 --- /dev/null +++ b/tests/unit/Messages/Enums/MessageRoleEnumTest.php @@ -0,0 +1,64 @@ + 'user', + 'MODEL' => 'model', + 'SYSTEM' => 'system', + ]; + } + + /** + * Tests the specific enum methods. + * + * @return void + */ + public function testSpecificEnumMethods(): void + { + $user = MessageRoleEnum::user(); + $this->assertTrue($user->isUser()); + $this->assertFalse($user->isModel()); + $this->assertFalse($user->isSystem()); + + $model = MessageRoleEnum::model(); + $this->assertFalse($model->isUser()); + $this->assertTrue($model->isModel()); + $this->assertFalse($model->isSystem()); + + $system = MessageRoleEnum::system(); + $this->assertFalse($system->isUser()); + $this->assertFalse($system->isModel()); + $this->assertTrue($system->isSystem()); + } +} diff --git a/tests/unit/Messages/Enums/ModalityEnumTest.php b/tests/unit/Messages/Enums/ModalityEnumTest.php new file mode 100644 index 0000000..cea4e5f --- /dev/null +++ b/tests/unit/Messages/Enums/ModalityEnumTest.php @@ -0,0 +1,63 @@ + 'text', + 'DOCUMENT' => 'document', + 'IMAGE' => 'image', + 'AUDIO' => 'audio', + 'VIDEO' => 'video', + ]; + } + + /** + * Tests the specific enum methods. + * + * @return void + */ + public function testSpecificEnumMethods(): void + { + $text = ModalityEnum::text(); + $this->assertTrue($text->isText()); + $this->assertFalse($text->isDocument()); + + $image = ModalityEnum::image(); + $this->assertTrue($image->isImage()); + $this->assertFalse($image->isAudio()); + + $video = ModalityEnum::video(); + $this->assertTrue($video->isVideo()); + $this->assertFalse($video->isText()); + } +} diff --git a/tests/unit/Operations/Enums/OperationStateEnumTest.php b/tests/unit/Operations/Enums/OperationStateEnumTest.php new file mode 100644 index 0000000..fd7f296 --- /dev/null +++ b/tests/unit/Operations/Enums/OperationStateEnumTest.php @@ -0,0 +1,63 @@ + 'starting', + 'PROCESSING' => 'processing', + 'SUCCEEDED' => 'succeeded', + 'FAILED' => 'failed', + 'CANCELED' => 'canceled', + ]; + } + + /** + * Tests the specific enum methods. + * + * @return void + */ + public function testSpecificEnumMethods(): void + { + $starting = OperationStateEnum::starting(); + $this->assertTrue($starting->isStarting()); + $this->assertFalse($starting->isProcessing()); + + $succeeded = OperationStateEnum::succeeded(); + $this->assertTrue($succeeded->isSucceeded()); + $this->assertFalse($succeeded->isFailed()); + + $failed = OperationStateEnum::failed(); + $this->assertTrue($failed->isFailed()); + $this->assertFalse($failed->isCanceled()); + } +} diff --git a/tests/unit/Providers/Enums/ProviderTypeEnumTest.php b/tests/unit/Providers/Enums/ProviderTypeEnumTest.php new file mode 100644 index 0000000..e658231 --- /dev/null +++ b/tests/unit/Providers/Enums/ProviderTypeEnumTest.php @@ -0,0 +1,64 @@ + 'cloud', + 'SERVER' => 'server', + 'CLIENT' => 'client', + ]; + } + + /** + * Tests the specific enum methods. + * + * @return void + */ + public function testSpecificEnumMethods(): void + { + $cloud = ProviderTypeEnum::cloud(); + $this->assertTrue($cloud->isCloud()); + $this->assertFalse($cloud->isServer()); + $this->assertFalse($cloud->isClient()); + + $server = ProviderTypeEnum::server(); + $this->assertFalse($server->isCloud()); + $this->assertTrue($server->isServer()); + $this->assertFalse($server->isClient()); + + $client = ProviderTypeEnum::client(); + $this->assertFalse($client->isCloud()); + $this->assertFalse($client->isServer()); + $this->assertTrue($client->isClient()); + } +} diff --git a/tests/unit/Providers/Enums/ToolTypeEnumTest.php b/tests/unit/Providers/Enums/ToolTypeEnumTest.php new file mode 100644 index 0000000..ddd1a19 --- /dev/null +++ b/tests/unit/Providers/Enums/ToolTypeEnumTest.php @@ -0,0 +1,56 @@ + 'function_declarations', + 'WEB_SEARCH' => 'web_search', + ]; + } + + /** + * Tests the specific enum methods. + * + * @return void + */ + public function testSpecificEnumMethods(): void + { + $functionDeclarations = ToolTypeEnum::functionDeclarations(); + $this->assertTrue($functionDeclarations->isFunctionDeclarations()); + $this->assertFalse($functionDeclarations->isWebSearch()); + + $webSearch = ToolTypeEnum::webSearch(); + $this->assertFalse($webSearch->isFunctionDeclarations()); + $this->assertTrue($webSearch->isWebSearch()); + } +} diff --git a/tests/unit/Providers/Models/Enums/CapabilityEnumTest.php b/tests/unit/Providers/Models/Enums/CapabilityEnumTest.php new file mode 100644 index 0000000..fa1e0b0 --- /dev/null +++ b/tests/unit/Providers/Models/Enums/CapabilityEnumTest.php @@ -0,0 +1,66 @@ + 'text_generation', + 'IMAGE_GENERATION' => 'image_generation', + 'TEXT_TO_SPEECH_CONVERSION' => 'text_to_speech_conversion', + 'SPEECH_GENERATION' => 'speech_generation', + 'MUSIC_GENERATION' => 'music_generation', + 'VIDEO_GENERATION' => 'video_generation', + 'EMBEDDING_GENERATION' => 'embedding_generation', + 'CHAT_HISTORY' => 'chat_history', + ]; + } + + /** + * Tests the specific enum methods. + * + * @return void + */ + public function testSpecificEnumMethods(): void + { + $textGen = CapabilityEnum::textGeneration(); + $this->assertTrue($textGen->isTextGeneration()); + $this->assertFalse($textGen->isImageGeneration()); + + $imageGen = CapabilityEnum::imageGeneration(); + $this->assertTrue($imageGen->isImageGeneration()); + $this->assertFalse($imageGen->isTextToSpeechConversion()); + + $chatHistory = CapabilityEnum::chatHistory(); + $this->assertTrue($chatHistory->isChatHistory()); + $this->assertFalse($chatHistory->isEmbeddingGeneration()); + } +} diff --git a/tests/unit/Providers/Models/Enums/OptionEnumTest.php b/tests/unit/Providers/Models/Enums/OptionEnumTest.php new file mode 100644 index 0000000..6869898 --- /dev/null +++ b/tests/unit/Providers/Models/Enums/OptionEnumTest.php @@ -0,0 +1,68 @@ + 'input_modalities', + 'OUTPUT_MODALITIES' => 'output_modalities', + 'SYSTEM_INSTRUCTION' => 'system_instruction', + 'CANDIDATE_COUNT' => 'candidate_count', + 'MAX_TOKENS' => 'max_tokens', + 'TEMPERATURE' => 'temperature', + 'TOP_K' => 'top_k', + 'TOP_P' => 'top_p', + 'OUTPUT_MIME_TYPE' => 'output_mime_type', + 'OUTPUT_SCHEMA' => 'output_schema', + ]; + } + + /** + * Tests the specific enum methods. + * + * @return void + */ + public function testSpecificEnumMethods(): void + { + $inputModalities = OptionEnum::inputModalities(); + $this->assertTrue($inputModalities->isInputModalities()); + $this->assertFalse($inputModalities->isOutputModalities()); + + $temperature = OptionEnum::temperature(); + $this->assertTrue($temperature->isTemperature()); + $this->assertFalse($temperature->isTopK()); + + $outputSchema = OptionEnum::outputSchema(); + $this->assertTrue($outputSchema->isOutputSchema()); + $this->assertFalse($outputSchema->isOutputMimeType()); + } +} diff --git a/tests/unit/Results/Enums/FinishReasonEnumTest.php b/tests/unit/Results/Enums/FinishReasonEnumTest.php new file mode 100644 index 0000000..4883203 --- /dev/null +++ b/tests/unit/Results/Enums/FinishReasonEnumTest.php @@ -0,0 +1,63 @@ + 'stop', + 'LENGTH' => 'length', + 'CONTENT_FILTER' => 'content_filter', + 'TOOL_CALLS' => 'tool_calls', + 'ERROR' => 'error', + ]; + } + + /** + * Tests the specific enum methods. + * + * @return void + */ + public function testSpecificEnumMethods(): void + { + $stop = FinishReasonEnum::stop(); + $this->assertTrue($stop->isStop()); + $this->assertFalse($stop->isLength()); + + $contentFilter = FinishReasonEnum::contentFilter(); + $this->assertTrue($contentFilter->isContentFilter()); + $this->assertFalse($contentFilter->isToolCalls()); + + $error = FinishReasonEnum::error(); + $this->assertTrue($error->isError()); + $this->assertFalse($error->isStop()); + } +}