diff --git a/src/Common/AbstractDataValueObject.php b/src/Common/AbstractDataValueObject.php new file mode 100644 index 00000000..db34ad53 --- /dev/null +++ b/src/Common/AbstractDataValueObject.php @@ -0,0 +1,131 @@ + + * @implements WithArrayTransformationInterface + */ +abstract class AbstractDataValueObject implements + WithArrayTransformationInterface, + WithJsonSchemaInterface, + JsonSerializable +{ + /** + * Validates that required keys exist in the array data. + * + * @since n.e.x.t + * + * @param TArrayShape $data The array data to validate. + * @param string[] $requiredKeys The keys that must be present. + * @throws InvalidArgumentException If any required key is missing. + */ + protected static function validateFromArrayData(array $data, array $requiredKeys): void + { + $missingKeys = []; + + foreach ($requiredKeys as $key) { + if (!array_key_exists($key, $data)) { + $missingKeys[] = $key; + } + } + + if (!empty($missingKeys)) { + throw new InvalidArgumentException( + sprintf( + '%s::fromArray() missing required keys: %s', + static::class, + implode(', ', $missingKeys) + ) + ); + } + } + + /** + * Converts the object to a JSON-serializable format. + * + * This method uses the toArray() method and then processes the result + * based on the JSON schema to ensure proper object representation for + * empty arrays. + * + * @since n.e.x.t + * + * @return mixed The JSON-serializable representation. + */ + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + $data = $this->toArray(); + $schema = static::getJsonSchema(); + + return $this->convertEmptyArraysToObjects($data, $schema); + } + + /** + * Recursively converts empty arrays to stdClass objects where the schema expects objects. + * + * @since n.e.x.t + * + * @param mixed $data The data to process. + * @param array $schema The JSON schema for the data. + * @return mixed The processed data. + */ + private function convertEmptyArraysToObjects($data, array $schema) + { + // If data is an empty array and schema expects object, convert to stdClass + if (is_array($data) && empty($data) && isset($schema['type']) && $schema['type'] === 'object') { + return new stdClass(); + } + + // If data is an array with content, recursively process nested structures + if (is_array($data)) { + // Handle object properties + if (isset($schema['properties']) && is_array($schema['properties'])) { + foreach ($data as $key => $value) { + if (isset($schema['properties'][$key]) && is_array($schema['properties'][$key])) { + $data[$key] = $this->convertEmptyArraysToObjects($value, $schema['properties'][$key]); + } + } + } + + // Handle array items + if (isset($schema['items']) && is_array($schema['items'])) { + foreach ($data as $index => $item) { + $data[$index] = $this->convertEmptyArraysToObjects($item, $schema['items']); + } + } + + // Handle oneOf schemas - just use the first one + if (isset($schema['oneOf']) && is_array($schema['oneOf'])) { + foreach ($schema['oneOf'] as $possibleSchema) { + if (is_array($possibleSchema)) { + return $this->convertEmptyArraysToObjects($data, $possibleSchema); + } + } + } + } + + return $data; + } +} diff --git a/src/Common/Contracts/WithArrayTransformationInterface.php b/src/Common/Contracts/WithArrayTransformationInterface.php new file mode 100644 index 00000000..257647f0 --- /dev/null +++ b/src/Common/Contracts/WithArrayTransformationInterface.php @@ -0,0 +1,34 @@ + + */ +interface WithArrayTransformationInterface +{ + /** + * Converts the object to an array representation. + * + * @since n.e.x.t + * + * @return TArrayShape The array representation. + */ + public function toArray(): array; + + /** + * Creates an instance from array data. + * + * @since n.e.x.t + * + * @param TArrayShape $array The array data. + * @return self The created instance. + */ + public static function fromArray(array $array): self; +} diff --git a/src/Files/DTO/File.php b/src/Files/DTO/File.php index cc84724f..14e3b64c 100644 --- a/src/Files/DTO/File.php +++ b/src/Files/DTO/File.php @@ -4,7 +4,9 @@ namespace WordPress\AiClient\Files\DTO; -use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; +use InvalidArgumentException; +use RuntimeException; +use WordPress\AiClient\Common\AbstractDataValueObject; use WordPress\AiClient\Files\Enums\FileTypeEnum; use WordPress\AiClient\Files\ValueObjects\MimeType; @@ -15,9 +17,22 @@ * and handles them appropriately. * * @since n.e.x.t + * + * @phpstan-type FileArrayShape array{ + * fileType: string, + * url?: string, + * mimeType: string, + * base64Data?: string + * } + * + * @extends AbstractDataValueObject */ -class File implements WithJsonSchemaInterface +class File extends AbstractDataValueObject { + public const KEY_FILE_TYPE = 'fileType'; + public const KEY_MIME_TYPE = 'mimeType'; + public const KEY_URL = 'url'; + public const KEY_BASE64_DATA = 'base64Data'; /** * @var MimeType The MIME type of the file. */ @@ -335,46 +350,94 @@ public static function getJsonSchema(): array 'oneOf' => [ [ 'properties' => [ - 'fileType' => [ + self::KEY_FILE_TYPE => [ 'type' => 'string', 'const' => FileTypeEnum::REMOTE, 'description' => 'The file type.', ], - 'mimeType' => [ + self::KEY_MIME_TYPE => [ 'type' => 'string', 'description' => 'The MIME type of the file.', 'pattern' => '^[a-zA-Z0-9][a-zA-Z0-9!#$&\\-\\^_+.]*\\/[a-zA-Z0-9]' . '[a-zA-Z0-9!#$&\\-\\^_+.]*$', ], - 'url' => [ + self::KEY_URL => [ 'type' => 'string', 'format' => 'uri', 'description' => 'The URL to the remote file.', ], ], - 'required' => ['fileType', 'mimeType', 'url'], + 'required' => [self::KEY_FILE_TYPE, self::KEY_MIME_TYPE, self::KEY_URL], ], [ 'properties' => [ - 'fileType' => [ + self::KEY_FILE_TYPE => [ 'type' => 'string', 'const' => FileTypeEnum::INLINE, 'description' => 'The file type.', ], - 'mimeType' => [ + self::KEY_MIME_TYPE => [ 'type' => 'string', 'description' => 'The MIME type of the file.', 'pattern' => '^[a-zA-Z0-9][a-zA-Z0-9!#$&\\-\\^_+.]*\\/[a-zA-Z0-9]' . '[a-zA-Z0-9!#$&\\-\\^_+.]*$', ], - 'base64Data' => [ + self::KEY_BASE64_DATA => [ 'type' => 'string', 'description' => 'The base64-encoded file data.', ], ], - 'required' => ['fileType', 'mimeType', 'base64Data'], + 'required' => [self::KEY_FILE_TYPE, self::KEY_MIME_TYPE, self::KEY_BASE64_DATA], ], ], ]; } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + * + * @return FileArrayShape + */ + public function toArray(): array + { + $data = [ + self::KEY_FILE_TYPE => $this->fileType->value, + self::KEY_MIME_TYPE => $this->getMimeType(), + ]; + + if ($this->url !== null) { + $data[self::KEY_URL] = $this->url; + } elseif (!$this->fileType->isRemote() && $this->base64Data !== null) { + $data[self::KEY_BASE64_DATA] = $this->base64Data; + } else { + throw new RuntimeException( + 'File requires either url or base64Data. This should not be a possible condition.' + ); + } + + return $data; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [self::KEY_FILE_TYPE]); + + // Check which properties are set to determine how to construct the File + $mimeType = $array[self::KEY_MIME_TYPE] ?? null; + + if (isset($array[self::KEY_URL])) { + return new self($array[self::KEY_URL], $mimeType); + } elseif (isset($array[self::KEY_BASE64_DATA])) { + return new self($array[self::KEY_BASE64_DATA], $mimeType); + } else { + throw new InvalidArgumentException('File requires either url or base64Data.'); + } + } } diff --git a/src/Messages/DTO/Message.php b/src/Messages/DTO/Message.php index cf73e30a..f38e0149 100644 --- a/src/Messages/DTO/Message.php +++ b/src/Messages/DTO/Message.php @@ -4,7 +4,8 @@ namespace WordPress\AiClient\Messages\DTO; -use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; +use InvalidArgumentException; +use WordPress\AiClient\Common\AbstractDataValueObject; use WordPress\AiClient\Messages\Enums\MessageRoleEnum; /** @@ -14,9 +15,20 @@ * containing a role and one or more parts with different content types. * * @since n.e.x.t + * + * @phpstan-import-type MessagePartArrayShape from MessagePart + * + * @phpstan-type MessageArrayShape array{ + * role: string, + * parts: array + * } + * + * @extends AbstractDataValueObject */ -class Message implements WithJsonSchemaInterface +class Message extends AbstractDataValueObject { + public const KEY_ROLE = 'role'; + public const KEY_PARTS = 'parts'; /** * @var MessageRoleEnum The role of the message sender. */ @@ -75,19 +87,64 @@ public static function getJsonSchema(): array return [ 'type' => 'object', 'properties' => [ - 'role' => [ + self::KEY_ROLE => [ 'type' => 'string', 'enum' => MessageRoleEnum::getValues(), 'description' => 'The role of the message sender.', ], - 'parts' => [ + self::KEY_PARTS => [ 'type' => 'array', 'items' => MessagePart::getJsonSchema(), 'minItems' => 1, 'description' => 'The parts that make up this message.', ], ], - 'required' => ['role', 'parts'], + 'required' => [self::KEY_ROLE, self::KEY_PARTS], ]; } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + * + * @return MessageArrayShape + */ + public function toArray(): array + { + return [ + self::KEY_ROLE => $this->role->value, + self::KEY_PARTS => array_map(function (MessagePart $part) { + return $part->toArray(); + }, $this->parts), + ]; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + * + * @return self The specific message class based on the role. + */ + final public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [self::KEY_ROLE, self::KEY_PARTS]); + + $role = MessageRoleEnum::from($array[self::KEY_ROLE]); + $partsData = $array[self::KEY_PARTS]; + $parts = array_map(function (array $partData) { + return MessagePart::fromArray($partData); + }, $partsData); + + // Determine which concrete class to instantiate based on role + if ($role->isUser()) { + return new UserMessage($parts); + } elseif ($role->isModel()) { + return new ModelMessage($parts); + } else { + // System is the only remaining option + return new SystemMessage($parts); + } + } } diff --git a/src/Messages/DTO/MessagePart.php b/src/Messages/DTO/MessagePart.php index ddaf3945..1a95032d 100644 --- a/src/Messages/DTO/MessagePart.php +++ b/src/Messages/DTO/MessagePart.php @@ -4,7 +4,9 @@ namespace WordPress\AiClient\Messages\DTO; -use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; +use InvalidArgumentException; +use RuntimeException; +use WordPress\AiClient\Common\AbstractDataValueObject; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Messages\Enums\MessagePartTypeEnum; use WordPress\AiClient\Tools\DTO\FunctionCall; @@ -17,9 +19,28 @@ * function calls, etc. This DTO encapsulates one such part. * * @since n.e.x.t + * + * @phpstan-import-type FileArrayShape from File + * @phpstan-import-type FunctionCallArrayShape from FunctionCall + * @phpstan-import-type FunctionResponseArrayShape from FunctionResponse + * + * @phpstan-type MessagePartArrayShape array{ + * type: string, + * text?: string, + * file?: FileArrayShape, + * functionCall?: FunctionCallArrayShape, + * functionResponse?: FunctionResponseArrayShape + * } + * + * @extends AbstractDataValueObject */ -class MessagePart implements WithJsonSchemaInterface +class MessagePart extends AbstractDataValueObject { + public const KEY_TYPE = 'type'; + public const KEY_TEXT = 'text'; + public const KEY_FILE = 'file'; + public const KEY_FUNCTION_CALL = 'functionCall'; + public const KEY_FUNCTION_RESPONSE = 'functionResponse'; /** * @var MessagePartTypeEnum The type of this message part. */ @@ -51,7 +72,7 @@ class MessagePart implements WithJsonSchemaInterface * @since n.e.x.t * * @param mixed $content The content of this message part. - * @throws \InvalidArgumentException If an unsupported content type is provided. + * @throws InvalidArgumentException If an unsupported content type is provided. */ public function __construct($content) { @@ -69,7 +90,7 @@ public function __construct($content) $this->functionResponse = $content; } else { $type = is_object($content) ? get_class($content) : gettype($content); - throw new \InvalidArgumentException( + throw new InvalidArgumentException( sprintf( 'Unsupported content type %s. Expected string, File, ' . 'FunctionCall, or FunctionResponse.', @@ -151,55 +172,107 @@ public static function getJsonSchema(): array [ 'type' => 'object', 'properties' => [ - 'type' => [ + self::KEY_TYPE => [ 'type' => 'string', 'const' => MessagePartTypeEnum::text()->value, ], - 'text' => [ + self::KEY_TEXT => [ 'type' => 'string', 'description' => 'Text content.', ], ], - 'required' => ['type', 'text'], + 'required' => [self::KEY_TYPE, self::KEY_TEXT], 'additionalProperties' => false, ], [ 'type' => 'object', 'properties' => [ - 'type' => [ + self::KEY_TYPE => [ 'type' => 'string', 'const' => MessagePartTypeEnum::file()->value, ], - 'file' => File::getJsonSchema(), + self::KEY_FILE => File::getJsonSchema(), ], - 'required' => ['type', 'file'], + 'required' => [self::KEY_TYPE, self::KEY_FILE], 'additionalProperties' => false, ], [ 'type' => 'object', 'properties' => [ - 'type' => [ + self::KEY_TYPE => [ 'type' => 'string', 'const' => MessagePartTypeEnum::functionCall()->value, ], - 'functionCall' => FunctionCall::getJsonSchema(), + self::KEY_FUNCTION_CALL => FunctionCall::getJsonSchema(), ], - 'required' => ['type', 'functionCall'], + 'required' => [self::KEY_TYPE, self::KEY_FUNCTION_CALL], 'additionalProperties' => false, ], [ 'type' => 'object', 'properties' => [ - 'type' => [ + self::KEY_TYPE => [ 'type' => 'string', 'const' => MessagePartTypeEnum::functionResponse()->value, ], - 'functionResponse' => FunctionResponse::getJsonSchema(), + self::KEY_FUNCTION_RESPONSE => FunctionResponse::getJsonSchema(), ], - 'required' => ['type', 'functionResponse'], + 'required' => [self::KEY_TYPE, self::KEY_FUNCTION_RESPONSE], 'additionalProperties' => false, ], ], ]; } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + * + * @return MessagePartArrayShape + */ + public function toArray(): array + { + $data = [self::KEY_TYPE => $this->type->value]; + + if ($this->text !== null) { + $data[self::KEY_TEXT] = $this->text; + } elseif ($this->file !== null) { + $data[self::KEY_FILE] = $this->file->toArray(); + } elseif ($this->functionCall !== null) { + $data[self::KEY_FUNCTION_CALL] = $this->functionCall->toArray(); + } elseif ($this->functionResponse !== null) { + $data[self::KEY_FUNCTION_RESPONSE] = $this->functionResponse->toArray(); + } else { + throw new RuntimeException( + 'MessagePart requires one of: text, file, functionCall, or functionResponse. ' + . 'This should not be a possible condition.' + ); + } + + return $data; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public static function fromArray(array $array): self + { + // Check which properties are set to determine how to construct the MessagePart + if (isset($array[self::KEY_TEXT])) { + return new self($array[self::KEY_TEXT]); + } elseif (isset($array[self::KEY_FILE])) { + return new self(File::fromArray($array[self::KEY_FILE])); + } elseif (isset($array[self::KEY_FUNCTION_CALL])) { + return new self(FunctionCall::fromArray($array[self::KEY_FUNCTION_CALL])); + } elseif (isset($array[self::KEY_FUNCTION_RESPONSE])) { + return new self(FunctionResponse::fromArray($array[self::KEY_FUNCTION_RESPONSE])); + } else { + throw new InvalidArgumentException( + 'MessagePart requires one of: text, file, functionCall, or functionResponse.' + ); + } + } } diff --git a/src/Messages/DTO/ModelMessage.php b/src/Messages/DTO/ModelMessage.php index cf67b79c..a9f4500f 100644 --- a/src/Messages/DTO/ModelMessage.php +++ b/src/Messages/DTO/ModelMessage.php @@ -5,6 +5,7 @@ namespace WordPress\AiClient\Messages\DTO; use WordPress\AiClient\Messages\Enums\MessageRoleEnum; +use WordPress\AiClient\Common\Traits\HasJsonSerialization; /** * Represents a message from the AI model. diff --git a/src/Messages/DTO/SystemMessage.php b/src/Messages/DTO/SystemMessage.php index e2adebf5..193999d4 100644 --- a/src/Messages/DTO/SystemMessage.php +++ b/src/Messages/DTO/SystemMessage.php @@ -5,6 +5,7 @@ namespace WordPress\AiClient\Messages\DTO; use WordPress\AiClient\Messages\Enums\MessageRoleEnum; +use WordPress\AiClient\Common\Traits\HasJsonSerialization; /** * Represents a system instruction message. diff --git a/src/Messages/DTO/UserMessage.php b/src/Messages/DTO/UserMessage.php index c0cb931f..42b97a65 100644 --- a/src/Messages/DTO/UserMessage.php +++ b/src/Messages/DTO/UserMessage.php @@ -5,6 +5,7 @@ namespace WordPress\AiClient\Messages\DTO; use WordPress\AiClient\Messages\Enums\MessageRoleEnum; +use WordPress\AiClient\Common\Traits\HasJsonSerialization; /** * Represents a message from a user. diff --git a/src/Operations/Contracts/OperationInterface.php b/src/Operations/Contracts/OperationInterface.php index d9bfa6b5..2594d0e6 100644 --- a/src/Operations/Contracts/OperationInterface.php +++ b/src/Operations/Contracts/OperationInterface.php @@ -4,7 +4,6 @@ namespace WordPress\AiClient\Operations\Contracts; -use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; use WordPress\AiClient\Operations\Enums\OperationStateEnum; /** @@ -15,7 +14,7 @@ * * @since n.e.x.t */ -interface OperationInterface extends WithJsonSchemaInterface +interface OperationInterface { /** * Gets the operation ID. diff --git a/src/Operations/DTO/GenerativeAiOperation.php b/src/Operations/DTO/GenerativeAiOperation.php index 9ab109b2..d6efb7b0 100644 --- a/src/Operations/DTO/GenerativeAiOperation.php +++ b/src/Operations/DTO/GenerativeAiOperation.php @@ -4,6 +4,8 @@ namespace WordPress\AiClient\Operations\DTO; +use InvalidArgumentException; +use WordPress\AiClient\Common\AbstractDataValueObject; use WordPress\AiClient\Operations\Contracts\OperationInterface; use WordPress\AiClient\Operations\Enums\OperationStateEnum; use WordPress\AiClient\Results\DTO\GenerativeAiResult; @@ -15,9 +17,18 @@ * immediately, providing access to the result once available. * * @since n.e.x.t + * + * @phpstan-import-type GenerativeAiResultArrayShape from GenerativeAiResult + * + * @phpstan-type GenerativeAiOperationArrayShape array{id: string, state: string, result?: GenerativeAiResultArrayShape} + * + * @extends AbstractDataValueObject */ -class GenerativeAiOperation implements OperationInterface +class GenerativeAiOperation extends AbstractDataValueObject implements OperationInterface { + public const KEY_ID = 'id'; + public const KEY_STATE = 'state'; + public const KEY_RESULT = 'result'; /** * @var string Unique identifier for this operation. */ @@ -94,28 +105,28 @@ public static function getJsonSchema(): array [ 'type' => 'object', 'properties' => [ - 'id' => [ + self::KEY_ID => [ 'type' => 'string', 'description' => 'Unique identifier for this operation.', ], - 'state' => [ + self::KEY_STATE => [ 'type' => 'string', 'const' => OperationStateEnum::succeeded()->value, ], - 'result' => GenerativeAiResult::getJsonSchema(), + self::KEY_RESULT => GenerativeAiResult::getJsonSchema(), ], - 'required' => ['id', 'state', 'result'], + 'required' => [self::KEY_ID, self::KEY_STATE, self::KEY_RESULT], 'additionalProperties' => false, ], // All other states - no result [ 'type' => 'object', 'properties' => [ - 'id' => [ + self::KEY_ID => [ 'type' => 'string', 'description' => 'Unique identifier for this operation.', ], - 'state' => [ + self::KEY_STATE => [ 'type' => 'string', 'enum' => [ OperationStateEnum::starting()->value, @@ -126,10 +137,55 @@ public static function getJsonSchema(): array 'description' => 'The current state of the operation.', ], ], - 'required' => ['id', 'state'], + 'required' => [self::KEY_ID, self::KEY_STATE], 'additionalProperties' => false, ], ], ]; } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + * + * @return GenerativeAiOperationArrayShape + */ + public function toArray(): array + { + $data = [ + self::KEY_ID => $this->id, + self::KEY_STATE => $this->state->value, + ]; + + if ($this->result !== null) { + $data[self::KEY_RESULT] = $this->result->toArray(); + } + + return $data; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [self::KEY_ID, self::KEY_STATE]); + + $state = OperationStateEnum::from($array[self::KEY_STATE]); + + if ($state->isSucceeded()) { + // If the operation has succeeded, it must have a result + static::validateFromArrayData($array, [self::KEY_RESULT]); + } + + $result = null; + if (isset($array[self::KEY_RESULT])) { + $result = GenerativeAiResult::fromArray($array[self::KEY_RESULT]); + } + + return new self($array[self::KEY_ID], $state, $result); + } } diff --git a/src/Results/Contracts/ResultInterface.php b/src/Results/Contracts/ResultInterface.php index 4da83eab..09d57812 100644 --- a/src/Results/Contracts/ResultInterface.php +++ b/src/Results/Contracts/ResultInterface.php @@ -4,7 +4,6 @@ namespace WordPress\AiClient\Results\Contracts; -use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; use WordPress\AiClient\Results\DTO\TokenUsage; /** @@ -15,7 +14,7 @@ * * @since n.e.x.t */ -interface ResultInterface extends WithJsonSchemaInterface +interface ResultInterface { /** * Gets the result ID. diff --git a/src/Results/DTO/Candidate.php b/src/Results/DTO/Candidate.php index bdb9360d..7f85920d 100644 --- a/src/Results/DTO/Candidate.php +++ b/src/Results/DTO/Candidate.php @@ -4,7 +4,7 @@ namespace WordPress\AiClient\Results\DTO; -use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; +use WordPress\AiClient\Common\AbstractDataValueObject; use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Results\Enums\FinishReasonEnum; @@ -15,9 +15,18 @@ * Each candidate contains a message and metadata about why generation stopped. * * @since n.e.x.t + * + * @phpstan-import-type MessageArrayShape from Message + * + * @phpstan-type CandidateArrayShape array{message: MessageArrayShape, finishReason: string, tokenCount: int} + * + * @extends AbstractDataValueObject */ -class Candidate implements WithJsonSchemaInterface +class Candidate extends AbstractDataValueObject { + public const KEY_MESSAGE = 'message'; + public const KEY_FINISH_REASON = 'finishReason'; + public const KEY_TOKEN_COUNT = 'tokenCount'; /** * @var Message The generated message. */ @@ -101,18 +110,52 @@ public static function getJsonSchema(): array return [ 'type' => 'object', 'properties' => [ - 'message' => Message::getJsonSchema(), - 'finishReason' => [ + self::KEY_MESSAGE => Message::getJsonSchema(), + self::KEY_FINISH_REASON => [ 'type' => 'string', 'enum' => FinishReasonEnum::getValues(), 'description' => 'The reason generation stopped.', ], - 'tokenCount' => [ + self::KEY_TOKEN_COUNT => [ 'type' => 'integer', 'description' => 'The number of tokens in this candidate.', ], ], - 'required' => ['message', 'finishReason', 'tokenCount'], + 'required' => [self::KEY_MESSAGE, self::KEY_FINISH_REASON, self::KEY_TOKEN_COUNT], + ]; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + * + * @return CandidateArrayShape + */ + public function toArray(): array + { + return [ + self::KEY_MESSAGE => $this->message->toArray(), + self::KEY_FINISH_REASON => $this->finishReason->value, + self::KEY_TOKEN_COUNT => $this->tokenCount, ]; } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [self::KEY_MESSAGE, self::KEY_FINISH_REASON, self::KEY_TOKEN_COUNT]); + + $messageData = $array[self::KEY_MESSAGE]; + + return new self( + Message::fromArray($messageData), + FinishReasonEnum::from($array[self::KEY_FINISH_REASON]), + $array[self::KEY_TOKEN_COUNT] + ); + } } diff --git a/src/Results/DTO/GenerativeAiResult.php b/src/Results/DTO/GenerativeAiResult.php index b9a19d5b..ed1c586d 100644 --- a/src/Results/DTO/GenerativeAiResult.php +++ b/src/Results/DTO/GenerativeAiResult.php @@ -4,9 +4,9 @@ namespace WordPress\AiClient\Results\DTO; +use WordPress\AiClient\Common\AbstractDataValueObject; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Messages\DTO\Message; -use WordPress\AiClient\Messages\Enums\MessagePartTypeEnum; use WordPress\AiClient\Results\Contracts\ResultInterface; /** @@ -16,9 +16,25 @@ * and metadata from the AI provider. * * @since n.e.x.t + * + * @phpstan-import-type CandidateArrayShape from Candidate + * @phpstan-import-type TokenUsageArrayShape from TokenUsage + * + * @phpstan-type GenerativeAiResultArrayShape array{ + * id: string, + * candidates: array, + * tokenUsage: TokenUsageArrayShape, + * providerMetadata?: array + * } + * + * @extends AbstractDataValueObject */ -class GenerativeAiResult implements ResultInterface +class GenerativeAiResult extends AbstractDataValueObject implements ResultInterface { + public const KEY_ID = 'id'; + public const KEY_CANDIDATES = 'candidates'; + public const KEY_TOKEN_USAGE = 'tokenUsage'; + public const KEY_PROVIDER_METADATA = 'providerMetadata'; /** * @var string Unique identifier for this result. */ @@ -358,24 +374,63 @@ public static function getJsonSchema(): array return [ 'type' => 'object', 'properties' => [ - 'id' => [ + self::KEY_ID => [ 'type' => 'string', 'description' => 'Unique identifier for this result.', ], - 'candidates' => [ + self::KEY_CANDIDATES => [ 'type' => 'array', 'items' => Candidate::getJsonSchema(), 'minItems' => 1, 'description' => 'The generated candidates.', ], - 'tokenUsage' => TokenUsage::getJsonSchema(), - 'providerMetadata' => [ + self::KEY_TOKEN_USAGE => TokenUsage::getJsonSchema(), + self::KEY_PROVIDER_METADATA => [ 'type' => 'object', 'additionalProperties' => true, 'description' => 'Provider-specific metadata.', ], ], - 'required' => ['id', 'candidates', 'tokenUsage'], + 'required' => [self::KEY_ID, self::KEY_CANDIDATES, self::KEY_TOKEN_USAGE], + ]; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + * + * @return GenerativeAiResultArrayShape + */ + public function toArray(): array + { + return [ + self::KEY_ID => $this->id, + self::KEY_CANDIDATES => array_map(fn(Candidate $candidate) => $candidate->toArray(), $this->candidates), + self::KEY_TOKEN_USAGE => $this->tokenUsage->toArray(), + self::KEY_PROVIDER_METADATA => $this->providerMetadata, ]; } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [self::KEY_ID, self::KEY_CANDIDATES, self::KEY_TOKEN_USAGE]); + + $candidates = array_map( + fn(array $candidateData) => Candidate::fromArray($candidateData), + $array[self::KEY_CANDIDATES] + ); + + return new self( + $array[self::KEY_ID], + $candidates, + TokenUsage::fromArray($array[self::KEY_TOKEN_USAGE]), + $array[self::KEY_PROVIDER_METADATA] ?? [] + ); + } } diff --git a/src/Results/DTO/TokenUsage.php b/src/Results/DTO/TokenUsage.php index da7d3714..6e30694d 100644 --- a/src/Results/DTO/TokenUsage.php +++ b/src/Results/DTO/TokenUsage.php @@ -4,7 +4,7 @@ namespace WordPress\AiClient\Results\DTO; -use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; +use WordPress\AiClient\Common\AbstractDataValueObject; /** * Represents token usage statistics for an AI operation. @@ -13,9 +13,20 @@ * which is important for monitoring usage and costs. * * @since n.e.x.t + * + * @phpstan-type TokenUsageArrayShape array{ + * promptTokens: int, + * completionTokens: int, + * totalTokens: int + * } + * + * @extends AbstractDataValueObject */ -class TokenUsage implements WithJsonSchemaInterface +class TokenUsage extends AbstractDataValueObject { + public const KEY_PROMPT_TOKENS = 'promptTokens'; + public const KEY_COMPLETION_TOKENS = 'completionTokens'; + public const KEY_TOTAL_TOKENS = 'totalTokens'; /** * @var int Number of tokens in the prompt. */ @@ -93,20 +104,56 @@ public static function getJsonSchema(): array return [ 'type' => 'object', 'properties' => [ - 'promptTokens' => [ + self::KEY_PROMPT_TOKENS => [ 'type' => 'integer', 'description' => 'Number of tokens in the prompt.', ], - 'completionTokens' => [ + self::KEY_COMPLETION_TOKENS => [ 'type' => 'integer', 'description' => 'Number of tokens in the completion.', ], - 'totalTokens' => [ + self::KEY_TOTAL_TOKENS => [ 'type' => 'integer', 'description' => 'Total number of tokens used.', ], ], - 'required' => ['promptTokens', 'completionTokens', 'totalTokens'], + 'required' => [self::KEY_PROMPT_TOKENS, self::KEY_COMPLETION_TOKENS, self::KEY_TOTAL_TOKENS], + ]; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + * + * @return TokenUsageArrayShape + */ + public function toArray(): array + { + return [ + self::KEY_PROMPT_TOKENS => $this->promptTokens, + self::KEY_COMPLETION_TOKENS => $this->completionTokens, + self::KEY_TOTAL_TOKENS => $this->totalTokens, ]; } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [ + self::KEY_PROMPT_TOKENS, + self::KEY_COMPLETION_TOKENS, + self::KEY_TOTAL_TOKENS + ]); + + return new self( + $array[self::KEY_PROMPT_TOKENS], + $array[self::KEY_COMPLETION_TOKENS], + $array[self::KEY_TOTAL_TOKENS] + ); + } } diff --git a/src/Tools/DTO/FunctionCall.php b/src/Tools/DTO/FunctionCall.php index bcbcfd3b..97c1f4d4 100644 --- a/src/Tools/DTO/FunctionCall.php +++ b/src/Tools/DTO/FunctionCall.php @@ -4,7 +4,7 @@ namespace WordPress\AiClient\Tools\DTO; -use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; +use WordPress\AiClient\Common\AbstractDataValueObject; /** * Represents a function call request from an AI model. @@ -13,9 +13,16 @@ * wants to invoke, including the function name and its arguments. * * @since n.e.x.t + * + * @phpstan-type FunctionCallArrayShape array{id?: string, name?: string, args?: array} + * + * @extends AbstractDataValueObject */ -class FunctionCall implements WithJsonSchemaInterface +class FunctionCall extends AbstractDataValueObject { + public const KEY_ID = 'id'; + public const KEY_NAME = 'name'; + public const KEY_ARGS = 'args'; /** * @var string|null Unique identifier for this function call. */ @@ -98,15 +105,15 @@ public static function getJsonSchema(): array return [ 'type' => 'object', 'properties' => [ - 'id' => [ + self::KEY_ID => [ 'type' => 'string', 'description' => 'Unique identifier for this function call.', ], - 'name' => [ + self::KEY_NAME => [ 'type' => 'string', 'description' => 'The name of the function to call.', ], - 'args' => [ + self::KEY_ARGS => [ 'type' => 'object', 'description' => 'The arguments to pass to the function.', 'additionalProperties' => true, @@ -114,15 +121,52 @@ public static function getJsonSchema(): array ], 'oneOf' => [ [ - 'required' => ['id'], - ], - [ - 'required' => ['name'], + 'required' => [self::KEY_ID], ], [ - 'required' => ['id', 'name'], + 'required' => [self::KEY_NAME], ], ], ]; } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + * + * @return FunctionCallArrayShape + */ + public function toArray(): array + { + $data = []; + + if ($this->id !== null) { + $data[self::KEY_ID] = $this->id; + } + + if ($this->name !== null) { + $data[self::KEY_NAME] = $this->name; + } + + if (!empty($this->args)) { + $data[self::KEY_ARGS] = $this->args; + } + + return $data; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public static function fromArray(array $array): self + { + return new self( + $array[self::KEY_ID] ?? null, + $array[self::KEY_NAME] ?? null, + $array[self::KEY_ARGS] ?? [] + ); + } } diff --git a/src/Tools/DTO/FunctionDeclaration.php b/src/Tools/DTO/FunctionDeclaration.php index 435ca021..8b14d8e4 100644 --- a/src/Tools/DTO/FunctionDeclaration.php +++ b/src/Tools/DTO/FunctionDeclaration.php @@ -4,7 +4,8 @@ namespace WordPress\AiClient\Tools\DTO; -use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; +use InvalidArgumentException; +use WordPress\AiClient\Common\AbstractDataValueObject; /** * Represents a function declaration for AI models. @@ -13,9 +14,16 @@ * including its name, description, and parameter schema. * * @since n.e.x.t + * + * @phpstan-type FunctionDeclarationArrayShape array{name: string, description: string, parameters?: mixed} + * + * @extends AbstractDataValueObject */ -class FunctionDeclaration implements WithJsonSchemaInterface +class FunctionDeclaration extends AbstractDataValueObject { + public const KEY_NAME = 'name'; + public const KEY_DESCRIPTION = 'description'; + public const KEY_PARAMETERS = 'parameters'; /** * @var string The name of the function. */ @@ -38,7 +46,7 @@ class FunctionDeclaration implements WithJsonSchemaInterface * * @param string $name The name of the function. * @param string $description A description of what the function does. - * @param mixed|null $parameters The JSON schema for the function parameters. + * @param mixed $parameters The JSON schema for the function parameters. */ public function __construct(string $name, string $description, $parameters = null) { @@ -93,20 +101,57 @@ public static function getJsonSchema(): array return [ 'type' => 'object', 'properties' => [ - 'name' => [ + self::KEY_NAME => [ 'type' => 'string', 'description' => 'The name of the function.', ], - 'description' => [ + self::KEY_DESCRIPTION => [ 'type' => 'string', 'description' => 'A description of what the function does.', ], - 'parameters' => [ + self::KEY_PARAMETERS => [ 'type' => ['string', 'number', 'boolean', 'object', 'array', 'null'], 'description' => 'The JSON schema for the function parameters.', ], ], - 'required' => ['name', 'description'], + 'required' => [self::KEY_NAME, self::KEY_DESCRIPTION], + ]; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + * + * @return FunctionDeclarationArrayShape + */ + public function toArray(): array + { + $data = [ + self::KEY_NAME => $this->name, + self::KEY_DESCRIPTION => $this->description, ]; + + if ($this->parameters !== null) { + $data[self::KEY_PARAMETERS] = $this->parameters; + } + + return $data; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [self::KEY_NAME, self::KEY_DESCRIPTION]); + + return new self( + $array[self::KEY_NAME], + $array[self::KEY_DESCRIPTION], + $array[self::KEY_PARAMETERS] ?? null + ); } } diff --git a/src/Tools/DTO/FunctionResponse.php b/src/Tools/DTO/FunctionResponse.php index 42a1e617..4f4d4aeb 100644 --- a/src/Tools/DTO/FunctionResponse.php +++ b/src/Tools/DTO/FunctionResponse.php @@ -4,7 +4,7 @@ namespace WordPress\AiClient\Tools\DTO; -use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; +use WordPress\AiClient\Common\AbstractDataValueObject; /** * Represents a response to a function call. @@ -13,9 +13,16 @@ * requested by the AI model through a FunctionCall. * * @since n.e.x.t + * + * @phpstan-type FunctionResponseArrayShape array{id: string, name: string, response: mixed} + * + * @extends AbstractDataValueObject */ -class FunctionResponse implements WithJsonSchemaInterface +class FunctionResponse extends AbstractDataValueObject { + public const KEY_ID = 'id'; + public const KEY_NAME = 'name'; + public const KEY_RESPONSE = 'response'; /** * @var string The ID of the function call this is responding to. */ @@ -93,20 +100,64 @@ public static function getJsonSchema(): array return [ 'type' => 'object', 'properties' => [ - 'id' => [ + self::KEY_ID => [ 'type' => 'string', 'description' => 'The ID of the function call this is responding to.', ], - 'name' => [ + self::KEY_NAME => [ 'type' => 'string', 'description' => 'The name of the function that was called.', ], - 'response' => [ + self::KEY_RESPONSE => [ 'type' => ['string', 'number', 'boolean', 'object', 'array', 'null'], 'description' => 'The response data from the function.', ], ], - 'required' => ['id', 'name', 'response'], + 'oneOf' => [ + [ + 'required' => [self::KEY_RESPONSE, self::KEY_ID], + ], + [ + 'required' => [self::KEY_RESPONSE, self::KEY_NAME], + ], + ], + ]; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + * + * @return FunctionResponseArrayShape + */ + public function toArray(): array + { + return [ + self::KEY_ID => $this->id, + self::KEY_NAME => $this->name, + self::KEY_RESPONSE => $this->response, ]; } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [self::KEY_RESPONSE]); + + // Validate that at least one of id or name is provided + if (!array_key_exists(self::KEY_ID, $array) && !array_key_exists(self::KEY_NAME, $array)) { + throw new \InvalidArgumentException('At least one of id or name must be provided.'); + } + + return new self( + $array[self::KEY_ID] ?? null, + $array[self::KEY_NAME] ?? null, + $array[self::KEY_RESPONSE] + ); + } } diff --git a/src/Tools/DTO/Tool.php b/src/Tools/DTO/Tool.php index ccc2d108..b566c9e7 100644 --- a/src/Tools/DTO/Tool.php +++ b/src/Tools/DTO/Tool.php @@ -4,7 +4,8 @@ namespace WordPress\AiClient\Tools\DTO; -use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; +use InvalidArgumentException; +use WordPress\AiClient\Common\AbstractDataValueObject; use WordPress\AiClient\Providers\Enums\ToolTypeEnum; /** @@ -14,9 +15,23 @@ * such as calling functions or performing web searches. * * @since n.e.x.t + * + * @phpstan-import-type FunctionDeclarationArrayShape from FunctionDeclaration + * @phpstan-import-type WebSearchArrayShape from WebSearch + * + * @phpstan-type ToolArrayShape array{ + * type: string, + * functionDeclarations?: array, + * webSearch?: WebSearchArrayShape + * } + * + * @extends AbstractDataValueObject */ -class Tool implements WithJsonSchemaInterface +class Tool extends AbstractDataValueObject { + public const KEY_TYPE = 'type'; + public const KEY_FUNCTION_DECLARATIONS = 'functionDeclarations'; + public const KEY_WEB_SEARCH = 'webSearch'; /** * @var ToolTypeEnum The type of tool. */ @@ -55,7 +70,6 @@ public function __construct($content) } } - /** * Gets the tool type. * @@ -104,32 +118,76 @@ public static function getJsonSchema(): array [ 'type' => 'object', 'properties' => [ - 'type' => [ + self::KEY_TYPE => [ 'type' => 'string', 'const' => ToolTypeEnum::functionDeclarations()->value, 'description' => 'The type of tool.', ], - 'functionDeclarations' => [ + self::KEY_FUNCTION_DECLARATIONS => [ 'type' => 'array', 'items' => FunctionDeclaration::getJsonSchema(), 'description' => 'Function declarations.', ], ], - 'required' => ['type', 'functionDeclarations'], + 'required' => [self::KEY_TYPE, self::KEY_FUNCTION_DECLARATIONS], ], [ 'type' => 'object', 'properties' => [ - 'type' => [ + self::KEY_TYPE => [ 'type' => 'string', 'const' => ToolTypeEnum::webSearch()->value, 'description' => 'The type of tool.', ], - 'webSearch' => WebSearch::getJsonSchema(), + self::KEY_WEB_SEARCH => WebSearch::getJsonSchema(), ], - 'required' => ['type', 'webSearch'], + 'required' => [self::KEY_TYPE, self::KEY_WEB_SEARCH], ], ], ]; } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + * + * @return ToolArrayShape + */ + public function toArray(): array + { + $data = [self::KEY_TYPE => $this->type->value]; + + if ($this->type->isFunctionDeclarations() && $this->functionDeclarations !== null) { + $data[self::KEY_FUNCTION_DECLARATIONS] = array_map(function (FunctionDeclaration $declaration) { + return $declaration->toArray(); + }, $this->functionDeclarations); + } elseif ($this->type->isWebSearch() && $this->webSearch !== null) { + $data[self::KEY_WEB_SEARCH] = $this->webSearch->toArray(); + } + + return $data; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public static function fromArray(array $array): self + { + // Check which properties are set to determine how to construct the Tool + if (isset($array[self::KEY_FUNCTION_DECLARATIONS])) { + $declarations = array_map(function (array $declarationData) { + return FunctionDeclaration::fromArray($declarationData); + }, $array[self::KEY_FUNCTION_DECLARATIONS]); + return new self($declarations); + } elseif (isset($array[self::KEY_WEB_SEARCH])) { + return new self(WebSearch::fromArray($array[self::KEY_WEB_SEARCH])); + } else { + throw new InvalidArgumentException( + 'Tool requires either functionDeclarations or webSearch.' + ); + } + } } diff --git a/src/Tools/DTO/WebSearch.php b/src/Tools/DTO/WebSearch.php index ecc1526f..fe01c7d7 100644 --- a/src/Tools/DTO/WebSearch.php +++ b/src/Tools/DTO/WebSearch.php @@ -4,7 +4,7 @@ namespace WordPress\AiClient\Tools\DTO; -use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; +use WordPress\AiClient\Common\AbstractDataValueObject; /** * Represents web search configuration for AI models. @@ -13,9 +13,15 @@ * including allowed and disallowed domains. * * @since n.e.x.t + * + * @phpstan-type WebSearchArrayShape array{allowedDomains?: string[], disallowedDomains?: string[]} + * + * @extends AbstractDataValueObject */ -class WebSearch implements WithJsonSchemaInterface +class WebSearch extends AbstractDataValueObject { + public const KEY_ALLOWED_DOMAINS = 'allowedDomains'; + public const KEY_DISALLOWED_DOMAINS = 'disallowedDomains'; /** * @var string[] List of domains that are allowed for web search. */ @@ -74,14 +80,14 @@ public static function getJsonSchema(): array return [ 'type' => 'object', 'properties' => [ - 'allowedDomains' => [ + self::KEY_ALLOWED_DOMAINS => [ 'type' => 'array', 'items' => [ 'type' => 'string', ], 'description' => 'List of domains that are allowed for web search.', ], - 'disallowedDomains' => [ + self::KEY_DISALLOWED_DOMAINS => [ 'type' => 'array', 'items' => [ 'type' => 'string', @@ -92,4 +98,32 @@ public static function getJsonSchema(): array 'required' => [], ]; } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + * + * @return WebSearchArrayShape + */ + public function toArray(): array + { + return [ + self::KEY_ALLOWED_DOMAINS => $this->allowedDomains, + self::KEY_DISALLOWED_DOMAINS => $this->disallowedDomains, + ]; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public static function fromArray(array $array): self + { + return new self( + $array[self::KEY_ALLOWED_DOMAINS] ?? [], + $array[self::KEY_DISALLOWED_DOMAINS] ?? [] + ); + } } diff --git a/tests/traits/ArrayTransformationTestTrait.php b/tests/traits/ArrayTransformationTestTrait.php new file mode 100644 index 00000000..f32381f1 --- /dev/null +++ b/tests/traits/ArrayTransformationTestTrait.php @@ -0,0 +1,88 @@ +assertInstanceOf( + \WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface::class, + $object, + 'Object should implement WithArrayTransformationInterface interface' + ); + } + + /** + * Asserts that toArray returns a valid array. + * + * @param object $object The object to test. + * @return array The serialized data. + */ + protected function assertToArrayReturnsArray($object): array + { + $array = $object->toArray(); + $this->assertIsArray($array, 'toArray() should return an array'); + return $array; + } + + /** + * Asserts round-trip array transformation works correctly. + * + * @param object $original The original object. + * @param callable $assertCallback Callback to assert equality between original and restored. + * @return void + */ + protected function assertArrayRoundTrip($original, callable $assertCallback): void + { + $array = $original->toArray(); + $className = get_class($original); + $restored = $className::fromArray($array); + + $this->assertInstanceOf($className, $restored, 'fromArray() should return instance of ' . $className); + $assertCallback($original, $restored); + } + + /** + * Asserts that specific keys exist in transformed array. + * + * @param array $array The transformed array. + * @param array $expectedKeys The keys that should exist. + * @return void + */ + protected function assertArrayHasKeys(array $array, array $expectedKeys): void + { + foreach ($expectedKeys as $key) { + $this->assertArrayHasKey($key, $array, "Array should contain key: {$key}"); + } + } + + /** + * Asserts that specific keys do not exist in transformed array. + * + * @param array $array The transformed array. + * @param array $unexpectedKeys The keys that should not exist. + * @return void + */ + protected function assertArrayNotHasKeys(array $array, array $unexpectedKeys): void + { + foreach ($unexpectedKeys as $key) { + $this->assertArrayNotHasKey($key, $array, "Array should not contain key: {$key}"); + } + } +} \ No newline at end of file diff --git a/tests/unit/EnumTestTrait.php b/tests/traits/EnumTestTrait.php similarity index 98% rename from tests/unit/EnumTestTrait.php rename to tests/traits/EnumTestTrait.php index b73c16cb..d84703ee 100644 --- a/tests/unit/EnumTestTrait.php +++ b/tests/traits/EnumTestTrait.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace WordPress\AiClient\Tests\unit; +namespace WordPress\AiClient\Tests\traits; use WordPress\AiClient\Common\AbstractEnum; diff --git a/tests/unit/Common/AbstractDataValueObjectTest.php b/tests/unit/Common/AbstractDataValueObjectTest.php new file mode 100644 index 00000000..4c9e28b7 --- /dev/null +++ b/tests/unit/Common/AbstractDataValueObjectTest.php @@ -0,0 +1,556 @@ + [], + 'nonEmptyObject' => ['key' => 'value'], + 'emptyArray' => [], + 'nonEmptyArray' => [1, 2, 3], + ]; + } + + public static function fromArray(array $array): self + { + return new static(); + } + + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'emptyObject' => [ + 'type' => 'object', + 'properties' => [] + ], + 'nonEmptyObject' => [ + 'type' => 'object', + 'properties' => [ + 'key' => ['type' => 'string'] + ] + ], + 'emptyArray' => [ + 'type' => 'array', + 'items' => ['type' => 'integer'] + ], + 'nonEmptyArray' => [ + 'type' => 'array', + 'items' => ['type' => 'integer'] + ], + ], + ]; + } + }; + + $result = $testObject->jsonSerialize(); + + // Verify result is an array + $this->assertIsArray($result); + + // Empty array marked as object in schema should be stdClass + $this->assertInstanceOf(stdClass::class, $result['emptyObject']); + + // Non-empty object should remain array + $this->assertIsArray($result['nonEmptyObject']); + $this->assertEquals(['key' => 'value'], $result['nonEmptyObject']); + + // Empty array marked as array in schema should remain array + $this->assertIsArray($result['emptyArray']); + $this->assertEmpty($result['emptyArray']); + + // Non-empty array should remain array + $this->assertIsArray($result['nonEmptyArray']); + $this->assertEquals([1, 2, 3], $result['nonEmptyArray']); + + // Verify JSON encoding produces correct output + $json = json_encode($result); + $this->assertIsString($json); + $decoded = json_decode($json, true); + + // In JSON, empty object should be {} not [] + $this->assertStringContainsString('"emptyObject":{}', $json); + $this->assertStringContainsString('"emptyArray":[]', $json); + } + + /** + * Tests nested object conversion. + * + * @return void + */ + public function testNestedObjectConversion(): void + { + $testObject = new class extends AbstractDataValueObject { + public function toArray(): array + { + return [ + 'nested' => [ + 'emptyChild' => [], + 'nonEmptyChild' => ['value' => 'test'], + ], + ]; + } + + public static function fromArray(array $array): self + { + return new static(); + } + + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'nested' => [ + 'type' => 'object', + 'properties' => [ + 'emptyChild' => [ + 'type' => 'object', + 'properties' => [] + ], + 'nonEmptyChild' => [ + 'type' => 'object', + 'properties' => [ + 'value' => ['type' => 'string'] + ] + ], + ], + ], + ], + ]; + } + }; + + $result = $testObject->jsonSerialize(); + + // Verify result is an array + $this->assertIsArray($result); + $this->assertIsArray($result['nested']); + $this->assertInstanceOf(stdClass::class, $result['nested']['emptyChild']); + $this->assertIsArray($result['nested']['nonEmptyChild']); + + $json = json_encode($result); + $this->assertIsString($json); + $this->assertStringContainsString('"emptyChild":{}', $json); + } + + /** + * Tests handling of oneOf schemas uses first schema without validation. + * + * @return void + */ + public function testOneOfSchemaHandling(): void + { + $testObject = new class extends AbstractDataValueObject { + public function toArray(): array + { + return [ + 'dynamicField' => [ + 'type' => 'objectType', + 'data' => [], + ], + ]; + } + + public static function fromArray(array $array): self + { + return new static(); + } + + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'dynamicField' => [ + 'oneOf' => [ + [ + 'type' => 'object', + 'properties' => [ + 'type' => [ + 'type' => 'string', + 'const' => 'objectType' + ], + 'data' => [ + 'type' => 'object', + 'properties' => [] + ], + ], + 'required' => ['type', 'data'], + ], + [ + 'type' => 'object', + 'properties' => [ + 'type' => [ + 'type' => 'string', + 'const' => 'arrayType' + ], + 'data' => [ + 'type' => 'array', + 'items' => ['type' => 'string'] + ], + ], + 'required' => ['type', 'data'], + ], + ], + ], + ], + ]; + } + }; + + $result = $testObject->jsonSerialize(); + + // Verify result is an array + $this->assertIsArray($result); + + // The implementation uses the first oneOf schema without validation + // Since the first schema has 'data' as type 'object', empty array is converted + $this->assertIsArray($result['dynamicField']); + $this->assertInstanceOf(stdClass::class, $result['dynamicField']['data']); + + $json = json_encode($result); + $this->assertIsString($json); + $this->assertStringContainsString('"data":{}', $json); + } + + /** + * Tests that arrays of objects are processed recursively. + * + * @return void + */ + public function testArrayOfObjectsProcessing(): void + { + $testObject = new class extends AbstractDataValueObject { + public function toArray(): array + { + return [ + 'items' => [ + ['name' => 'Item 1', 'metadata' => []], + ['name' => 'Item 2', 'metadata' => ['key' => 'value']], + ['name' => 'Item 3', 'metadata' => []], + ], + ]; + } + + public static function fromArray(array $array): self + { + return new static(); + } + + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'items' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string'], + 'metadata' => [ + 'type' => 'object', + 'properties' => [] + ], + ], + ], + ], + ], + ]; + } + }; + + $result = $testObject->jsonSerialize(); + + // Verify result is an array + $this->assertIsArray($result); + + // Verify array structure is preserved + $this->assertIsArray($result['items']); + $this->assertCount(3, $result['items']); + + // Each item should have empty metadata converted to stdClass + $items = $result['items']; + $this->assertIsArray($items[0]); + $this->assertInstanceOf(stdClass::class, $items[0]['metadata']); + $this->assertIsArray($items[1]); + $this->assertIsArray($items[1]['metadata']); // Non-empty remains array + $this->assertIsArray($items[2]); + $this->assertInstanceOf(stdClass::class, $items[2]['metadata']); + + $json = json_encode($result); + $this->assertIsString($json); + $this->assertStringContainsString('"metadata":{}', $json); + $this->assertStringContainsString('"metadata":{"key":"value"}', $json); + } + + /** + * Tests deeply nested structure conversion. + * + * @return void + */ + public function testDeeplyNestedStructures(): void + { + $testObject = new class extends AbstractDataValueObject { + public function toArray(): array + { + return [ + 'level1' => [ + 'level2' => [ + 'level3' => [ + 'emptyObject' => [], + 'emptyArray' => [], + ], + ], + ], + ]; + } + + public static function fromArray(array $array): self + { + return new static(); + } + + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'level1' => [ + 'type' => 'object', + 'properties' => [ + 'level2' => [ + 'type' => 'object', + 'properties' => [ + 'level3' => [ + 'type' => 'object', + 'properties' => [ + 'emptyObject' => [ + 'type' => 'object', + 'properties' => [] + ], + 'emptyArray' => [ + 'type' => 'array', + 'items' => ['type' => 'string'] + ], + ], + ], + ], + ], + ], + ], + ], + ]; + } + }; + + $result = $testObject->jsonSerialize(); + + // Verify result is an array + $this->assertIsArray($result); + + // Verify deep nesting is preserved + $this->assertIsArray($result['level1']); + $this->assertIsArray($result['level1']['level2']); + $this->assertIsArray($result['level1']['level2']['level3']); + + // Verify conversions at deepest level + $this->assertInstanceOf(stdClass::class, $result['level1']['level2']['level3']['emptyObject']); + $this->assertIsArray($result['level1']['level2']['level3']['emptyArray']); + $this->assertEmpty($result['level1']['level2']['level3']['emptyArray']); + } + + /** + * Tests that non-array data types pass through unchanged. + * + * @return void + */ + public function testNonArrayDataPassesThrough(): void + { + $testObject = new class extends AbstractDataValueObject { + public function toArray(): array + { + return [ + 'string' => 'test', + 'number' => 42, + 'float' => 3.14, + 'boolean' => true, + 'null' => null, + 'mixedObject' => [ + 'value' => 'test', + 'emptyData' => [], + ], + ]; + } + + public static function fromArray(array $array): self + { + return new static(); + } + + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'string' => ['type' => 'string'], + 'number' => ['type' => 'integer'], + 'float' => ['type' => 'number'], + 'boolean' => ['type' => 'boolean'], + 'null' => ['type' => 'null'], + 'mixedObject' => [ + 'type' => 'object', + 'properties' => [ + 'value' => ['type' => 'string'], + 'emptyData' => [ + 'type' => 'object', + 'properties' => [] + ], + ], + ], + ], + ]; + } + }; + + $result = $testObject->jsonSerialize(); + + // Verify result is an array + $this->assertIsArray($result); + + // Non-array values should pass through unchanged + $this->assertSame('test', $result['string']); + $this->assertSame(42, $result['number']); + $this->assertSame(3.14, $result['float']); + $this->assertSame(true, $result['boolean']); + $this->assertNull($result['null']); + + // Mixed object should have empty array converted + $this->assertIsArray($result['mixedObject']); + $this->assertSame('test', $result['mixedObject']['value']); + $this->assertInstanceOf(stdClass::class, $result['mixedObject']['emptyData']); + } + + /** + * Tests behavior when schema is missing or incomplete. + * + * @return void + */ + public function testMissingSchemaProperties(): void + { + $testObject = new class extends AbstractDataValueObject { + public function toArray(): array + { + return [ + 'hasSchema' => [], + 'noSchema' => [], + ]; + } + + public static function fromArray(array $array): self + { + return new static(); + } + + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'hasSchema' => [ + 'type' => 'object', + 'properties' => [] + ], + // 'noSchema' is intentionally missing from properties + ], + ]; + } + }; + + $result = $testObject->jsonSerialize(); + + // Verify result is an array + $this->assertIsArray($result); + + // Property with schema should be converted + $this->assertInstanceOf(stdClass::class, $result['hasSchema']); + + // Property without schema should remain as-is + $this->assertIsArray($result['noSchema']); + $this->assertEmpty($result['noSchema']); + + $json = json_encode($result); + $this->assertIsString($json); + $this->assertStringContainsString('"hasSchema":{}', $json); + $this->assertStringContainsString('"noSchema":[]', $json); + } + + /** + * Tests that AbstractDataValueObject implements all required interfaces. + * + * @return void + */ + public function testImplementsRequiredInterfaces(): void + { + $testObject = new class extends AbstractDataValueObject { + public function toArray(): array + { + return ['test' => 'value']; + } + + public static function fromArray(array $array): self + { + return new static(); + } + + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'test' => ['type' => 'string'] + ], + ]; + } + }; + + // Verify interface implementations + $this->assertInstanceOf(\WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface::class, $testObject); + $this->assertInstanceOf(\WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface::class, $testObject); + $this->assertInstanceOf(\JsonSerializable::class, $testObject); + + // Verify methods exist and work + $this->assertIsArray($testObject->toArray()); + $this->assertIsArray($testObject::getJsonSchema()); + $this->assertNotNull($testObject->jsonSerialize()); + } +} \ No newline at end of file diff --git a/tests/unit/Files/DTO/FileTest.php b/tests/unit/Files/DTO/FileTest.php index 15ee2ae4..931ca16b 100644 --- a/tests/unit/Files/DTO/FileTest.php +++ b/tests/unit/Files/DTO/FileTest.php @@ -225,18 +225,18 @@ public function testJsonSchema(): void // Check remote file schema $remoteSchema = $schema['oneOf'][0]; $this->assertArrayHasKey('properties', $remoteSchema); - $this->assertArrayHasKey('fileType', $remoteSchema['properties']); - $this->assertArrayHasKey('mimeType', $remoteSchema['properties']); - $this->assertArrayHasKey('url', $remoteSchema['properties']); - $this->assertEquals(['fileType', 'mimeType', 'url'], $remoteSchema['required']); + $this->assertArrayHasKey(File::KEY_FILE_TYPE, $remoteSchema['properties']); + $this->assertArrayHasKey(File::KEY_MIME_TYPE, $remoteSchema['properties']); + $this->assertArrayHasKey(File::KEY_URL, $remoteSchema['properties']); + $this->assertEquals([File::KEY_FILE_TYPE, File::KEY_MIME_TYPE, File::KEY_URL], $remoteSchema['required']); // Check inline file schema $inlineSchema = $schema['oneOf'][1]; $this->assertArrayHasKey('properties', $inlineSchema); - $this->assertArrayHasKey('fileType', $inlineSchema['properties']); - $this->assertArrayHasKey('mimeType', $inlineSchema['properties']); - $this->assertArrayHasKey('base64Data', $inlineSchema['properties']); - $this->assertEquals(['fileType', 'mimeType', 'base64Data'], $inlineSchema['required']); + $this->assertArrayHasKey(File::KEY_FILE_TYPE, $inlineSchema['properties']); + $this->assertArrayHasKey(File::KEY_MIME_TYPE, $inlineSchema['properties']); + $this->assertArrayHasKey(File::KEY_BASE64_DATA, $inlineSchema['properties']); + $this->assertEquals([File::KEY_FILE_TYPE, File::KEY_MIME_TYPE, File::KEY_BASE64_DATA], $inlineSchema['required']); } /** @@ -267,4 +267,128 @@ public function testUrlWithUnknownExtension(): void new File('https://example.com/file.unknown'); } + + /** + * Tests array transformation for remote file. + * + * @return void + */ + public function testToArrayRemoteFile(): void + { + $file = new File('https://example.com/image.jpg', 'image/jpeg'); + $json = $file->toArray(); + + $this->assertIsArray($json); + $this->assertEquals(\WordPress\AiClient\Files\Enums\FileTypeEnum::remote()->value, $json[File::KEY_FILE_TYPE]); + $this->assertEquals('image/jpeg', $json[File::KEY_MIME_TYPE]); + $this->assertEquals('https://example.com/image.jpg', $json[File::KEY_URL]); + $this->assertArrayNotHasKey(File::KEY_BASE64_DATA, $json); + } + + /** + * Tests array transformation for inline file. + * + * @return void + */ + public function testToArrayInlineFile(): void + { + $base64Data = 'SGVsbG8gV29ybGQ='; + $dataUri = 'data:text/plain;base64,' . $base64Data; + $file = new File($dataUri); + $json = $file->toArray(); + + $this->assertIsArray($json); + $this->assertEquals(\WordPress\AiClient\Files\Enums\FileTypeEnum::inline()->value, $json[File::KEY_FILE_TYPE]); + $this->assertEquals('text/plain', $json[File::KEY_MIME_TYPE]); + $this->assertEquals($base64Data, $json[File::KEY_BASE64_DATA]); + $this->assertArrayNotHasKey(File::KEY_URL, $json); + } + + /** + * Tests fromJson for remote file. + * + * @return void + */ + public function testFromArrayRemoteFile(): void + { + $json = [ + File::KEY_FILE_TYPE => \WordPress\AiClient\Files\Enums\FileTypeEnum::remote()->value, + File::KEY_MIME_TYPE => 'image/png', + File::KEY_URL => 'https://example.com/test.png' + ]; + + $file = File::fromArray($json); + + $this->assertInstanceOf(File::class, $file); + $this->assertTrue($file->getFileType()->isRemote()); + $this->assertEquals('image/png', $file->getMimeType()); + $this->assertEquals('https://example.com/test.png', $file->getUrl()); + $this->assertNull($file->getBase64Data()); + } + + /** + * Tests fromJson for inline file. + * + * @return void + */ + public function testFromArrayInlineFile(): void + { + $base64Data = 'SGVsbG8gV29ybGQ='; + $json = [ + File::KEY_FILE_TYPE => \WordPress\AiClient\Files\Enums\FileTypeEnum::inline()->value, + File::KEY_MIME_TYPE => 'text/plain', + File::KEY_BASE64_DATA => $base64Data + ]; + + $file = File::fromArray($json); + + $this->assertInstanceOf(File::class, $file); + $this->assertTrue($file->getFileType()->isInline()); + $this->assertEquals('text/plain', $file->getMimeType()); + $this->assertEquals($base64Data, $file->getBase64Data()); + $this->assertNull($file->getUrl()); + } + + /** + * Tests round-trip array transformation. + * + * @return void + */ + public function testArrayRoundTrip(): void + { + // Test remote file + $remoteFile = new File('https://example.com/doc.pdf', 'application/pdf'); + $remoteJson = $remoteFile->toArray(); + $restoredRemote = File::fromArray($remoteJson); + + $this->assertEquals($remoteFile->getFileType()->value, $restoredRemote->getFileType()->value); + $this->assertEquals($remoteFile->getMimeType(), $restoredRemote->getMimeType()); + $this->assertEquals($remoteFile->getUrl(), $restoredRemote->getUrl()); + + // Test inline file + $dataUri = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; + $inlineFile = new File($dataUri); + $inlineJson = $inlineFile->toArray(); + $restoredInline = File::fromArray($inlineJson); + + $this->assertEquals($inlineFile->getFileType()->value, $restoredInline->getFileType()->value); + $this->assertEquals($inlineFile->getMimeType(), $restoredInline->getMimeType()); + $this->assertEquals($inlineFile->getBase64Data(), $restoredInline->getBase64Data()); + } + + /** + * Tests File implements WithArrayTransformationInterface. + * + * @return void + */ + public function testImplementsWithArrayTransformationInterface(): void + { + $file = new File('https://example.com/test.jpg'); + + $this->assertInstanceOf( + \WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface::class, + $file + ); + + } } \ No newline at end of file diff --git a/tests/unit/Files/Enums/FileTypeEnumTest.php b/tests/unit/Files/Enums/FileTypeEnumTest.php index c80101d2..3bd832ff 100644 --- a/tests/unit/Files/Enums/FileTypeEnumTest.php +++ b/tests/unit/Files/Enums/FileTypeEnumTest.php @@ -6,7 +6,7 @@ use PHPUnit\Framework\TestCase; use WordPress\AiClient\Files\Enums\FileTypeEnum; -use WordPress\AiClient\Tests\unit\EnumTestTrait; +use WordPress\AiClient\Tests\traits\EnumTestTrait; /** * @covers \WordPress\AiClient\Files\Enums\FileTypeEnum diff --git a/tests/unit/Messages/DTO/MessagePartTest.php b/tests/unit/Messages/DTO/MessagePartTest.php index 0bdbccdf..f1783c94 100644 --- a/tests/unit/Messages/DTO/MessagePartTest.php +++ b/tests/unit/Messages/DTO/MessagePartTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use WordPress\AiClient\Files\DTO\File; +use WordPress\AiClient\Files\Enums\FileTypeEnum; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\Enums\MessagePartTypeEnum; use WordPress\AiClient\Tools\DTO\FunctionCall; @@ -149,30 +150,30 @@ public function testJsonSchema(): void // Check text variant $textSchema = $schema['oneOf'][0]; $this->assertEquals('object', $textSchema['type']); - $this->assertEquals(MessagePartTypeEnum::text()->value, $textSchema['properties']['type']['const']); - $this->assertArrayHasKey('text', $textSchema['properties']); - $this->assertEquals(['type', 'text'], $textSchema['required']); + $this->assertEquals(MessagePartTypeEnum::text()->value, $textSchema['properties'][MessagePart::KEY_TYPE]['const']); + $this->assertArrayHasKey(MessagePart::KEY_TEXT, $textSchema['properties']); + $this->assertEquals([MessagePart::KEY_TYPE, MessagePart::KEY_TEXT], $textSchema['required']); // Check file variant $fileSchema = $schema['oneOf'][1]; $this->assertEquals('object', $fileSchema['type']); - $this->assertEquals(MessagePartTypeEnum::file()->value, $fileSchema['properties']['type']['const']); - $this->assertArrayHasKey('file', $fileSchema['properties']); - $this->assertEquals(['type', 'file'], $fileSchema['required']); + $this->assertEquals(MessagePartTypeEnum::file()->value, $fileSchema['properties'][MessagePart::KEY_TYPE]['const']); + $this->assertArrayHasKey(MessagePart::KEY_FILE, $fileSchema['properties']); + $this->assertEquals([MessagePart::KEY_TYPE, MessagePart::KEY_FILE], $fileSchema['required']); // Check function_call variant $functionCallSchema = $schema['oneOf'][2]; $this->assertEquals('object', $functionCallSchema['type']); - $this->assertEquals(MessagePartTypeEnum::functionCall()->value, $functionCallSchema['properties']['type']['const']); - $this->assertArrayHasKey('functionCall', $functionCallSchema['properties']); - $this->assertEquals(['type', 'functionCall'], $functionCallSchema['required']); + $this->assertEquals(MessagePartTypeEnum::functionCall()->value, $functionCallSchema['properties'][MessagePart::KEY_TYPE]['const']); + $this->assertArrayHasKey(MessagePart::KEY_FUNCTION_CALL, $functionCallSchema['properties']); + $this->assertEquals([MessagePart::KEY_TYPE, MessagePart::KEY_FUNCTION_CALL], $functionCallSchema['required']); // Check function_response variant $functionResponseSchema = $schema['oneOf'][3]; $this->assertEquals('object', $functionResponseSchema['type']); - $this->assertEquals(MessagePartTypeEnum::functionResponse()->value, $functionResponseSchema['properties']['type']['const']); - $this->assertArrayHasKey('functionResponse', $functionResponseSchema['properties']); - $this->assertEquals(['type', 'functionResponse'], $functionResponseSchema['required']); + $this->assertEquals(MessagePartTypeEnum::functionResponse()->value, $functionResponseSchema['properties'][MessagePart::KEY_TYPE]['const']); + $this->assertArrayHasKey(MessagePart::KEY_FUNCTION_RESPONSE, $functionResponseSchema['properties']); + $this->assertEquals([MessagePart::KEY_TYPE, MessagePart::KEY_FUNCTION_RESPONSE], $functionResponseSchema['required']); } /** @@ -230,4 +231,132 @@ public function testWithUnicodeText(): void $this->assertEquals($unicodeText, $part->getText()); } + + /** + * Tests array transformation with text content. + * + * @return void + */ + public function testToArrayWithText(): void + { + $part = new MessagePart('Hello, world!'); + $json = $part->toArray(); + + $this->assertIsArray($json); + $this->assertArrayHasKey(MessagePart::KEY_TYPE, $json); + $this->assertArrayHasKey(MessagePart::KEY_TEXT, $json); + $this->assertEquals(MessagePartTypeEnum::text()->value, $json[MessagePart::KEY_TYPE]); + $this->assertEquals('Hello, world!', $json[MessagePart::KEY_TEXT]); + + // Ensure other fields are not present + $this->assertArrayNotHasKey(MessagePart::KEY_FILE, $json); + $this->assertArrayNotHasKey(MessagePart::KEY_FUNCTION_CALL, $json); + $this->assertArrayNotHasKey(MessagePart::KEY_FUNCTION_RESPONSE, $json); + } + + /** + * Tests array transformation with file content. + * + * @return void + */ + public function testToArrayWithFile(): void + { + $file = new File('https://example.com/image.jpg', 'image/jpeg'); + $part = new MessagePart($file); + $json = $part->toArray(); + + $this->assertIsArray($json); + $this->assertArrayHasKey(MessagePart::KEY_TYPE, $json); + $this->assertArrayHasKey(MessagePart::KEY_FILE, $json); + $this->assertEquals(MessagePartTypeEnum::file()->value, $json[MessagePart::KEY_TYPE]); + $this->assertIsArray($json[MessagePart::KEY_FILE]); + } + + /** + * Tests fromJson with text content. + * + * @return void + */ + public function testFromArrayWithText(): void + { + $json = [ + MessagePart::KEY_TYPE => MessagePartTypeEnum::text()->value, + MessagePart::KEY_TEXT => 'Test message' + ]; + + $part = MessagePart::fromArray($json); + + $this->assertEquals(MessagePartTypeEnum::text(), $part->getType()); + $this->assertEquals('Test message', $part->getText()); + } + + /** + * Tests fromJson with file content. + * + * @return void + */ + public function testFromArrayWithFile(): void + { + $json = [ + MessagePart::KEY_TYPE => MessagePartTypeEnum::file()->value, + MessagePart::KEY_FILE => [ + File::KEY_FILE_TYPE => FileTypeEnum::remote()->value, + File::KEY_MIME_TYPE => 'image/jpeg', + File::KEY_URL => 'https://example.com/image.jpg' + ] + ]; + + $part = MessagePart::fromArray($json); + + $this->assertEquals(MessagePartTypeEnum::file(), $part->getType()); + $this->assertInstanceOf(File::class, $part->getFile()); + $this->assertEquals('https://example.com/image.jpg', $part->getFile()->getUrl()); + } + + /** + * Tests round-trip array transformation with different content types. + * + * @return void + */ + public function testArrayRoundTrip(): void + { + // Test with text + $textPart = new MessagePart('Test text'); + $textJson = $textPart->toArray(); + $restoredText = MessagePart::fromArray($textJson); + $this->assertEquals($textPart->getText(), $restoredText->getText()); + + // Test with file + $file = new File('https://example.com/doc.pdf', 'application/pdf'); + $filePart = new MessagePart($file); + $fileJson = $filePart->toArray(); + $restoredFile = MessagePart::fromArray($fileJson); + $this->assertEquals($file->getUrl(), $restoredFile->getFile()->getUrl()); + $this->assertEquals($file->getMimeType(), $restoredFile->getFile()->getMimeType()); + + // Test with function call + $functionCall = new FunctionCall('id_123', 'getData', ['key' => 'value']); + $funcPart = new MessagePart($functionCall); + $funcJson = $funcPart->toArray(); + $restoredFunc = MessagePart::fromArray($funcJson); + $this->assertEquals($functionCall->getId(), $restoredFunc->getFunctionCall()->getId()); + $this->assertEquals($functionCall->getName(), $restoredFunc->getFunctionCall()->getName()); + $this->assertEquals($functionCall->getArgs(), $restoredFunc->getFunctionCall()->getArgs()); + } + + /** + * Tests MessagePart implements WithArrayTransformationInterface. + * + * @return void + */ + public function testImplementsWithArrayTransformationInterface(): void + { + $part = new MessagePart('test'); + + $this->assertInstanceOf( + \WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface::class, + $part + ); + + } } \ No newline at end of file diff --git a/tests/unit/Messages/DTO/MessageTest.php b/tests/unit/Messages/DTO/MessageTest.php index 763696a7..0d28933c 100644 --- a/tests/unit/Messages/DTO/MessageTest.php +++ b/tests/unit/Messages/DTO/MessageTest.php @@ -8,6 +8,7 @@ use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; +use WordPress\AiClient\Messages\Enums\MessagePartTypeEnum; use WordPress\AiClient\Messages\Enums\MessageRoleEnum; use WordPress\AiClient\Tools\DTO\FunctionCall; use WordPress\AiClient\Tools\DTO\FunctionResponse; @@ -140,11 +141,11 @@ public function testJsonSchema(): void // Check properties $this->assertArrayHasKey('properties', $schema); - $this->assertArrayHasKey('role', $schema['properties']); - $this->assertArrayHasKey('parts', $schema['properties']); + $this->assertArrayHasKey(Message::KEY_ROLE, $schema['properties']); + $this->assertArrayHasKey(Message::KEY_PARTS, $schema['properties']); // Check role property - $roleSchema = $schema['properties']['role']; + $roleSchema = $schema['properties'][Message::KEY_ROLE]; $this->assertEquals('string', $roleSchema['type']); $this->assertArrayHasKey('enum', $roleSchema); $this->assertContains('system', $roleSchema['enum']); @@ -152,14 +153,14 @@ public function testJsonSchema(): void $this->assertContains('model', $roleSchema['enum']); // Check parts property - $partsSchema = $schema['properties']['parts']; + $partsSchema = $schema['properties'][Message::KEY_PARTS]; $this->assertEquals('array', $partsSchema['type']); $this->assertArrayHasKey('items', $partsSchema); $this->assertIsArray($partsSchema['items']); // Check required fields $this->assertArrayHasKey('required', $schema); - $this->assertEquals(['role', 'parts'], $schema['required']); + $this->assertEquals([Message::KEY_ROLE, Message::KEY_PARTS], $schema['required']); } /** @@ -227,4 +228,92 @@ public function testModelMessageWithFunctionResponse(): void $this->assertTrue($message->getRole()->isModel()); $this->assertNotNull($message->getParts()[0]->getFunctionResponse()); } + + /** + * Tests array transformation. + * + * @return void + */ + public function testToArray(): void + { + $role = MessageRoleEnum::user(); + $parts = [ + new MessagePart('Hello, world!'), + new MessagePart('How are you?') + ]; + $message = new Message($role, $parts); + $json = $message->toArray(); + + $this->assertIsArray($json); + $this->assertEquals($role->value, $json[Message::KEY_ROLE]); + $this->assertIsArray($json[Message::KEY_PARTS]); + $this->assertCount(2, $json[Message::KEY_PARTS]); + $this->assertEquals('Hello, world!', $json[Message::KEY_PARTS][0][MessagePart::KEY_TEXT]); + $this->assertEquals('How are you?', $json[Message::KEY_PARTS][1][MessagePart::KEY_TEXT]); + } + + /** + * Tests fromJson method. + * + * @return void + */ + public function testFromArray(): void + { + $json = [ + Message::KEY_ROLE => MessageRoleEnum::system()->value, + Message::KEY_PARTS => [ + [MessagePart::KEY_TYPE => MessagePartTypeEnum::text()->value, MessagePart::KEY_TEXT => 'You are a helpful assistant.'] + ] + ]; + + $message = Message::fromArray($json); + + $this->assertInstanceOf(Message::class, $message); + $this->assertEquals(MessageRoleEnum::system(), $message->getRole()); + $this->assertCount(1, $message->getParts()); + $this->assertEquals('You are a helpful assistant.', $message->getParts()[0]->getText()); + } + + /** + * Tests round-trip array transformation. + * + * @return void + */ + public function testArrayRoundTrip(): void + { + $original = new Message( + MessageRoleEnum::model(), + [ + new MessagePart('Here is the result:'), + new MessagePart(new File('https://example.com/result.png', 'image/png')) + ] + ); + + $json = $original->toArray(); + $restored = Message::fromArray($json); + + $this->assertEquals($original->getRole()->value, $restored->getRole()->value); + $this->assertCount(count($original->getParts()), $restored->getParts()); + $this->assertEquals($original->getParts()[0]->getText(), $restored->getParts()[0]->getText()); + $this->assertEquals( + $original->getParts()[1]->getFile()->getUrl(), + $restored->getParts()[1]->getFile()->getUrl() + ); + } + + /** + * Tests Message implements WithArrayTransformationInterface. + * + * @return void + */ + public function testImplementsWithArrayTransformationInterface(): void + { + $message = new Message(MessageRoleEnum::user(), [new MessagePart('test')]); + + $this->assertInstanceOf( + \WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface::class, + $message + ); + + } } \ No newline at end of file diff --git a/tests/unit/Messages/DTO/ModelMessageTest.php b/tests/unit/Messages/DTO/ModelMessageTest.php index 9536b0e7..7f0352c6 100644 --- a/tests/unit/Messages/DTO/ModelMessageTest.php +++ b/tests/unit/Messages/DTO/ModelMessageTest.php @@ -5,15 +5,22 @@ namespace WordPress\AiClient\Tests\unit\Messages\DTO; use PHPUnit\Framework\TestCase; +use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\ModelMessage; +use WordPress\AiClient\Messages\Enums\MessagePartTypeEnum; use WordPress\AiClient\Messages\Enums\MessageRoleEnum; +use WordPress\AiClient\Tests\traits\ArrayTransformationTestTrait; +use WordPress\AiClient\Tools\DTO\FunctionCall; +use WordPress\AiClient\Tools\DTO\FunctionResponse; /** * @covers \WordPress\AiClient\Messages\DTO\ModelMessage */ class ModelMessageTest extends TestCase { + use ArrayTransformationTestTrait; + /** * Tests creating ModelMessage automatically sets MODEL role. * @@ -83,9 +90,9 @@ public function testInheritsFromMessage(): void */ public function testWithVariousContentTypes(): void { - $file = new \WordPress\AiClient\Files\DTO\File('https://example.com/image.jpg', 'image/jpeg'); - $functionCall = new \WordPress\AiClient\Tools\DTO\FunctionCall('func_123', 'search', ['q' => 'test']); - $functionResponse = new \WordPress\AiClient\Tools\DTO\FunctionResponse('func_123', 'search', ['results' => []]); + $file = new File('https://example.com/image.jpg', 'image/jpeg'); + $functionCall = new FunctionCall('func_123', 'search', ['q' => 'test']); + $functionResponse = new FunctionResponse('func_123', 'search', ['results' => []]); $parts = [ new MessagePart('I found the following:'), @@ -114,4 +121,91 @@ public function testJsonSchemaInheritance(): void $this->assertEquals($parentSchema, $schema); } + + /** + * Tests array transformation. + * + * @return void + */ + public function testToArray(): void + { + $message = new ModelMessage([ + new MessagePart('I can help you with that.'), + new MessagePart('Here is the solution:') + ]); + + $json = $this->assertToArrayReturnsArray($message); + + $this->assertArrayHasKeys($json, ['role', 'parts']); + $this->assertEquals(MessageRoleEnum::model()->value, $json['role']); + $this->assertCount(2, $json['parts']); + $this->assertEquals('I can help you with that.', $json['parts'][0]['text']); + $this->assertEquals('Here is the solution:', $json['parts'][1]['text']); + } + + /** + * Tests fromJson method. + * + * @return void + */ + public function testFromArray(): void + { + $json = [ + 'role' => MessageRoleEnum::model()->value, + 'parts' => [ + ['type' => MessagePartTypeEnum::text()->value, 'text' => 'Model response 1'], + ['type' => MessagePartTypeEnum::text()->value, 'text' => 'Model response 2'] + ] + ]; + + $message = ModelMessage::fromArray($json); + + $this->assertInstanceOf(ModelMessage::class, $message); + $this->assertEquals(MessageRoleEnum::model(), $message->getRole()); + $this->assertCount(2, $message->getParts()); + $this->assertEquals('Model response 1', $message->getParts()[0]->getText()); + $this->assertEquals('Model response 2', $message->getParts()[1]->getText()); + } + + /** + * Tests round-trip array transformation with function call. + * + * @return void + */ + public function testArrayRoundTripWithFunctionCall(): void + { + $this->assertArrayRoundTrip( + new ModelMessage([ + new MessagePart('I\'ll search for that information.'), + new MessagePart(new FunctionCall('search_123', 'webSearch', ['query' => 'PHP 8 features'])) + ]), + function ($original, $restored) { + $this->assertEquals($original->getRole()->value, $restored->getRole()->value); + $this->assertCount(count($original->getParts()), $restored->getParts()); + $this->assertEquals( + $original->getParts()[0]->getText(), + $restored->getParts()[0]->getText() + ); + $this->assertEquals( + $original->getParts()[1]->getFunctionCall()->getId(), + $restored->getParts()[1]->getFunctionCall()->getId() + ); + $this->assertEquals( + $original->getParts()[1]->getFunctionCall()->getName(), + $restored->getParts()[1]->getFunctionCall()->getName() + ); + } + ); + } + + /** + * Tests ModelMessage implements WithArrayTransformationInterface. + * + * @return void + */ + public function testImplementsWithArrayTransformationInterface(): void + { + $message = new ModelMessage([new MessagePart('test')]); + $this->assertImplementsArrayTransformation($message); + } } \ No newline at end of file diff --git a/tests/unit/Messages/DTO/SystemMessageTest.php b/tests/unit/Messages/DTO/SystemMessageTest.php index 5d80ef2b..e716091c 100644 --- a/tests/unit/Messages/DTO/SystemMessageTest.php +++ b/tests/unit/Messages/DTO/SystemMessageTest.php @@ -7,13 +7,17 @@ use PHPUnit\Framework\TestCase; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\SystemMessage; +use WordPress\AiClient\Messages\Enums\MessagePartTypeEnum; use WordPress\AiClient\Messages\Enums\MessageRoleEnum; +use WordPress\AiClient\Tests\traits\ArrayTransformationTestTrait; /** * @covers \WordPress\AiClient\Messages\DTO\SystemMessage */ class SystemMessageTest extends TestCase { + use ArrayTransformationTestTrait; + /** * Tests creating SystemMessage automatically sets SYSTEM role. * @@ -163,4 +167,87 @@ public function testPreservesPartOrder(): void $this->assertEquals('Third instruction', $retrievedParts[2]->getText()); $this->assertEquals('Fourth instruction', $retrievedParts[3]->getText()); } + + /** + * Tests array transformation. + * + * @return void + */ + public function testToArray(): void + { + $message = new SystemMessage([ + new MessagePart('You are a helpful assistant.'), + new MessagePart('Always be respectful and accurate.') + ]); + + $json = $this->assertToArrayReturnsArray($message); + + $this->assertArrayHasKeys($json, ['role', 'parts']); + $this->assertEquals(MessageRoleEnum::system()->value, $json['role']); + $this->assertCount(2, $json['parts']); + $this->assertEquals('You are a helpful assistant.', $json['parts'][0]['text']); + $this->assertEquals('Always be respectful and accurate.', $json['parts'][1]['text']); + } + + /** + * Tests fromJson method. + * + * @return void + */ + public function testFromArray(): void + { + $json = [ + 'role' => MessageRoleEnum::system()->value, + 'parts' => [ + ['type' => MessagePartTypeEnum::text()->value, 'text' => 'System instruction 1'], + ['type' => MessagePartTypeEnum::text()->value, 'text' => 'System instruction 2'] + ] + ]; + + $message = SystemMessage::fromArray($json); + + $this->assertInstanceOf(SystemMessage::class, $message); + $this->assertEquals(MessageRoleEnum::system(), $message->getRole()); + $this->assertCount(2, $message->getParts()); + $this->assertEquals('System instruction 1', $message->getParts()[0]->getText()); + $this->assertEquals('System instruction 2', $message->getParts()[1]->getText()); + } + + /** + * Tests round-trip array transformation. + * + * @return void + */ + public function testArrayRoundTrip(): void + { + $this->assertArrayRoundTrip( + new SystemMessage([ + new MessagePart('You are an expert in PHP.'), + new MessagePart('Follow best practices.') + ]), + function ($original, $restored) { + $this->assertEquals($original->getRole()->value, $restored->getRole()->value); + $this->assertCount(count($original->getParts()), $restored->getParts()); + $this->assertEquals( + $original->getParts()[0]->getText(), + $restored->getParts()[0]->getText() + ); + $this->assertEquals( + $original->getParts()[1]->getText(), + $restored->getParts()[1]->getText() + ); + } + ); + } + + /** + * Tests SystemMessage implements WithArrayTransformationInterface. + * + * @return void + */ + public function testImplementsWithArrayTransformationInterface(): void + { + $message = new SystemMessage([new MessagePart('test')]); + $this->assertImplementsArrayTransformation($message); + } } \ No newline at end of file diff --git a/tests/unit/Messages/DTO/UserMessageTest.php b/tests/unit/Messages/DTO/UserMessageTest.php index 2249d58c..392a7249 100644 --- a/tests/unit/Messages/DTO/UserMessageTest.php +++ b/tests/unit/Messages/DTO/UserMessageTest.php @@ -8,13 +8,17 @@ use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\UserMessage; +use WordPress\AiClient\Messages\Enums\MessagePartTypeEnum; use WordPress\AiClient\Messages\Enums\MessageRoleEnum; +use WordPress\AiClient\Tests\traits\ArrayTransformationTestTrait; /** * @covers \WordPress\AiClient\Messages\DTO\UserMessage */ class UserMessageTest extends TestCase { + use ArrayTransformationTestTrait; + /** * Tests creating UserMessage automatically sets USER role. * @@ -223,4 +227,87 @@ public function testWithMultipleFiles(): void $this->assertInstanceOf(File::class, $message->getParts()[2]->getFile()); $this->assertInstanceOf(File::class, $message->getParts()[4]->getFile()); } + + /** + * Tests array transformation. + * + * @return void + */ + public function testToArray(): void + { + $message = new UserMessage([ + new MessagePart('Hello, I need help'), + new MessagePart('Can you assist?') + ]); + + $json = $this->assertToArrayReturnsArray($message); + + $this->assertArrayHasKeys($json, ['role', 'parts']); + $this->assertEquals(MessageRoleEnum::user()->value, $json['role']); + $this->assertCount(2, $json['parts']); + $this->assertEquals('Hello, I need help', $json['parts'][0]['text']); + $this->assertEquals('Can you assist?', $json['parts'][1]['text']); + } + + /** + * Tests fromJson method. + * + * @return void + */ + public function testFromArray(): void + { + $json = [ + 'role' => MessageRoleEnum::user()->value, + 'parts' => [ + ['type' => MessagePartTypeEnum::text()->value, 'text' => 'Question 1'], + ['type' => MessagePartTypeEnum::text()->value, 'text' => 'Question 2'] + ] + ]; + + $message = UserMessage::fromArray($json); + + $this->assertInstanceOf(UserMessage::class, $message); + $this->assertEquals(MessageRoleEnum::user(), $message->getRole()); + $this->assertCount(2, $message->getParts()); + $this->assertEquals('Question 1', $message->getParts()[0]->getText()); + $this->assertEquals('Question 2', $message->getParts()[1]->getText()); + } + + /** + * Tests round-trip array transformation. + * + * @return void + */ + public function testArrayRoundTrip(): void + { + $this->assertArrayRoundTrip( + new UserMessage([ + new MessagePart('Test message'), + new MessagePart(new File('https://example.com/image.jpg', 'image/jpeg')) + ]), + function ($original, $restored) { + $this->assertEquals($original->getRole()->value, $restored->getRole()->value); + $this->assertCount(count($original->getParts()), $restored->getParts()); + $this->assertEquals( + $original->getParts()[0]->getText(), + $restored->getParts()[0]->getText() + ); + $this->assertEquals( + $original->getParts()[1]->getFile()->getUrl(), + $restored->getParts()[1]->getFile()->getUrl() + ); + } + ); + } + + /** + * Tests UserMessage implements WithArrayTransformationInterface. + * + * @return void + */ + public function testImplementsWithArrayTransformationInterface(): void + { + $message = new UserMessage([new MessagePart('test')]); + $this->assertImplementsArrayTransformation($message); + } } \ No newline at end of file diff --git a/tests/unit/Messages/Enums/MessagePartTypeEnumTest.php b/tests/unit/Messages/Enums/MessagePartTypeEnumTest.php index e0551d24..35860816 100644 --- a/tests/unit/Messages/Enums/MessagePartTypeEnumTest.php +++ b/tests/unit/Messages/Enums/MessagePartTypeEnumTest.php @@ -6,7 +6,7 @@ use PHPUnit\Framework\TestCase; use WordPress\AiClient\Messages\Enums\MessagePartTypeEnum; -use WordPress\AiClient\Tests\unit\EnumTestTrait; +use WordPress\AiClient\Tests\traits\EnumTestTrait; /** * @covers \WordPress\AiClient\Messages\Enums\MessagePartTypeEnum diff --git a/tests/unit/Messages/Enums/MessageRoleEnumTest.php b/tests/unit/Messages/Enums/MessageRoleEnumTest.php index 9e354325..61418b44 100644 --- a/tests/unit/Messages/Enums/MessageRoleEnumTest.php +++ b/tests/unit/Messages/Enums/MessageRoleEnumTest.php @@ -6,7 +6,7 @@ use PHPUnit\Framework\TestCase; use WordPress\AiClient\Messages\Enums\MessageRoleEnum; -use WordPress\AiClient\Tests\unit\EnumTestTrait; +use WordPress\AiClient\Tests\traits\EnumTestTrait; /** * @covers \WordPress\AiClient\Messages\Enums\MessageRoleEnum diff --git a/tests/unit/Messages/Enums/ModalityEnumTest.php b/tests/unit/Messages/Enums/ModalityEnumTest.php index cea4e5f0..bac0a452 100644 --- a/tests/unit/Messages/Enums/ModalityEnumTest.php +++ b/tests/unit/Messages/Enums/ModalityEnumTest.php @@ -6,7 +6,7 @@ use PHPUnit\Framework\TestCase; use WordPress\AiClient\Messages\Enums\ModalityEnum; -use WordPress\AiClient\Tests\unit\EnumTestTrait; +use WordPress\AiClient\Tests\traits\EnumTestTrait; /** * @covers \WordPress\AiClient\Messages\Enums\ModalityEnum diff --git a/tests/unit/Operations/DTO/GenerativeAiOperationTest.php b/tests/unit/Operations/DTO/GenerativeAiOperationTest.php index be2d7970..5d929202 100644 --- a/tests/unit/Operations/DTO/GenerativeAiOperationTest.php +++ b/tests/unit/Operations/DTO/GenerativeAiOperationTest.php @@ -11,16 +11,19 @@ use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\ModelMessage; use WordPress\AiClient\Messages\Enums\MessageRoleEnum; +use WordPress\AiClient\Messages\Enums\MessagePartTypeEnum; use WordPress\AiClient\Results\DTO\Candidate; use WordPress\AiClient\Results\DTO\GenerativeAiResult; use WordPress\AiClient\Results\DTO\TokenUsage; use WordPress\AiClient\Results\Enums\FinishReasonEnum; +use WordPress\AiClient\Tests\traits\ArrayTransformationTestTrait; /** * @covers \WordPress\AiClient\Operations\DTO\GenerativeAiOperation */ class GenerativeAiOperationTest extends TestCase { + use ArrayTransformationTestTrait; /** * Tests creating operation in starting state. * @@ -232,18 +235,18 @@ public function testJsonSchemaForSucceededState(): void $succeededSchema = $schema['oneOf'][0]; $this->assertEquals('object', $succeededSchema['type']); $this->assertArrayHasKey('properties', $succeededSchema); - $this->assertArrayHasKey('id', $succeededSchema['properties']); - $this->assertArrayHasKey('state', $succeededSchema['properties']); - $this->assertArrayHasKey('result', $succeededSchema['properties']); + $this->assertArrayHasKey(GenerativeAiOperation::KEY_ID, $succeededSchema['properties']); + $this->assertArrayHasKey(GenerativeAiOperation::KEY_STATE, $succeededSchema['properties']); + $this->assertArrayHasKey(GenerativeAiOperation::KEY_RESULT, $succeededSchema['properties']); // State should be const for succeeded $this->assertEquals( OperationStateEnum::succeeded()->value, - $succeededSchema['properties']['state']['const'] + $succeededSchema['properties'][GenerativeAiOperation::KEY_STATE]['const'] ); // Required fields - $this->assertEquals(['id', 'state', 'result'], $succeededSchema['required']); + $this->assertEquals([GenerativeAiOperation::KEY_ID, GenerativeAiOperation::KEY_STATE, GenerativeAiOperation::KEY_RESULT], $succeededSchema['required']); } /** @@ -259,19 +262,19 @@ public function testJsonSchemaForNonSucceededStates(): void $otherStatesSchema = $schema['oneOf'][1]; $this->assertEquals('object', $otherStatesSchema['type']); $this->assertArrayHasKey('properties', $otherStatesSchema); - $this->assertArrayHasKey('id', $otherStatesSchema['properties']); - $this->assertArrayHasKey('state', $otherStatesSchema['properties']); - $this->assertArrayNotHasKey('result', $otherStatesSchema['properties']); + $this->assertArrayHasKey(GenerativeAiOperation::KEY_ID, $otherStatesSchema['properties']); + $this->assertArrayHasKey(GenerativeAiOperation::KEY_STATE, $otherStatesSchema['properties']); + $this->assertArrayNotHasKey(GenerativeAiOperation::KEY_RESULT, $otherStatesSchema['properties']); // State should be enum for other states - $stateEnum = $otherStatesSchema['properties']['state']['enum']; + $stateEnum = $otherStatesSchema['properties'][GenerativeAiOperation::KEY_STATE]['enum']; $this->assertContains(OperationStateEnum::starting()->value, $stateEnum); $this->assertContains(OperationStateEnum::processing()->value, $stateEnum); $this->assertContains(OperationStateEnum::failed()->value, $stateEnum); $this->assertContains(OperationStateEnum::canceled()->value, $stateEnum); // Required fields - $this->assertEquals(['id', 'state'], $otherStatesSchema['required']); + $this->assertEquals([GenerativeAiOperation::KEY_ID, GenerativeAiOperation::KEY_STATE], $otherStatesSchema['required']); } /** @@ -288,4 +291,191 @@ public function testWithEmptyStringId(): void $this->assertEquals('', $operation->getId()); } + + /** + * Tests array transformation for operation in starting state. + * + * @return void + */ + public function testToArrayStartingState(): void + { + $operation = new GenerativeAiOperation( + 'op_start_123', + OperationStateEnum::starting() + ); + + $json = $this->assertToArrayReturnsArray($operation); + + $this->assertArrayHasKeys($json, [GenerativeAiOperation::KEY_ID, GenerativeAiOperation::KEY_STATE]); + $this->assertArrayNotHasKeys($json, [GenerativeAiOperation::KEY_RESULT]); + $this->assertEquals('op_start_123', $json[GenerativeAiOperation::KEY_ID]); + $this->assertEquals(OperationStateEnum::starting()->value, $json[GenerativeAiOperation::KEY_STATE]); + } + + /** + * Tests array transformation for operation in succeeded state. + * + * @return void + */ + public function testToArraySucceededState(): void + { + $modelMessage = new ModelMessage([ + new MessagePart('Success response') + ]); + $candidate = new Candidate( + $modelMessage, + FinishReasonEnum::stop(), + 50 + ); + $tokenUsage = new TokenUsage(15, 50, 65); + $result = new GenerativeAiResult( + 'result_success', + [$candidate], + $tokenUsage + ); + + $operation = new GenerativeAiOperation( + 'op_success_456', + OperationStateEnum::succeeded(), + $result + ); + + $json = $this->assertToArrayReturnsArray($operation); + + $this->assertArrayHasKeys($json, [GenerativeAiOperation::KEY_ID, GenerativeAiOperation::KEY_STATE, GenerativeAiOperation::KEY_RESULT]); + $this->assertEquals('op_success_456', $json[GenerativeAiOperation::KEY_ID]); + $this->assertEquals(OperationStateEnum::succeeded()->value, $json[GenerativeAiOperation::KEY_STATE]); + $this->assertIsArray($json[GenerativeAiOperation::KEY_RESULT]); + $this->assertEquals('result_success', $json[GenerativeAiOperation::KEY_RESULT][GenerativeAiResult::KEY_ID]); + } + + /** + * Tests fromJson method for starting state. + * + * @return void + */ + public function testFromArrayStartingState(): void + { + $json = [ + GenerativeAiOperation::KEY_ID => 'op_from_json_start', + GenerativeAiOperation::KEY_STATE => OperationStateEnum::starting()->value + ]; + + $operation = GenerativeAiOperation::fromArray($json); + + $this->assertInstanceOf(GenerativeAiOperation::class, $operation); + $this->assertEquals('op_from_json_start', $operation->getId()); + $this->assertEquals(OperationStateEnum::starting(), $operation->getState()); + $this->assertNull($operation->getResult()); + } + + /** + * Tests fromJson method for succeeded state with result. + * + * @return void + */ + public function testFromArraySucceededState(): void + { + $json = [ + GenerativeAiOperation::KEY_ID => 'op_from_json_success', + GenerativeAiOperation::KEY_STATE => OperationStateEnum::succeeded()->value, + GenerativeAiOperation::KEY_RESULT => [ + GenerativeAiResult::KEY_ID => 'result_from_json', + GenerativeAiResult::KEY_CANDIDATES => [ + [ + Candidate::KEY_MESSAGE => [ + Message::KEY_ROLE => MessageRoleEnum::model()->value, + Message::KEY_PARTS => [[MessagePart::KEY_TYPE => 'text', MessagePart::KEY_TEXT => 'Response text']] + ], + Candidate::KEY_FINISH_REASON => FinishReasonEnum::stop()->value, + Candidate::KEY_TOKEN_COUNT => 30 + ] + ], + GenerativeAiResult::KEY_TOKEN_USAGE => [ + TokenUsage::KEY_PROMPT_TOKENS => 10, + TokenUsage::KEY_COMPLETION_TOKENS => 30, + TokenUsage::KEY_TOTAL_TOKENS => 40 + ] + ] + ]; + + $operation = GenerativeAiOperation::fromArray($json); + + $this->assertInstanceOf(GenerativeAiOperation::class, $operation); + $this->assertEquals('op_from_json_success', $operation->getId()); + $this->assertEquals(OperationStateEnum::succeeded(), $operation->getState()); + $this->assertNotNull($operation->getResult()); + $this->assertEquals('result_from_json', $operation->getResult()->getId()); + } + + /** + * Tests round-trip array transformation for processing state. + * + * @return void + */ + public function testArrayRoundTripProcessingState(): void + { + $this->assertArrayRoundTrip( + new GenerativeAiOperation( + 'op_roundtrip_process', + OperationStateEnum::processing() + ), + function ($original, $restored) { + $this->assertEquals($original->getId(), $restored->getId()); + $this->assertEquals($original->getState()->value, $restored->getState()->value); + $this->assertNull($restored->getResult()); + } + ); + } + + /** + * Tests round-trip array transformation for succeeded state. + * + * @return void + */ + public function testArrayRoundTripSucceededState(): void + { + $modelMessage = new ModelMessage([ + new MessagePart('Roundtrip test response') + ]); + $candidate = new Candidate( + $modelMessage, + FinishReasonEnum::stop(), + 25 + ); + $tokenUsage = new TokenUsage(5, 25, 30); + $result = new GenerativeAiResult( + 'result_roundtrip', + [$candidate], + $tokenUsage + ); + + $this->assertArrayRoundTrip( + new GenerativeAiOperation( + 'op_roundtrip_success', + OperationStateEnum::succeeded(), + $result + ), + function ($original, $restored) { + $this->assertEquals($original->getId(), $restored->getId()); + $this->assertEquals($original->getState()->value, $restored->getState()->value); + $this->assertNotNull($restored->getResult()); + $this->assertEquals($original->getResult()->getId(), $restored->getResult()->getId()); + } + ); + } + + /** + * Tests GenerativeAiOperation implements WithArrayTransformationInterface. + * + * @return void + */ + public function testImplementsWithArrayTransformationInterface(): void + { + $operation = new GenerativeAiOperation( + 'op_test', + OperationStateEnum::starting() + ); + $this->assertImplementsArrayTransformation($operation); + } } \ No newline at end of file diff --git a/tests/unit/Operations/Enums/OperationStateEnumTest.php b/tests/unit/Operations/Enums/OperationStateEnumTest.php index fd7f2962..3de8cadc 100644 --- a/tests/unit/Operations/Enums/OperationStateEnumTest.php +++ b/tests/unit/Operations/Enums/OperationStateEnumTest.php @@ -6,7 +6,7 @@ use PHPUnit\Framework\TestCase; use WordPress\AiClient\Operations\Enums\OperationStateEnum; -use WordPress\AiClient\Tests\unit\EnumTestTrait; +use WordPress\AiClient\Tests\traits\EnumTestTrait; /** * @covers \WordPress\AiClient\Operations\Enums\OperationStateEnum diff --git a/tests/unit/Providers/Enums/ProviderTypeEnumTest.php b/tests/unit/Providers/Enums/ProviderTypeEnumTest.php index e6582317..905b579c 100644 --- a/tests/unit/Providers/Enums/ProviderTypeEnumTest.php +++ b/tests/unit/Providers/Enums/ProviderTypeEnumTest.php @@ -6,7 +6,7 @@ use PHPUnit\Framework\TestCase; use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; -use WordPress\AiClient\Tests\unit\EnumTestTrait; +use WordPress\AiClient\Tests\traits\EnumTestTrait; /** * @covers \WordPress\AiClient\Providers\Enums\ProviderTypeEnum diff --git a/tests/unit/Providers/Enums/ToolTypeEnumTest.php b/tests/unit/Providers/Enums/ToolTypeEnumTest.php index ddd1a19e..10d545b3 100644 --- a/tests/unit/Providers/Enums/ToolTypeEnumTest.php +++ b/tests/unit/Providers/Enums/ToolTypeEnumTest.php @@ -6,7 +6,7 @@ use PHPUnit\Framework\TestCase; use WordPress\AiClient\Providers\Enums\ToolTypeEnum; -use WordPress\AiClient\Tests\unit\EnumTestTrait; +use WordPress\AiClient\Tests\traits\EnumTestTrait; /** * @covers \WordPress\AiClient\Providers\Enums\ToolTypeEnum diff --git a/tests/unit/Providers/Models/Enums/CapabilityEnumTest.php b/tests/unit/Providers/Models/Enums/CapabilityEnumTest.php index fa1e0b0d..c0f006da 100644 --- a/tests/unit/Providers/Models/Enums/CapabilityEnumTest.php +++ b/tests/unit/Providers/Models/Enums/CapabilityEnumTest.php @@ -6,7 +6,7 @@ use PHPUnit\Framework\TestCase; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; -use WordPress\AiClient\Tests\unit\EnumTestTrait; +use WordPress\AiClient\Tests\traits\EnumTestTrait; /** * @covers \WordPress\AiClient\Providers\Models\Enums\CapabilityEnum diff --git a/tests/unit/Providers/Models/Enums/OptionEnumTest.php b/tests/unit/Providers/Models/Enums/OptionEnumTest.php index 68698982..06d40d73 100644 --- a/tests/unit/Providers/Models/Enums/OptionEnumTest.php +++ b/tests/unit/Providers/Models/Enums/OptionEnumTest.php @@ -6,7 +6,7 @@ use PHPUnit\Framework\TestCase; use WordPress\AiClient\Providers\Models\Enums\OptionEnum; -use WordPress\AiClient\Tests\unit\EnumTestTrait; +use WordPress\AiClient\Tests\traits\EnumTestTrait; /** * @covers \WordPress\AiClient\Providers\Models\Enums\OptionEnum diff --git a/tests/unit/Results/DTO/CandidateTest.php b/tests/unit/Results/DTO/CandidateTest.php index 7a1fcd75..d9875081 100644 --- a/tests/unit/Results/DTO/CandidateTest.php +++ b/tests/unit/Results/DTO/CandidateTest.php @@ -10,9 +10,11 @@ use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\ModelMessage; use WordPress\AiClient\Messages\DTO\UserMessage; +use WordPress\AiClient\Messages\Enums\MessagePartTypeEnum; use WordPress\AiClient\Messages\Enums\MessageRoleEnum; use WordPress\AiClient\Results\DTO\Candidate; use WordPress\AiClient\Results\Enums\FinishReasonEnum; +use WordPress\AiClient\Tests\traits\ArrayTransformationTestTrait; use WordPress\AiClient\Tools\DTO\FunctionCall; /** @@ -20,6 +22,7 @@ */ class CandidateTest extends TestCase { + use ArrayTransformationTestTrait; /** * Tests creating candidate with basic properties. * @@ -227,12 +230,12 @@ public function testJsonSchema(): void // Check properties $this->assertArrayHasKey('properties', $schema); - $this->assertArrayHasKey('message', $schema['properties']); - $this->assertArrayHasKey('finishReason', $schema['properties']); - $this->assertArrayHasKey('tokenCount', $schema['properties']); + $this->assertArrayHasKey(Candidate::KEY_MESSAGE, $schema['properties']); + $this->assertArrayHasKey(Candidate::KEY_FINISH_REASON, $schema['properties']); + $this->assertArrayHasKey(Candidate::KEY_TOKEN_COUNT, $schema['properties']); // Check finishReason property - $finishReasonSchema = $schema['properties']['finishReason']; + $finishReasonSchema = $schema['properties'][Candidate::KEY_FINISH_REASON]; $this->assertEquals('string', $finishReasonSchema['type']); $this->assertArrayHasKey('enum', $finishReasonSchema); $this->assertContains('stop', $finishReasonSchema['enum']); @@ -242,12 +245,12 @@ public function testJsonSchema(): void $this->assertContains('error', $finishReasonSchema['enum']); // Check tokenCount property - $tokenCountSchema = $schema['properties']['tokenCount']; + $tokenCountSchema = $schema['properties'][Candidate::KEY_TOKEN_COUNT]; $this->assertEquals('integer', $tokenCountSchema['type']); // Check required fields $this->assertArrayHasKey('required', $schema); - $this->assertEquals(['message', 'finishReason', 'tokenCount'], $schema['required']); + $this->assertEquals([Candidate::KEY_MESSAGE, Candidate::KEY_FINISH_REASON, Candidate::KEY_TOKEN_COUNT], $schema['required']); } /** @@ -329,4 +332,109 @@ public function testWithErrorFinishReason(): void $this->assertTrue($candidate->getFinishReason()->isError()); } + + /** + * Tests array transformation. + * + * @return void + */ + public function testToArray(): void + { + $message = new ModelMessage([ + new MessagePart('This is the AI response.'), + new MessagePart('It contains multiple parts.') + ]); + + $candidate = new Candidate( + $message, + FinishReasonEnum::stop(), + 45 + ); + + $json = $this->assertToArrayReturnsArray($candidate); + + $this->assertArrayHasKeys($json, [Candidate::KEY_MESSAGE, Candidate::KEY_FINISH_REASON, Candidate::KEY_TOKEN_COUNT]); + $this->assertIsArray($json[Candidate::KEY_MESSAGE]); + $this->assertEquals(FinishReasonEnum::stop()->value, $json[Candidate::KEY_FINISH_REASON]); + $this->assertEquals(45, $json[Candidate::KEY_TOKEN_COUNT]); + } + + /** + * Tests fromJson method. + * + * @return void + */ + public function testFromArray(): void + { + $json = [ + Candidate::KEY_MESSAGE => [ + Message::KEY_ROLE => MessageRoleEnum::model()->value, + Message::KEY_PARTS => [ + [MessagePart::KEY_TYPE => MessagePartTypeEnum::text()->value, MessagePart::KEY_TEXT => 'Response text 1'], + [MessagePart::KEY_TYPE => MessagePartTypeEnum::text()->value, MessagePart::KEY_TEXT => 'Response text 2'] + ] + ], + Candidate::KEY_FINISH_REASON => FinishReasonEnum::stop()->value, + Candidate::KEY_TOKEN_COUNT => 75 + ]; + + $candidate = Candidate::fromArray($json); + + $this->assertInstanceOf(Candidate::class, $candidate); + $this->assertEquals(FinishReasonEnum::stop(), $candidate->getFinishReason()); + $this->assertEquals(75, $candidate->getTokenCount()); + $this->assertCount(2, $candidate->getMessage()->getParts()); + $this->assertEquals('Response text 1', $candidate->getMessage()->getParts()[0]->getText()); + $this->assertEquals('Response text 2', $candidate->getMessage()->getParts()[1]->getText()); + } + + /** + * Tests round-trip array transformation. + * + * @return void + */ + public function testArrayRoundTrip(): void + { + $this->assertArrayRoundTrip( + new Candidate( + new ModelMessage([ + new MessagePart('Generated response'), + new MessagePart(new FunctionCall('call_123', 'search', ['q' => 'test'])) + ]), + FinishReasonEnum::toolCalls(), + 120 + ), + function ($original, $restored) { + $this->assertEquals($original->getFinishReason()->value, $restored->getFinishReason()->value); + $this->assertEquals($original->getTokenCount(), $restored->getTokenCount()); + $this->assertCount( + count($original->getMessage()->getParts()), + $restored->getMessage()->getParts() + ); + $this->assertEquals( + $original->getMessage()->getParts()[0]->getText(), + $restored->getMessage()->getParts()[0]->getText() + ); + $this->assertEquals( + $original->getMessage()->getParts()[1]->getFunctionCall()->getId(), + $restored->getMessage()->getParts()[1]->getFunctionCall()->getId() + ); + } + ); + } + + /** + * Tests Candidate implements WithArrayTransformationInterface. + * + * @return void + */ + public function testImplementsWithArrayTransformationInterface(): void + { + $candidate = new Candidate( + new ModelMessage([new MessagePart('test')]), + FinishReasonEnum::stop(), + 10 + ); + $this->assertImplementsArrayTransformation($candidate); + } } \ No newline at end of file diff --git a/tests/unit/Results/DTO/GenerativeAiResultTest.php b/tests/unit/Results/DTO/GenerativeAiResultTest.php index 61d67580..35772b55 100644 --- a/tests/unit/Results/DTO/GenerativeAiResultTest.php +++ b/tests/unit/Results/DTO/GenerativeAiResultTest.php @@ -6,18 +6,24 @@ use PHPUnit\Framework\TestCase; use WordPress\AiClient\Files\DTO\File; +use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\ModelMessage; +use WordPress\AiClient\Messages\Enums\MessagePartTypeEnum; +use WordPress\AiClient\Messages\Enums\MessageRoleEnum; use WordPress\AiClient\Results\DTO\Candidate; use WordPress\AiClient\Results\DTO\GenerativeAiResult; use WordPress\AiClient\Results\DTO\TokenUsage; use WordPress\AiClient\Results\Enums\FinishReasonEnum; +use WordPress\AiClient\Tests\traits\ArrayTransformationTestTrait; +use WordPress\AiClient\Tools\DTO\FunctionCall; /** * @covers \WordPress\AiClient\Results\DTO\GenerativeAiResult */ class GenerativeAiResultTest extends TestCase { + use ArrayTransformationTestTrait; /** * Tests creating result with single candidate. * @@ -525,30 +531,30 @@ public function testJsonSchema(): void // Check properties $this->assertArrayHasKey('properties', $schema); - $this->assertArrayHasKey('id', $schema['properties']); - $this->assertArrayHasKey('candidates', $schema['properties']); - $this->assertArrayHasKey('tokenUsage', $schema['properties']); - $this->assertArrayHasKey('providerMetadata', $schema['properties']); + $this->assertArrayHasKey(GenerativeAiResult::KEY_ID, $schema['properties']); + $this->assertArrayHasKey(GenerativeAiResult::KEY_CANDIDATES, $schema['properties']); + $this->assertArrayHasKey(GenerativeAiResult::KEY_TOKEN_USAGE, $schema['properties']); + $this->assertArrayHasKey(GenerativeAiResult::KEY_PROVIDER_METADATA, $schema['properties']); // Check id property - $this->assertEquals('string', $schema['properties']['id']['type']); + $this->assertEquals('string', $schema['properties'][GenerativeAiResult::KEY_ID]['type']); // Check candidates property - $candidatesSchema = $schema['properties']['candidates']; + $candidatesSchema = $schema['properties'][GenerativeAiResult::KEY_CANDIDATES]; $this->assertEquals('array', $candidatesSchema['type']); $this->assertEquals(1, $candidatesSchema['minItems']); // Check providerMetadata property - $metadataSchema = $schema['properties']['providerMetadata']; + $metadataSchema = $schema['properties'][GenerativeAiResult::KEY_PROVIDER_METADATA]; $this->assertEquals('object', $metadataSchema['type']); $this->assertTrue($metadataSchema['additionalProperties']); // Check required fields $this->assertArrayHasKey('required', $schema); - $this->assertContains('id', $schema['required']); - $this->assertContains('candidates', $schema['required']); - $this->assertContains('tokenUsage', $schema['required']); - $this->assertNotContains('providerMetadata', $schema['required']); + $this->assertContains(GenerativeAiResult::KEY_ID, $schema['required']); + $this->assertContains(GenerativeAiResult::KEY_CANDIDATES, $schema['required']); + $this->assertContains(GenerativeAiResult::KEY_TOKEN_USAGE, $schema['required']); + $this->assertNotContains(GenerativeAiResult::KEY_PROVIDER_METADATA, $schema['required']); } /** @@ -594,4 +600,160 @@ public function testHasMultipleCandidatesReturnsFalseForSingle(): void $this->assertFalse($result->hasMultipleCandidates()); $this->assertEquals(1, $result->getCandidateCount()); } + + /** + * Tests array transformation. + * + * @return void + */ + public function testToArray(): void + { + $message = new ModelMessage([ + new MessagePart('AI generated response'), + new MessagePart('with multiple parts') + ]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 15); + $tokenUsage = new TokenUsage(10, 15, 25); + $metadata = ['model' => 'test-model', 'version' => '1.0']; + + $result = new GenerativeAiResult( + 'result_json_123', + [$candidate], + $tokenUsage, + $metadata + ); + + $json = $this->assertToArrayReturnsArray($result); + + $this->assertArrayHasKeys($json, [GenerativeAiResult::KEY_ID, GenerativeAiResult::KEY_CANDIDATES, GenerativeAiResult::KEY_TOKEN_USAGE, GenerativeAiResult::KEY_PROVIDER_METADATA]); + $this->assertEquals('result_json_123', $json[GenerativeAiResult::KEY_ID]); + $this->assertIsArray($json[GenerativeAiResult::KEY_CANDIDATES]); + $this->assertCount(1, $json[GenerativeAiResult::KEY_CANDIDATES]); + $this->assertIsArray($json[GenerativeAiResult::KEY_TOKEN_USAGE]); + $this->assertEquals($metadata, $json[GenerativeAiResult::KEY_PROVIDER_METADATA]); + } + + /** + * Tests fromJson method. + * + * @return void + */ + public function testFromArray(): void + { + $json = [ + GenerativeAiResult::KEY_ID => 'result_from_json', + GenerativeAiResult::KEY_CANDIDATES => [ + [ + Candidate::KEY_MESSAGE => [ + Message::KEY_ROLE => MessageRoleEnum::model()->value, + Message::KEY_PARTS => [ + [MessagePart::KEY_TYPE => MessagePartTypeEnum::text()->value, MessagePart::KEY_TEXT => 'First part'], + [MessagePart::KEY_TYPE => MessagePartTypeEnum::text()->value, MessagePart::KEY_TEXT => 'Second part'] + ] + ], + Candidate::KEY_FINISH_REASON => FinishReasonEnum::stop()->value, + Candidate::KEY_TOKEN_COUNT => 20 + ] + ], + GenerativeAiResult::KEY_TOKEN_USAGE => [ + TokenUsage::KEY_PROMPT_TOKENS => 8, + TokenUsage::KEY_COMPLETION_TOKENS => 20, + TokenUsage::KEY_TOTAL_TOKENS => 28 + ], + GenerativeAiResult::KEY_PROVIDER_METADATA => ['provider' => 'test'] + ]; + + $result = GenerativeAiResult::fromArray($json); + + $this->assertInstanceOf(GenerativeAiResult::class, $result); + $this->assertEquals('result_from_json', $result->getId()); + $this->assertCount(1, $result->getCandidates()); + $this->assertEquals(8, $result->getTokenUsage()->getPromptTokens()); + $this->assertEquals(20, $result->getTokenUsage()->getCompletionTokens()); + $this->assertEquals(28, $result->getTokenUsage()->getTotalTokens()); + $this->assertEquals(['provider' => 'test'], $result->getProviderMetadata()); + } + + /** + * Tests round-trip array transformation with multiple candidates. + * + * @return void + */ + public function testArrayRoundTripWithMultipleCandidates(): void + { + $candidates = []; + for ($i = 1; $i <= 2; $i++) { + $message = new ModelMessage([ + new MessagePart("Response $i"), + new MessagePart(new FunctionCall("call_$i", "func$i", ['arg' => $i])) + ]); + $candidates[] = new Candidate($message, FinishReasonEnum::toolCalls(), 25 * $i); + } + + $this->assertArrayRoundTrip( + new GenerativeAiResult( + 'result_roundtrip', + $candidates, + new TokenUsage(30, 75, 105), + ['test_meta' => true] + ), + function ($original, $restored) { + $this->assertEquals($original->getId(), $restored->getId()); + $this->assertCount(count($original->getCandidates()), $restored->getCandidates()); + $this->assertEquals($original->getTokenUsage()->getTotalTokens(), + $restored->getTokenUsage()->getTotalTokens()); + $this->assertEquals($original->getProviderMetadata(), $restored->getProviderMetadata()); + + // Check first candidate details + $originalFirst = $original->getCandidates()[0]; + $restoredFirst = $restored->getCandidates()[0]; + $this->assertEquals( + $originalFirst->getMessage()->getParts()[0]->getText(), + $restoredFirst->getMessage()->getParts()[0]->getText() + ); + $this->assertEquals( + $originalFirst->getMessage()->getParts()[1]->getFunctionCall()->getId(), + $restoredFirst->getMessage()->getParts()[1]->getFunctionCall()->getId() + ); + } + ); + } + + /** + * Tests array transformation without provider metadata. + * + * @return void + */ + public function testToArrayWithoutProviderMetadata(): void + { + $message = new ModelMessage([new MessagePart('Simple response')]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 5); + $tokenUsage = new TokenUsage(3, 5, 8); + + $result = new GenerativeAiResult( + 'result_no_meta', + [$candidate], + $tokenUsage + ); + + $json = $this->assertToArrayReturnsArray($result); + + $this->assertArrayHasKeys($json, [GenerativeAiResult::KEY_ID, GenerativeAiResult::KEY_CANDIDATES, GenerativeAiResult::KEY_TOKEN_USAGE, GenerativeAiResult::KEY_PROVIDER_METADATA]); + $this->assertEquals([], $json[GenerativeAiResult::KEY_PROVIDER_METADATA]); + } + + /** + * Tests GenerativeAiResult implements WithArrayTransformationInterface. + * + * @return void + */ + public function testImplementsWithArrayTransformationInterface(): void + { + $message = new ModelMessage([new MessagePart('test')]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 1); + $tokenUsage = new TokenUsage(1, 1, 2); + + $result = new GenerativeAiResult('test', [$candidate], $tokenUsage); + $this->assertImplementsArrayTransformation($result); + } } \ No newline at end of file diff --git a/tests/unit/Results/DTO/TokenUsageTest.php b/tests/unit/Results/DTO/TokenUsageTest.php index af299c1f..7e8f7051 100644 --- a/tests/unit/Results/DTO/TokenUsageTest.php +++ b/tests/unit/Results/DTO/TokenUsageTest.php @@ -107,23 +107,23 @@ public function testJsonSchema(): void // Check properties $this->assertArrayHasKey('properties', $schema); - $this->assertArrayHasKey('promptTokens', $schema['properties']); - $this->assertArrayHasKey('completionTokens', $schema['properties']); - $this->assertArrayHasKey('totalTokens', $schema['properties']); + $this->assertArrayHasKey(TokenUsage::KEY_PROMPT_TOKENS, $schema['properties']); + $this->assertArrayHasKey(TokenUsage::KEY_COMPLETION_TOKENS, $schema['properties']); + $this->assertArrayHasKey(TokenUsage::KEY_TOTAL_TOKENS, $schema['properties']); // Check each property type - $this->assertEquals('integer', $schema['properties']['promptTokens']['type']); - $this->assertEquals('integer', $schema['properties']['completionTokens']['type']); - $this->assertEquals('integer', $schema['properties']['totalTokens']['type']); + $this->assertEquals('integer', $schema['properties'][TokenUsage::KEY_PROMPT_TOKENS]['type']); + $this->assertEquals('integer', $schema['properties'][TokenUsage::KEY_COMPLETION_TOKENS]['type']); + $this->assertEquals('integer', $schema['properties'][TokenUsage::KEY_TOTAL_TOKENS]['type']); // Check descriptions - $this->assertArrayHasKey('description', $schema['properties']['promptTokens']); - $this->assertArrayHasKey('description', $schema['properties']['completionTokens']); - $this->assertArrayHasKey('description', $schema['properties']['totalTokens']); + $this->assertArrayHasKey('description', $schema['properties'][TokenUsage::KEY_PROMPT_TOKENS]); + $this->assertArrayHasKey('description', $schema['properties'][TokenUsage::KEY_COMPLETION_TOKENS]); + $this->assertArrayHasKey('description', $schema['properties'][TokenUsage::KEY_TOTAL_TOKENS]); // Check required fields $this->assertArrayHasKey('required', $schema); - $this->assertEquals(['promptTokens', 'completionTokens', 'totalTokens'], $schema['required']); + $this->assertEquals([TokenUsage::KEY_PROMPT_TOKENS, TokenUsage::KEY_COMPLETION_TOKENS, TokenUsage::KEY_TOTAL_TOKENS], $schema['required']); } /** @@ -208,6 +208,79 @@ public function testMultipleInstances(): void $this->assertEquals($usage1->getTotalTokens(), $usage3->getTotalTokens()); } + /** + * Tests array transformation. + * + * @return void + */ + public function testToArray(): void + { + $tokenUsage = new TokenUsage(100, 50, 150); + $json = $tokenUsage->toArray(); + + $this->assertIsArray($json); + $this->assertArrayHasKey(TokenUsage::KEY_PROMPT_TOKENS, $json); + $this->assertArrayHasKey(TokenUsage::KEY_COMPLETION_TOKENS, $json); + $this->assertArrayHasKey(TokenUsage::KEY_TOTAL_TOKENS, $json); + + $this->assertEquals(100, $json[TokenUsage::KEY_PROMPT_TOKENS]); + $this->assertEquals(50, $json[TokenUsage::KEY_COMPLETION_TOKENS]); + $this->assertEquals(150, $json[TokenUsage::KEY_TOTAL_TOKENS]); + } + + /** + * Tests fromJson method. + * + * @return void + */ + public function testFromArray(): void + { + $json = [ + TokenUsage::KEY_PROMPT_TOKENS => 100, + TokenUsage::KEY_COMPLETION_TOKENS => 50, + TokenUsage::KEY_TOTAL_TOKENS => 150, + ]; + + $tokenUsage = TokenUsage::fromArray($json); + + $this->assertInstanceOf(TokenUsage::class, $tokenUsage); + $this->assertEquals(100, $tokenUsage->getPromptTokens()); + $this->assertEquals(50, $tokenUsage->getCompletionTokens()); + $this->assertEquals(150, $tokenUsage->getTotalTokens()); + } + + /** + * Tests round-trip array transformation. + * + * @return void + */ + public function testArrayRoundTrip(): void + { + $original = new TokenUsage(123, 456, 579); + $json = $original->toArray(); + $restored = TokenUsage::fromArray($json); + + $this->assertEquals($original->getPromptTokens(), $restored->getPromptTokens()); + $this->assertEquals($original->getCompletionTokens(), $restored->getCompletionTokens()); + $this->assertEquals($original->getTotalTokens(), $restored->getTotalTokens()); + } + + /** + * Tests TokenUsage implements WithArrayTransformationInterface. + * + * @return void + */ + public function testImplementsWithArrayTransformationInterface(): void + { + $tokenUsage = new TokenUsage(10, 20, 30); + + $this->assertInstanceOf( + \WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface::class, + $tokenUsage + ); + + } + /** * Tests TokenUsage with streaming response simulation. * diff --git a/tests/unit/Results/Enums/FinishReasonEnumTest.php b/tests/unit/Results/Enums/FinishReasonEnumTest.php index 48832032..979574bc 100644 --- a/tests/unit/Results/Enums/FinishReasonEnumTest.php +++ b/tests/unit/Results/Enums/FinishReasonEnumTest.php @@ -6,7 +6,7 @@ use PHPUnit\Framework\TestCase; use WordPress\AiClient\Results\Enums\FinishReasonEnum; -use WordPress\AiClient\Tests\unit\EnumTestTrait; +use WordPress\AiClient\Tests\traits\EnumTestTrait; /** * @covers \WordPress\AiClient\Results\Enums\FinishReasonEnum diff --git a/tests/unit/Tools/DTO/FunctionCallTest.php b/tests/unit/Tools/DTO/FunctionCallTest.php index 0a387515..9bfe0b1f 100644 --- a/tests/unit/Tools/DTO/FunctionCallTest.php +++ b/tests/unit/Tools/DTO/FunctionCallTest.php @@ -105,34 +105,31 @@ public function testJsonSchema(): void // Check properties $this->assertArrayHasKey('properties', $schema); - $this->assertArrayHasKey('id', $schema['properties']); - $this->assertArrayHasKey('name', $schema['properties']); - $this->assertArrayHasKey('args', $schema['properties']); + $this->assertArrayHasKey(FunctionCall::KEY_ID, $schema['properties']); + $this->assertArrayHasKey(FunctionCall::KEY_NAME, $schema['properties']); + $this->assertArrayHasKey(FunctionCall::KEY_ARGS, $schema['properties']); // Check id property - $this->assertEquals('string', $schema['properties']['id']['type']); - $this->assertArrayHasKey('description', $schema['properties']['id']); + $this->assertEquals('string', $schema['properties'][FunctionCall::KEY_ID]['type']); + $this->assertArrayHasKey('description', $schema['properties'][FunctionCall::KEY_ID]); // Check name property - $this->assertEquals('string', $schema['properties']['name']['type']); - $this->assertArrayHasKey('description', $schema['properties']['name']); + $this->assertEquals('string', $schema['properties'][FunctionCall::KEY_NAME]['type']); + $this->assertArrayHasKey('description', $schema['properties'][FunctionCall::KEY_NAME]); // Check args property - $this->assertEquals('object', $schema['properties']['args']['type']); - $this->assertTrue($schema['properties']['args']['additionalProperties']); + $this->assertEquals('object', $schema['properties'][FunctionCall::KEY_ARGS]['type']); + $this->assertTrue($schema['properties'][FunctionCall::KEY_ARGS]['additionalProperties']); // Check oneOf for required fields $this->assertArrayHasKey('oneOf', $schema); - $this->assertCount(3, $schema['oneOf']); + $this->assertCount(2, $schema['oneOf']); // First option: only id required - $this->assertEquals(['id'], $schema['oneOf'][0]['required']); + $this->assertEquals([FunctionCall::KEY_ID], $schema['oneOf'][0]['required']); // Second option: only name required - $this->assertEquals(['name'], $schema['oneOf'][1]['required']); - - // Third option: both id and name required - $this->assertEquals(['id', 'name'], $schema['oneOf'][2]['required']); + $this->assertEquals([FunctionCall::KEY_NAME], $schema['oneOf'][1]['required']); } /** @@ -161,4 +158,122 @@ public function testWithComplexArgs(): void $this->assertEquals($args, $functionCall->getArgs()); } + + /** + * Tests array transformation with all fields. + * + * @return void + */ + public function testToArrayAllFields(): void + { + $functionCall = new FunctionCall('func_123', 'calculate', ['x' => 10, 'y' => 20]); + $json = $functionCall->toArray(); + + $this->assertIsArray($json); + $this->assertEquals('func_123', $json[FunctionCall::KEY_ID]); + $this->assertEquals('calculate', $json[FunctionCall::KEY_NAME]); + $this->assertEquals(['x' => 10, 'y' => 20], $json[FunctionCall::KEY_ARGS]); + } + + /** + * Tests array transformation with only ID. + * + * @return void + */ + public function testToArrayOnlyId(): void + { + $functionCall = new FunctionCall('func_456', null); + $json = $functionCall->toArray(); + + $this->assertIsArray($json); + $this->assertEquals('func_456', $json[FunctionCall::KEY_ID]); + $this->assertArrayNotHasKey(FunctionCall::KEY_NAME, $json); + $this->assertArrayNotHasKey(FunctionCall::KEY_ARGS, $json); + } + + /** + * Tests array transformation with only name. + * + * @return void + */ + public function testToArrayOnlyName(): void + { + $functionCall = new FunctionCall(null, 'search'); + $json = $functionCall->toArray(); + + $this->assertIsArray($json); + $this->assertEquals('search', $json[FunctionCall::KEY_NAME]); + $this->assertArrayNotHasKey(FunctionCall::KEY_ID, $json); + $this->assertArrayNotHasKey(FunctionCall::KEY_ARGS, $json); + } + + /** + * Tests fromJson with all fields. + * + * @return void + */ + public function testFromArrayAllFields(): void + { + $json = [ + FunctionCall::KEY_ID => 'func_789', + FunctionCall::KEY_NAME => 'process', + FunctionCall::KEY_ARGS => ['input' => 'data', 'format' => 'json'] + ]; + + $functionCall = FunctionCall::fromArray($json); + + $this->assertInstanceOf(FunctionCall::class, $functionCall); + $this->assertEquals('func_789', $functionCall->getId()); + $this->assertEquals('process', $functionCall->getName()); + $this->assertEquals(['input' => 'data', 'format' => 'json'], $functionCall->getArgs()); + } + + /** + * Tests fromJson with minimal fields. + * + * @return void + */ + public function testFromArrayMinimalFields(): void + { + $json = [FunctionCall::KEY_NAME => 'minimal']; + + $functionCall = FunctionCall::fromArray($json); + + $this->assertInstanceOf(FunctionCall::class, $functionCall); + $this->assertNull($functionCall->getId()); + $this->assertEquals('minimal', $functionCall->getName()); + $this->assertEquals([], $functionCall->getArgs()); + } + + /** + * Tests round-trip array transformation. + * + * @return void + */ + public function testArrayRoundTrip(): void + { + $original = new FunctionCall('id_123', 'execute', ['param' => 'value', 'count' => 5]); + $json = $original->toArray(); + $restored = FunctionCall::fromArray($json); + + $this->assertEquals($original->getId(), $restored->getId()); + $this->assertEquals($original->getName(), $restored->getName()); + $this->assertEquals($original->getArgs(), $restored->getArgs()); + } + + /** + * Tests FunctionCall implements WithArrayTransformationInterface. + * + * @return void + */ + public function testImplementsWithArrayTransformationInterface(): void + { + $functionCall = new FunctionCall('id', 'name'); + + $this->assertInstanceOf( + \WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface::class, + $functionCall + ); + + } } \ No newline at end of file diff --git a/tests/unit/Tools/DTO/FunctionDeclarationTest.php b/tests/unit/Tools/DTO/FunctionDeclarationTest.php index b5a2f3d7..cc62d8cc 100644 --- a/tests/unit/Tools/DTO/FunctionDeclarationTest.php +++ b/tests/unit/Tools/DTO/FunctionDeclarationTest.php @@ -5,6 +5,7 @@ namespace WordPress\AiClient\Tests\unit\Tools\DTO; use PHPUnit\Framework\TestCase; +use WordPress\AiClient\Tests\traits\ArrayTransformationTestTrait; use WordPress\AiClient\Tools\DTO\FunctionDeclaration; /** @@ -12,6 +13,7 @@ */ class FunctionDeclarationTest extends TestCase { + use ArrayTransformationTestTrait; /** * Tests creating FunctionDeclaration with all properties. * @@ -111,20 +113,20 @@ public function testJsonSchema(): void // Check properties $this->assertArrayHasKey('properties', $schema); - $this->assertArrayHasKey('name', $schema['properties']); - $this->assertArrayHasKey('description', $schema['properties']); - $this->assertArrayHasKey('parameters', $schema['properties']); + $this->assertArrayHasKey(FunctionDeclaration::KEY_NAME, $schema['properties']); + $this->assertArrayHasKey(FunctionDeclaration::KEY_DESCRIPTION, $schema['properties']); + $this->assertArrayHasKey(FunctionDeclaration::KEY_PARAMETERS, $schema['properties']); // Check name property - $this->assertEquals('string', $schema['properties']['name']['type']); - $this->assertArrayHasKey('description', $schema['properties']['name']); + $this->assertEquals('string', $schema['properties'][FunctionDeclaration::KEY_NAME]['type']); + $this->assertArrayHasKey('description', $schema['properties'][FunctionDeclaration::KEY_NAME]); // Check description property - $this->assertEquals('string', $schema['properties']['description']['type']); - $this->assertArrayHasKey('description', $schema['properties']['description']); + $this->assertEquals('string', $schema['properties'][FunctionDeclaration::KEY_DESCRIPTION]['type']); + $this->assertArrayHasKey('description', $schema['properties'][FunctionDeclaration::KEY_DESCRIPTION]); // Check parameters property allows multiple types - $paramTypes = $schema['properties']['parameters']['type']; + $paramTypes = $schema['properties'][FunctionDeclaration::KEY_PARAMETERS]['type']; $this->assertIsArray($paramTypes); $this->assertContains('string', $paramTypes); $this->assertContains('number', $paramTypes); @@ -135,8 +137,8 @@ public function testJsonSchema(): void // Check required fields - parameters should NOT be required $this->assertArrayHasKey('required', $schema); - $this->assertEquals(['name', 'description'], $schema['required']); - $this->assertNotContains('parameters', $schema['required']); + $this->assertEquals([FunctionDeclaration::KEY_NAME, FunctionDeclaration::KEY_DESCRIPTION], $schema['required']); + $this->assertNotContains(FunctionDeclaration::KEY_PARAMETERS, $schema['required']); } /** @@ -185,4 +187,140 @@ public function testWithOpenApiStyleSchema(): void $this->assertEquals($parameters, $declaration->getParameters()); } + + /** + * Tests array transformation with parameters. + * + * @return void + */ + public function testToArrayWithParameters(): void + { + $declaration = new FunctionDeclaration( + 'searchWeb', + 'Searches the web for information', + ['type' => 'object', 'properties' => ['query' => ['type' => 'string']]] + ); + + $json = $this->assertToArrayReturnsArray($declaration); + + $this->assertArrayHasKeys($json, [FunctionDeclaration::KEY_NAME, FunctionDeclaration::KEY_DESCRIPTION, FunctionDeclaration::KEY_PARAMETERS]); + $this->assertEquals('searchWeb', $json[FunctionDeclaration::KEY_NAME]); + $this->assertEquals('Searches the web for information', $json[FunctionDeclaration::KEY_DESCRIPTION]); + $this->assertEquals(['type' => 'object', 'properties' => ['query' => ['type' => 'string']]], $json[FunctionDeclaration::KEY_PARAMETERS]); + } + + /** + * Tests array transformation without parameters. + * + * @return void + */ + public function testToArrayWithoutParameters(): void + { + $declaration = new FunctionDeclaration( + 'getTimestamp', + 'Returns the current Unix timestamp' + ); + + $json = $this->assertToArrayReturnsArray($declaration); + + $this->assertArrayHasKeys($json, [FunctionDeclaration::KEY_NAME, FunctionDeclaration::KEY_DESCRIPTION]); + $this->assertArrayNotHasKey(FunctionDeclaration::KEY_PARAMETERS, $json); + $this->assertEquals('getTimestamp', $json[FunctionDeclaration::KEY_NAME]); + $this->assertEquals('Returns the current Unix timestamp', $json[FunctionDeclaration::KEY_DESCRIPTION]); + } + + /** + * Tests fromJson method with parameters. + * + * @return void + */ + public function testFromArrayWithParameters(): void + { + $json = [ + FunctionDeclaration::KEY_NAME => 'calculateArea', + FunctionDeclaration::KEY_DESCRIPTION => 'Calculates the area of a rectangle', + FunctionDeclaration::KEY_PARAMETERS => [ + 'type' => 'object', + 'properties' => [ + 'width' => ['type' => 'number'], + 'height' => ['type' => 'number'] + ], + 'required' => ['width', 'height'] + ] + ]; + + $declaration = FunctionDeclaration::fromArray($json); + + $this->assertInstanceOf(FunctionDeclaration::class, $declaration); + $this->assertEquals('calculateArea', $declaration->getName()); + $this->assertEquals('Calculates the area of a rectangle', $declaration->getDescription()); + $this->assertEquals($json[FunctionDeclaration::KEY_PARAMETERS], $declaration->getParameters()); + } + + /** + * Tests fromJson method without parameters. + * + * @return void + */ + public function testFromArrayWithoutParameters(): void + { + $json = [ + FunctionDeclaration::KEY_NAME => 'ping', + FunctionDeclaration::KEY_DESCRIPTION => 'Simple ping function' + ]; + + $declaration = FunctionDeclaration::fromArray($json); + + $this->assertInstanceOf(FunctionDeclaration::class, $declaration); + $this->assertEquals('ping', $declaration->getName()); + $this->assertEquals('Simple ping function', $declaration->getDescription()); + $this->assertNull($declaration->getParameters()); + } + + /** + * Tests round-trip array transformation. + * + * @return void + */ + public function testArrayRoundTrip(): void + { + $this->assertArrayRoundTrip( + new FunctionDeclaration( + 'complexFunction', + 'A complex function with nested parameters', + [ + 'type' => 'object', + 'properties' => [ + 'user' => [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string'], + 'age' => ['type' => 'integer', 'minimum' => 0] + ] + ], + 'options' => [ + 'type' => 'array', + 'items' => ['type' => 'string'] + ] + ] + ] + ), + function ($original, $restored) { + $this->assertEquals($original->getName(), $restored->getName()); + $this->assertEquals($original->getDescription(), $restored->getDescription()); + $this->assertEquals($original->getParameters(), $restored->getParameters()); + } + ); + } + + /** + * Tests FunctionDeclaration implements WithArrayTransformationInterface. + * + * @return void + */ + public function testImplementsWithArrayTransformationInterface(): void + { + $declaration = new FunctionDeclaration('test', 'test function'); + $this->assertImplementsArrayTransformation($declaration); + } } \ No newline at end of file diff --git a/tests/unit/Tools/DTO/FunctionResponseTest.php b/tests/unit/Tools/DTO/FunctionResponseTest.php index 20180ab9..ad7b3927 100644 --- a/tests/unit/Tools/DTO/FunctionResponseTest.php +++ b/tests/unit/Tools/DTO/FunctionResponseTest.php @@ -5,6 +5,7 @@ namespace WordPress\AiClient\Tests\unit\Tools\DTO; use PHPUnit\Framework\TestCase; +use WordPress\AiClient\Tests\traits\ArrayTransformationTestTrait; use WordPress\AiClient\Tools\DTO\FunctionResponse; /** @@ -12,6 +13,8 @@ */ class FunctionResponseTest extends TestCase { + use ArrayTransformationTestTrait; + /** * Tests creating FunctionResponse with all properties. * @@ -97,20 +100,20 @@ public function testJsonSchema(): void // Check properties $this->assertArrayHasKey('properties', $schema); - $this->assertArrayHasKey('id', $schema['properties']); - $this->assertArrayHasKey('name', $schema['properties']); - $this->assertArrayHasKey('response', $schema['properties']); + $this->assertArrayHasKey(FunctionResponse::KEY_ID, $schema['properties']); + $this->assertArrayHasKey(FunctionResponse::KEY_NAME, $schema['properties']); + $this->assertArrayHasKey(FunctionResponse::KEY_RESPONSE, $schema['properties']); // Check id property - $this->assertEquals('string', $schema['properties']['id']['type']); - $this->assertArrayHasKey('description', $schema['properties']['id']); + $this->assertEquals('string', $schema['properties'][FunctionResponse::KEY_ID]['type']); + $this->assertArrayHasKey('description', $schema['properties'][FunctionResponse::KEY_ID]); // Check name property - $this->assertEquals('string', $schema['properties']['name']['type']); - $this->assertArrayHasKey('description', $schema['properties']['name']); + $this->assertEquals('string', $schema['properties'][FunctionResponse::KEY_NAME]['type']); + $this->assertArrayHasKey('description', $schema['properties'][FunctionResponse::KEY_NAME]); // Check response property allows multiple types - $responseTypes = $schema['properties']['response']['type']; + $responseTypes = $schema['properties'][FunctionResponse::KEY_RESPONSE]['type']; $this->assertIsArray($responseTypes); $this->assertContains('string', $responseTypes); $this->assertContains('number', $responseTypes); @@ -119,9 +122,15 @@ public function testJsonSchema(): void $this->assertContains('array', $responseTypes); $this->assertContains('null', $responseTypes); - // Check required fields - $this->assertArrayHasKey('required', $schema); - $this->assertEquals(['id', 'name', 'response'], $schema['required']); + // Check oneOf for required fields + $this->assertArrayHasKey('oneOf', $schema); + $this->assertCount(2, $schema['oneOf']); + + // First option: response and id required + $this->assertEquals([FunctionResponse::KEY_RESPONSE, FunctionResponse::KEY_ID], $schema['oneOf'][0]['required']); + + // Second option: response and name required + $this->assertEquals([FunctionResponse::KEY_RESPONSE, FunctionResponse::KEY_NAME], $schema['oneOf'][1]['required']); } /** @@ -180,4 +189,69 @@ public function testWithLargeResponseData(): void $this->assertEquals($largeData, $response->getResponse()); $this->assertCount(1000, $response->getResponse()); } + + /** + * Tests array transformation. + * + * @return void + */ + public function testToArray(): void + { + $response = new FunctionResponse('func_123', 'calculate', ['result' => 42]); + $json = $this->assertToArrayReturnsArray($response); + + $this->assertArrayHasKeys($json, [FunctionResponse::KEY_ID, FunctionResponse::KEY_NAME, FunctionResponse::KEY_RESPONSE]); + $this->assertEquals('func_123', $json[FunctionResponse::KEY_ID]); + $this->assertEquals('calculate', $json[FunctionResponse::KEY_NAME]); + $this->assertEquals(['result' => 42], $json[FunctionResponse::KEY_RESPONSE]); + } + + /** + * Tests fromJson method. + * + * @return void + */ + public function testFromArray(): void + { + $json = [ + FunctionResponse::KEY_ID => 'func_456', + FunctionResponse::KEY_NAME => 'search', + FunctionResponse::KEY_RESPONSE => ['found' => true, 'count' => 5] + ]; + + $response = FunctionResponse::fromArray($json); + + $this->assertInstanceOf(FunctionResponse::class, $response); + $this->assertEquals('func_456', $response->getId()); + $this->assertEquals('search', $response->getName()); + $this->assertEquals(['found' => true, 'count' => 5], $response->getResponse()); + } + + /** + * Tests round-trip array transformation. + * + * @return void + */ + public function testArrayRoundTrip(): void + { + $this->assertArrayRoundTrip( + new FunctionResponse('id_789', 'process', ['status' => 'complete']), + function ($original, $restored) { + $this->assertEquals($original->getId(), $restored->getId()); + $this->assertEquals($original->getName(), $restored->getName()); + $this->assertEquals($original->getResponse(), $restored->getResponse()); + } + ); + } + + /** + * Tests FunctionResponse implements WithArrayTransformationInterface. + * + * @return void + */ + public function testImplementsWithArrayTransformationInterface(): void + { + $response = new FunctionResponse('id', 'name', 'result'); + $this->assertImplementsArrayTransformation($response); + } } \ No newline at end of file diff --git a/tests/unit/Tools/DTO/ToolTest.php b/tests/unit/Tools/DTO/ToolTest.php index 74058ade..9ebf2833 100644 --- a/tests/unit/Tools/DTO/ToolTest.php +++ b/tests/unit/Tools/DTO/ToolTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use WordPress\AiClient\Providers\Enums\ToolTypeEnum; +use WordPress\AiClient\Tests\traits\ArrayTransformationTestTrait; use WordPress\AiClient\Tools\DTO\FunctionDeclaration; use WordPress\AiClient\Tools\DTO\Tool; use WordPress\AiClient\Tools\DTO\WebSearch; @@ -15,6 +16,7 @@ */ class ToolTest extends TestCase { + use ArrayTransformationTestTrait; /** * Tests creating tool with function declarations. * @@ -141,21 +143,21 @@ public function testJsonSchemaForFunctionDeclarationsTool(): void $functionSchema = $schema['oneOf'][0]; $this->assertEquals('object', $functionSchema['type']); $this->assertArrayHasKey('properties', $functionSchema); - $this->assertArrayHasKey('type', $functionSchema['properties']); - $this->assertArrayHasKey('functionDeclarations', $functionSchema['properties']); + $this->assertArrayHasKey(Tool::KEY_TYPE, $functionSchema['properties']); + $this->assertArrayHasKey(Tool::KEY_FUNCTION_DECLARATIONS, $functionSchema['properties']); // Type property - $typeProperty = $functionSchema['properties']['type']; + $typeProperty = $functionSchema['properties'][Tool::KEY_TYPE]; $this->assertEquals('string', $typeProperty['type']); $this->assertEquals(ToolTypeEnum::functionDeclarations()->value, $typeProperty['const']); // Function declarations property - $functionsProperty = $functionSchema['properties']['functionDeclarations']; + $functionsProperty = $functionSchema['properties'][Tool::KEY_FUNCTION_DECLARATIONS]; $this->assertEquals('array', $functionsProperty['type']); $this->assertArrayHasKey('items', $functionsProperty); // Required fields - $this->assertEquals(['type', 'functionDeclarations'], $functionSchema['required']); + $this->assertEquals([Tool::KEY_TYPE, Tool::KEY_FUNCTION_DECLARATIONS], $functionSchema['required']); } /** @@ -171,19 +173,19 @@ public function testJsonSchemaForWebSearchTool(): void $webSearchSchema = $schema['oneOf'][1]; $this->assertEquals('object', $webSearchSchema['type']); $this->assertArrayHasKey('properties', $webSearchSchema); - $this->assertArrayHasKey('type', $webSearchSchema['properties']); - $this->assertArrayHasKey('webSearch', $webSearchSchema['properties']); + $this->assertArrayHasKey(Tool::KEY_TYPE, $webSearchSchema['properties']); + $this->assertArrayHasKey(Tool::KEY_WEB_SEARCH, $webSearchSchema['properties']); // Type property - $typeProperty = $webSearchSchema['properties']['type']; + $typeProperty = $webSearchSchema['properties'][Tool::KEY_TYPE]; $this->assertEquals('string', $typeProperty['type']); $this->assertEquals(ToolTypeEnum::webSearch()->value, $typeProperty['const']); // Web search property - $this->assertArrayHasKey('webSearch', $webSearchSchema['properties']); + $this->assertArrayHasKey(Tool::KEY_WEB_SEARCH, $webSearchSchema['properties']); // Required fields - $this->assertEquals(['type', 'webSearch'], $webSearchSchema['required']); + $this->assertEquals([Tool::KEY_TYPE, Tool::KEY_WEB_SEARCH], $webSearchSchema['required']); } /** @@ -295,4 +297,163 @@ public function testMultipleToolInstances(): void $this->assertNull($tool2->getFunctionDeclarations()); $this->assertNotNull($tool2->getWebSearch()); } + + /** + * Tests array transformation with function declarations. + * + * @return void + */ + public function testToArrayWithFunctionDeclarations(): void + { + $functions = [ + new FunctionDeclaration('func1', 'First function', ['param1' => ['type' => 'string']]), + new FunctionDeclaration('func2', 'Second function') + ]; + + $tool = new Tool($functions); + $json = $this->assertToArrayReturnsArray($tool); + + $this->assertArrayHasKeys($json, [Tool::KEY_TYPE, Tool::KEY_FUNCTION_DECLARATIONS]); + $this->assertEquals(ToolTypeEnum::functionDeclarations()->value, $json[Tool::KEY_TYPE]); + $this->assertIsArray($json[Tool::KEY_FUNCTION_DECLARATIONS]); + $this->assertCount(2, $json[Tool::KEY_FUNCTION_DECLARATIONS]); + $this->assertEquals('func1', $json[Tool::KEY_FUNCTION_DECLARATIONS][0][FunctionDeclaration::KEY_NAME]); + $this->assertEquals('func2', $json[Tool::KEY_FUNCTION_DECLARATIONS][1][FunctionDeclaration::KEY_NAME]); + } + + /** + * Tests array transformation with web search. + * + * @return void + */ + public function testToArrayWithWebSearch(): void + { + $webSearch = new WebSearch( + ['allowed1.com', 'allowed2.com'], + ['blocked1.com', 'blocked2.com'] + ); + + $tool = new Tool($webSearch); + $json = $this->assertToArrayReturnsArray($tool); + + $this->assertArrayHasKeys($json, [Tool::KEY_TYPE, Tool::KEY_WEB_SEARCH]); + $this->assertEquals(ToolTypeEnum::webSearch()->value, $json[Tool::KEY_TYPE]); + $this->assertIsArray($json[Tool::KEY_WEB_SEARCH]); + $this->assertArrayHasKey(WebSearch::KEY_ALLOWED_DOMAINS, $json[Tool::KEY_WEB_SEARCH]); + $this->assertArrayHasKey(WebSearch::KEY_DISALLOWED_DOMAINS, $json[Tool::KEY_WEB_SEARCH]); + } + + /** + * Tests fromJson method with function declarations. + * + * @return void + */ + public function testFromArrayWithFunctionDeclarations(): void + { + $json = [ + Tool::KEY_TYPE => ToolTypeEnum::functionDeclarations()->value, + Tool::KEY_FUNCTION_DECLARATIONS => [ + [ + FunctionDeclaration::KEY_NAME => 'testFunc', + FunctionDeclaration::KEY_DESCRIPTION => 'Test function', + FunctionDeclaration::KEY_PARAMETERS => ['type' => 'object'] + ] + ] + ]; + + $tool = Tool::fromArray($json); + + $this->assertInstanceOf(Tool::class, $tool); + $this->assertEquals(ToolTypeEnum::functionDeclarations(), $tool->getType()); + $this->assertCount(1, $tool->getFunctionDeclarations()); + $this->assertEquals('testFunc', $tool->getFunctionDeclarations()[0]->getName()); + $this->assertNull($tool->getWebSearch()); + } + + /** + * Tests fromJson method with web search. + * + * @return void + */ + public function testFromArrayWithWebSearch(): void + { + $json = [ + Tool::KEY_TYPE => ToolTypeEnum::webSearch()->value, + Tool::KEY_WEB_SEARCH => [ + WebSearch::KEY_ALLOWED_DOMAINS => ['example.com'], + WebSearch::KEY_DISALLOWED_DOMAINS => ['spam.com'] + ] + ]; + + $tool = Tool::fromArray($json); + + $this->assertInstanceOf(Tool::class, $tool); + $this->assertEquals(ToolTypeEnum::webSearch(), $tool->getType()); + $this->assertNotNull($tool->getWebSearch()); + $this->assertEquals(['example.com'], $tool->getWebSearch()->getAllowedDomains()); + $this->assertEquals(['spam.com'], $tool->getWebSearch()->getDisallowedDomains()); + $this->assertNull($tool->getFunctionDeclarations()); + } + + /** + * Tests round-trip array transformation with function declarations. + * + * @return void + */ + public function testArrayRoundTripWithFunctionDeclarations(): void + { + $this->assertArrayRoundTrip( + new Tool([ + new FunctionDeclaration('calculate', 'Performs calculations', ['expr' => ['type' => 'string']]), + new FunctionDeclaration('validate', 'Validates input', ['data' => ['type' => 'object']]) + ]), + function ($original, $restored) { + $this->assertEquals($original->getType()->value, $restored->getType()->value); + $this->assertCount( + count($original->getFunctionDeclarations()), + $restored->getFunctionDeclarations() + ); + foreach ($original->getFunctionDeclarations() as $i => $origFunc) { + $restoredFunc = $restored->getFunctionDeclarations()[$i]; + $this->assertEquals($origFunc->getName(), $restoredFunc->getName()); + $this->assertEquals($origFunc->getDescription(), $restoredFunc->getDescription()); + $this->assertEquals($origFunc->getParameters(), $restoredFunc->getParameters()); + } + } + ); + } + + /** + * Tests round-trip array transformation with web search. + * + * @return void + */ + public function testArrayRoundTripWithWebSearch(): void + { + $this->assertArrayRoundTrip( + new Tool(new WebSearch(['docs.example.com'], ['ads.example.com'])), + function ($original, $restored) { + $this->assertEquals($original->getType()->value, $restored->getType()->value); + $this->assertEquals( + $original->getWebSearch()->getAllowedDomains(), + $restored->getWebSearch()->getAllowedDomains() + ); + $this->assertEquals( + $original->getWebSearch()->getDisallowedDomains(), + $restored->getWebSearch()->getDisallowedDomains() + ); + } + ); + } + + /** + * Tests Tool implements WithArrayTransformationInterface. + * + * @return void + */ + public function testImplementsWithArrayTransformationInterface(): void + { + $tool = new Tool([]); + $this->assertImplementsArrayTransformation($tool); + } } \ No newline at end of file diff --git a/tests/unit/Tools/DTO/WebSearchTest.php b/tests/unit/Tools/DTO/WebSearchTest.php index 0a9aee97..2016d347 100644 --- a/tests/unit/Tools/DTO/WebSearchTest.php +++ b/tests/unit/Tools/DTO/WebSearchTest.php @@ -5,6 +5,7 @@ namespace WordPress\AiClient\Tests\unit\Tools\DTO; use PHPUnit\Framework\TestCase; +use WordPress\AiClient\Tests\traits\ArrayTransformationTestTrait; use WordPress\AiClient\Tools\DTO\WebSearch; /** @@ -12,6 +13,7 @@ */ class WebSearchTest extends TestCase { + use ArrayTransformationTestTrait; /** * Tests creating WebSearch with both allowed and disallowed domains. * @@ -126,18 +128,18 @@ public function testJsonSchema(): void // Check properties $this->assertArrayHasKey('properties', $schema); - $this->assertArrayHasKey('allowedDomains', $schema['properties']); - $this->assertArrayHasKey('disallowedDomains', $schema['properties']); + $this->assertArrayHasKey(WebSearch::KEY_ALLOWED_DOMAINS, $schema['properties']); + $this->assertArrayHasKey(WebSearch::KEY_DISALLOWED_DOMAINS, $schema['properties']); // Check allowedDomains property - $allowedSchema = $schema['properties']['allowedDomains']; + $allowedSchema = $schema['properties'][WebSearch::KEY_ALLOWED_DOMAINS]; $this->assertEquals('array', $allowedSchema['type']); $this->assertArrayHasKey('items', $allowedSchema); $this->assertEquals('string', $allowedSchema['items']['type']); $this->assertArrayHasKey('description', $allowedSchema); // Check disallowedDomains property - $disallowedSchema = $schema['properties']['disallowedDomains']; + $disallowedSchema = $schema['properties'][WebSearch::KEY_DISALLOWED_DOMAINS]; $this->assertEquals('array', $disallowedSchema['type']); $this->assertArrayHasKey('items', $disallowedSchema); $this->assertEquals('string', $disallowedSchema['items']['type']); @@ -291,4 +293,158 @@ public function testWithCommonDomainPatterns(): void $this->assertContains('stackoverflow.com', $webSearch->getAllowedDomains()); $this->assertContains('youtube.com', $webSearch->getDisallowedDomains()); } + + /** + * Tests array transformation with both domain lists. + * + * @return void + */ + public function testToArrayWithBothDomainLists(): void + { + $webSearch = new WebSearch( + ['example.com', 'docs.example.com'], + ['spam.com', 'malware.com'] + ); + + $json = $this->assertToArrayReturnsArray($webSearch); + + $this->assertArrayHasKeys($json, [WebSearch::KEY_ALLOWED_DOMAINS, WebSearch::KEY_DISALLOWED_DOMAINS]); + $this->assertEquals(['example.com', 'docs.example.com'], $json[WebSearch::KEY_ALLOWED_DOMAINS]); + $this->assertEquals(['spam.com', 'malware.com'], $json[WebSearch::KEY_DISALLOWED_DOMAINS]); + } + + /** + * Tests array transformation with empty domain lists. + * + * @return void + */ + public function testToArrayWithEmptyDomainLists(): void + { + $webSearch = new WebSearch(); + + $json = $this->assertToArrayReturnsArray($webSearch); + + $this->assertArrayHasKeys($json, [WebSearch::KEY_ALLOWED_DOMAINS, WebSearch::KEY_DISALLOWED_DOMAINS]); + $this->assertEquals([], $json[WebSearch::KEY_ALLOWED_DOMAINS]); + $this->assertEquals([], $json[WebSearch::KEY_DISALLOWED_DOMAINS]); + } + + /** + * Tests array transformation with only allowed domains. + * + * @return void + */ + public function testToArrayWithOnlyAllowedDomains(): void + { + $webSearch = new WebSearch(['trusted1.com', 'trusted2.com']); + + $json = $this->assertToArrayReturnsArray($webSearch); + + $this->assertArrayHasKeys($json, [WebSearch::KEY_ALLOWED_DOMAINS, WebSearch::KEY_DISALLOWED_DOMAINS]); + $this->assertEquals(['trusted1.com', 'trusted2.com'], $json[WebSearch::KEY_ALLOWED_DOMAINS]); + $this->assertEquals([], $json[WebSearch::KEY_DISALLOWED_DOMAINS]); + } + + /** + * Tests fromJson method with both domain lists. + * + * @return void + */ + public function testFromArrayWithBothDomainLists(): void + { + $json = [ + WebSearch::KEY_ALLOWED_DOMAINS => ['api.example.com', 'docs.example.com'], + WebSearch::KEY_DISALLOWED_DOMAINS => ['ads.example.com', 'tracking.example.com'] + ]; + + $webSearch = WebSearch::fromArray($json); + + $this->assertInstanceOf(WebSearch::class, $webSearch); + $this->assertEquals(['api.example.com', 'docs.example.com'], $webSearch->getAllowedDomains()); + $this->assertEquals(['ads.example.com', 'tracking.example.com'], $webSearch->getDisallowedDomains()); + } + + /** + * Tests fromJson method with empty arrays. + * + * @return void + */ + public function testFromArrayWithEmptyArrays(): void + { + $json = [ + WebSearch::KEY_ALLOWED_DOMAINS => [], + WebSearch::KEY_DISALLOWED_DOMAINS => [] + ]; + + $webSearch = WebSearch::fromArray($json); + + $this->assertInstanceOf(WebSearch::class, $webSearch); + $this->assertEquals([], $webSearch->getAllowedDomains()); + $this->assertEquals([], $webSearch->getDisallowedDomains()); + } + + /** + * Tests fromJson method with missing fields uses defaults. + * + * @return void + */ + public function testFromArrayWithMissingFieldsUsesDefaults(): void + { + $json = []; + + $webSearch = WebSearch::fromArray($json); + + $this->assertInstanceOf(WebSearch::class, $webSearch); + $this->assertEquals([], $webSearch->getAllowedDomains()); + $this->assertEquals([], $webSearch->getDisallowedDomains()); + } + + /** + * Tests round-trip array transformation. + * + * @return void + */ + public function testArrayRoundTrip(): void + { + $this->assertArrayRoundTrip( + new WebSearch( + ['wikipedia.org', 'arxiv.org', 'pubmed.gov'], + ['facebook.com', 'twitter.com', 'instagram.com'] + ), + function ($original, $restored) { + $this->assertEquals($original->getAllowedDomains(), $restored->getAllowedDomains()); + $this->assertEquals($original->getDisallowedDomains(), $restored->getDisallowedDomains()); + } + ); + } + + /** + * Tests round-trip with special characters in domains. + * + * @return void + */ + public function testArrayRoundTripWithSpecialCharacters(): void + { + $this->assertArrayRoundTrip( + new WebSearch( + ['example-with-dash.com', 'sub.domain.example.com', '192.168.1.1'], + ['bad_underscore.com', 'another-dash.org'] + ), + function ($original, $restored) { + $this->assertEquals($original->getAllowedDomains(), $restored->getAllowedDomains()); + $this->assertEquals($original->getDisallowedDomains(), $restored->getDisallowedDomains()); + } + ); + } + + /** + * Tests WebSearch implements WithArrayTransformationInterface. + * + * @return void + */ + public function testImplementsWithArrayTransformationInterface(): void + { + $webSearch = new WebSearch(); + $this->assertImplementsArrayTransformation($webSearch); + } } \ No newline at end of file