diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 776ccd86..b74b10f0 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -2,3 +2,4 @@ parameters: level: max paths: - src + treatPhpDocTypesAsCertain: false diff --git a/src/Common/AbstractEnum.php b/src/Common/AbstractEnum.php index 11f10134..df9524fa 100644 --- a/src/Common/AbstractEnum.php +++ b/src/Common/AbstractEnum.php @@ -30,6 +30,9 @@ * $enum->is(PersonEnum::firstName()); // Returns true * PersonEnum::cases(); // Returns array of all enum instances * + * @property-read string $value The value of the enum instance. + * @property-read string $name The name of the enum constant. + * * @since n.e.x.t */ abstract class AbstractEnum @@ -195,11 +198,11 @@ final public function is(self $other): bool * * @since n.e.x.t * - * @return array Map of constant names to values. + * @return string[] List of all enum values. */ final public static function getValues(): array { - return self::getConstants(); + return array_values(self::getConstants()); } /** diff --git a/src/Common/Contracts/WithJsonSchemaInterface.php b/src/Common/Contracts/WithJsonSchemaInterface.php new file mode 100644 index 00000000..889a69ff --- /dev/null +++ b/src/Common/Contracts/WithJsonSchemaInterface.php @@ -0,0 +1,25 @@ + The JSON schema as an associative array. + */ + public static function getJsonSchema(): array; +} diff --git a/src/Files/DTO/File.php b/src/Files/DTO/File.php new file mode 100644 index 00000000..cc84724f --- /dev/null +++ b/src/Files/DTO/File.php @@ -0,0 +1,380 @@ +detectAndProcessFile($file, $mimeType); + } + + /** + * Detects the file type and processes it accordingly. + * + * @since n.e.x.t + * + * @param string $file The file string to process. + * @param string|null $providedMimeType The explicitly provided MIME type. + * @throws \InvalidArgumentException If the file format is invalid or MIME type cannot be determined. + */ + private function detectAndProcessFile(string $file, ?string $providedMimeType): void + { + // Check if it's a URL + if ($this->isUrl($file)) { + $this->fileType = FileTypeEnum::remote(); + $this->url = $file; + $this->mimeType = $this->determineMimeType($providedMimeType, null, $file); + return; + } + + // Check if it's a data URI + $dataUriPattern = '/^data:(?:([a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*' + . '(?:;[a-zA-Z0-9\-]+=[a-zA-Z0-9\-]+)*)?;)?base64,([A-Za-z0-9+\/]*={0,2})$/'; + + if (preg_match($dataUriPattern, $file, $matches)) { + $this->fileType = FileTypeEnum::inline(); + $this->base64Data = $matches[2]; // Extract just the base64 data + $extractedMimeType = empty($matches[1]) ? null : $matches[1]; + $this->mimeType = $this->determineMimeType($providedMimeType, $extractedMimeType, null); + return; + } + + // Check if it's a local file path (before base64 check) + if (file_exists($file) && is_file($file)) { + $this->fileType = FileTypeEnum::inline(); + $this->base64Data = $this->convertFileToBase64($file); + $this->mimeType = $this->determineMimeType($providedMimeType, null, $file); + return; + } + + // Check if it's plain base64 + if (preg_match('/^[A-Za-z0-9+\/]*={0,2}$/', $file)) { + if ($providedMimeType === null) { + throw new \InvalidArgumentException( + 'MIME type is required when providing plain base64 data without data URI format.' + ); + } + $this->fileType = FileTypeEnum::inline(); + $this->base64Data = $file; + $this->mimeType = new MimeType($providedMimeType); + return; + } + + throw new \InvalidArgumentException( + 'Invalid file provided. Expected URL, base64 data, or valid local file path.' + ); + } + + /** + * Checks if a string is a valid URL. + * + * @since n.e.x.t + * + * @param string $string The string to check. + * @return bool True if the string is a URL. + */ + private function isUrl(string $string): bool + { + return filter_var($string, FILTER_VALIDATE_URL) !== false + && preg_match('/^https?:\/\//i', $string); + } + + /** + * Converts a local file to base64. + * + * @since n.e.x.t + * + * @param string $filePath The path to the local file. + * @return string The base64-encoded file data. + * @throws \RuntimeException If the file cannot be read. + */ + private function convertFileToBase64(string $filePath): string + { + $fileContent = @file_get_contents($filePath); + + if ($fileContent === false) { + throw new \RuntimeException( + sprintf('Unable to read file: %s', $filePath) + ); + } + + return base64_encode($fileContent); + } + + /** + * Gets the file type. + * + * @since n.e.x.t + * + * @return FileTypeEnum The file type. + */ + public function getFileType(): FileTypeEnum + { + return $this->fileType; + } + + /** + * Gets the URL for remote files. + * + * @since n.e.x.t + * + * @return string|null The URL, or null if not a remote file. + */ + public function getUrl(): ?string + { + return $this->url; + } + + /** + * Gets the base64-encoded data for inline files. + * + * @since n.e.x.t + * + * @return string|null The plain base64-encoded data (without data URI prefix), or null if not an inline file. + */ + public function getBase64Data(): ?string + { + return $this->base64Data; + } + + /** + * Gets the data as a data URI for inline files. + * + * @since n.e.x.t + * + * @return string|null The data URI in format: data:[mimeType];base64,[data], or null if not an inline file. + */ + public function getDataUri(): ?string + { + if ($this->base64Data === null) { + return null; + } + + return sprintf('data:%s;base64,%s', $this->getMimeType(), $this->base64Data); + } + + /** + * Gets the MIME type of the file as a string. + * + * @since n.e.x.t + * + * @return string The MIME type string value. + */ + public function getMimeType(): string + { + return (string) $this->mimeType; + } + + /** + * Gets the MIME type object. + * + * @since n.e.x.t + * + * @return MimeType The MIME type object. + */ + public function getMimeTypeObject(): MimeType + { + return $this->mimeType; + } + + /** + * Checks if the file is a video. + * + * @since n.e.x.t + * + * @return bool True if the file is a video. + */ + public function isVideo(): bool + { + return $this->mimeType->isVideo(); + } + + /** + * Checks if the file is an image. + * + * @since n.e.x.t + * + * @return bool True if the file is an image. + */ + public function isImage(): bool + { + return $this->mimeType->isImage(); + } + + /** + * Checks if the file is audio. + * + * @since n.e.x.t + * + * @return bool True if the file is audio. + */ + public function isAudio(): bool + { + return $this->mimeType->isAudio(); + } + + /** + * Checks if the file is text. + * + * @since n.e.x.t + * + * @return bool True if the file is text. + */ + public function isText(): bool + { + return $this->mimeType->isText(); + } + + /** + * Determines the MIME type from various sources. + * + * @since n.e.x.t + * + * @param string|null $providedMimeType The explicitly provided MIME type. + * @param string|null $extractedMimeType The MIME type extracted from data URI. + * @param string|null $pathOrUrl The file path or URL to extract extension from. + * @return MimeType The determined MIME type. + * @throws \InvalidArgumentException If MIME type cannot be determined. + */ + private function determineMimeType( + ?string $providedMimeType, + ?string $extractedMimeType, + ?string $pathOrUrl + ): MimeType { + // Prefer explicitly provided MIME type + if ($providedMimeType !== null) { + return new MimeType($providedMimeType); + } + + // Use extracted MIME type from data URI + if ($extractedMimeType !== null) { + return new MimeType($extractedMimeType); + } + + // Try to determine from file extension + if ($pathOrUrl !== null) { + $parsedUrl = parse_url($pathOrUrl); + $path = $parsedUrl['path'] ?? $pathOrUrl; + + // Remove query string and fragment if present + $cleanPath = strtok($path, '?#'); + if ($cleanPath === false) { + $cleanPath = $path; + } + + $extension = pathinfo($cleanPath, PATHINFO_EXTENSION); + if (!empty($extension)) { + try { + return MimeType::fromExtension($extension); + } catch (\InvalidArgumentException $e) { + // Extension not recognized, continue to error + unset($e); + } + } + } + + throw new \InvalidArgumentException( + 'Unable to determine MIME type. Please provide it explicitly.' + ); + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'oneOf' => [ + [ + 'properties' => [ + 'fileType' => [ + 'type' => 'string', + 'const' => FileTypeEnum::REMOTE, + 'description' => 'The file type.', + ], + 'mimeType' => [ + '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' => [ + 'type' => 'string', + 'format' => 'uri', + 'description' => 'The URL to the remote file.', + ], + ], + 'required' => ['fileType', 'mimeType', 'url'], + ], + [ + 'properties' => [ + 'fileType' => [ + 'type' => 'string', + 'const' => FileTypeEnum::INLINE, + 'description' => 'The file type.', + ], + 'mimeType' => [ + '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' => [ + 'type' => 'string', + 'description' => 'The base64-encoded file data.', + ], + ], + 'required' => ['fileType', 'mimeType', 'base64Data'], + ], + ], + ]; + } +} diff --git a/src/Files/Enums/FileTypeEnum.php b/src/Files/Enums/FileTypeEnum.php new file mode 100644 index 00000000..557bc5ca --- /dev/null +++ b/src/Files/Enums/FileTypeEnum.php @@ -0,0 +1,34 @@ + + */ + private static array $extensionMap = [ + // Text + 'txt' => 'text/plain', + 'html' => 'text/html', + 'htm' => 'text/html', + 'css' => 'text/css', + 'js' => 'application/javascript', + 'json' => 'application/json', + 'xml' => 'application/xml', + 'csv' => 'text/csv', + 'md' => 'text/markdown', + + // Images + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'png' => 'image/png', + 'gif' => 'image/gif', + 'bmp' => 'image/bmp', + 'webp' => 'image/webp', + 'svg' => 'image/svg+xml', + 'ico' => 'image/x-icon', + + // Documents + 'pdf' => 'application/pdf', + 'doc' => 'application/msword', + 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'xls' => 'application/vnd.ms-excel', + 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'ppt' => 'application/vnd.ms-powerpoint', + 'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'odt' => 'application/vnd.oasis.opendocument.text', + 'ods' => 'application/vnd.oasis.opendocument.spreadsheet', + + // Archives + 'zip' => 'application/zip', + 'tar' => 'application/x-tar', + 'gz' => 'application/gzip', + 'rar' => 'application/x-rar-compressed', + '7z' => 'application/x-7z-compressed', + + // Audio + 'mp3' => 'audio/mpeg', + 'wav' => 'audio/wav', + 'ogg' => 'audio/ogg', + 'flac' => 'audio/flac', + 'm4a' => 'audio/m4a', + + // Video + 'mp4' => 'video/mp4', + 'avi' => 'video/x-msvideo', + 'mov' => 'video/quicktime', + 'wmv' => 'video/x-ms-wmv', + 'flv' => 'video/x-flv', + 'webm' => 'video/webm', + 'mkv' => 'video/x-matroska', + + // Fonts + 'ttf' => 'font/ttf', + 'otf' => 'font/otf', + 'woff' => 'font/woff', + 'woff2' => 'font/woff2', + + // Other + 'php' => 'application/x-httpd-php', + 'sh' => 'application/x-sh', + 'exe' => 'application/x-msdownload', + ]; + + /** + * Document MIME types. + * + * @var array + */ + private static array $documentTypes = [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/vnd.oasis.opendocument.text', + 'application/vnd.oasis.opendocument.spreadsheet', + ]; + + /** + * Constructor. + * + * @since n.e.x.t + * + * @param string $value The MIME type value. + * @throws \InvalidArgumentException If the MIME type is invalid. + */ + public function __construct(string $value) + { + if (!self::isValid($value)) { + throw new \InvalidArgumentException( + sprintf('Invalid MIME type: %s', $value) + ); + } + + $this->value = strtolower($value); + } + + /** + * Creates a MimeType from a file extension. + * + * @since n.e.x.t + * + * @param string $extension The file extension (without the dot). + * @return self The MimeType instance. + * @throws \InvalidArgumentException If the extension is not recognized. + */ + public static function fromExtension(string $extension): self + { + $extension = strtolower($extension); + + if (!isset(self::$extensionMap[$extension])) { + throw new \InvalidArgumentException( + sprintf('Unknown file extension: %s', $extension) + ); + } + + return new self(self::$extensionMap[$extension]); + } + + /** + * Checks if a MIME type string is valid. + * + * @since n.e.x.t + * + * @param string $mimeType The MIME type to validate. + * @return bool True if valid. + */ + public static function isValid(string $mimeType): bool + { + // Basic MIME type validation: type/subtype + return (bool) preg_match( + '/^[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*$/', + $mimeType + ); + } + + /** + * Checks if this is an image MIME type. + * + * @since n.e.x.t + * + * @return bool True if this is an image type. + */ + public function isImage(): bool + { + return strpos($this->value, 'image/') === 0; + } + + /** + * Checks if this is an audio MIME type. + * + * @since n.e.x.t + * + * @return bool True if this is an audio type. + */ + public function isAudio(): bool + { + return strpos($this->value, 'audio/') === 0; + } + + /** + * Checks if this is a video MIME type. + * + * @since n.e.x.t + * + * @return bool True if this is a video type. + */ + public function isVideo(): bool + { + return strpos($this->value, 'video/') === 0; + } + + /** + * Checks if this is a text MIME type. + * + * @since n.e.x.t + * + * @return bool True if this is a text type. + */ + public function isText(): bool + { + return strpos($this->value, 'text/') === 0; + } + + /** + * Checks if this is a document MIME type. + * + * @since n.e.x.t + * + * @return bool True if this is a document type. + */ + public function isDocument(): bool + { + return in_array($this->value, self::$documentTypes, true); + } + + /** + * Checks if this MIME type equals another. + * + * @since n.e.x.t + * + * @param self|string $other The other MIME type to compare. + * @return bool True if equal. + */ + public function equals($other): bool + { + if ($other instanceof self) { + return $this->value === $other->value; + } + + if (is_string($other)) { + return $this->value === strtolower($other); + } + + throw new \InvalidArgumentException( + sprintf('Invalid MIME type comparison: %s', gettype($other)) + ); + } + + /** + * Gets the string representation of the MIME type. + * + * @since n.e.x.t + * + * @return string The MIME type value. + */ + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Messages/DTO/Message.php b/src/Messages/DTO/Message.php new file mode 100644 index 00000000..cf73e30a --- /dev/null +++ b/src/Messages/DTO/Message.php @@ -0,0 +1,93 @@ +role = $role; + $this->parts = $parts; + } + + /** + * Gets the role of the message sender. + * + * @since n.e.x.t + * + * @return MessageRoleEnum The role. + */ + public function getRole(): MessageRoleEnum + { + return $this->role; + } + + /** + * Gets the message parts. + * + * @since n.e.x.t + * + * @return MessagePart[] The message parts. + */ + public function getParts(): array + { + return $this->parts; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'role' => [ + 'type' => 'string', + 'enum' => MessageRoleEnum::getValues(), + 'description' => 'The role of the message sender.', + ], + 'parts' => [ + 'type' => 'array', + 'items' => MessagePart::getJsonSchema(), + 'minItems' => 1, + 'description' => 'The parts that make up this message.', + ], + ], + 'required' => ['role', 'parts'], + ]; + } +} diff --git a/src/Messages/DTO/MessagePart.php b/src/Messages/DTO/MessagePart.php new file mode 100644 index 00000000..ddaf3945 --- /dev/null +++ b/src/Messages/DTO/MessagePart.php @@ -0,0 +1,205 @@ +type = MessagePartTypeEnum::text(); + $this->text = $content; + } elseif ($content instanceof File) { + $this->type = MessagePartTypeEnum::file(); + $this->file = $content; + } elseif ($content instanceof FunctionCall) { + $this->type = MessagePartTypeEnum::functionCall(); + $this->functionCall = $content; + } elseif ($content instanceof FunctionResponse) { + $this->type = MessagePartTypeEnum::functionResponse(); + $this->functionResponse = $content; + } else { + $type = is_object($content) ? get_class($content) : gettype($content); + throw new \InvalidArgumentException( + sprintf( + 'Unsupported content type %s. Expected string, File, ' + . 'FunctionCall, or FunctionResponse.', + $type + ) + ); + } + } + + /** + * Gets the type of this message part. + * + * @since n.e.x.t + * + * @return MessagePartTypeEnum The type. + */ + public function getType(): MessagePartTypeEnum + { + return $this->type; + } + + /** + * Gets the text content. + * + * @since n.e.x.t + * + * @return string|null The text content or null if not a text part. + */ + public function getText(): ?string + { + return $this->text; + } + + /** + * Gets the file. + * + * @since n.e.x.t + * + * @return File|null The file or null if not a file part. + */ + public function getFile(): ?File + { + return $this->file; + } + + /** + * Gets the function call. + * + * @since n.e.x.t + * + * @return FunctionCall|null The function call or null if not a function call part. + */ + public function getFunctionCall(): ?FunctionCall + { + return $this->functionCall; + } + + /** + * Gets the function response. + * + * @since n.e.x.t + * + * @return FunctionResponse|null The function response or null if not a function response part. + */ + public function getFunctionResponse(): ?FunctionResponse + { + return $this->functionResponse; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public static function getJsonSchema(): array + { + return [ + 'oneOf' => [ + [ + 'type' => 'object', + 'properties' => [ + 'type' => [ + 'type' => 'string', + 'const' => MessagePartTypeEnum::text()->value, + ], + 'text' => [ + 'type' => 'string', + 'description' => 'Text content.', + ], + ], + 'required' => ['type', 'text'], + 'additionalProperties' => false, + ], + [ + 'type' => 'object', + 'properties' => [ + 'type' => [ + 'type' => 'string', + 'const' => MessagePartTypeEnum::file()->value, + ], + 'file' => File::getJsonSchema(), + ], + 'required' => ['type', 'file'], + 'additionalProperties' => false, + ], + [ + 'type' => 'object', + 'properties' => [ + 'type' => [ + 'type' => 'string', + 'const' => MessagePartTypeEnum::functionCall()->value, + ], + 'functionCall' => FunctionCall::getJsonSchema(), + ], + 'required' => ['type', 'functionCall'], + 'additionalProperties' => false, + ], + [ + 'type' => 'object', + 'properties' => [ + 'type' => [ + 'type' => 'string', + 'const' => MessagePartTypeEnum::functionResponse()->value, + ], + 'functionResponse' => FunctionResponse::getJsonSchema(), + ], + 'required' => ['type', 'functionResponse'], + 'additionalProperties' => false, + ], + ], + ]; + } +} diff --git a/src/Messages/DTO/ModelMessage.php b/src/Messages/DTO/ModelMessage.php new file mode 100644 index 00000000..cf67b79c --- /dev/null +++ b/src/Messages/DTO/ModelMessage.php @@ -0,0 +1,30 @@ +id = $id; + $this->state = $state; + $this->result = $result; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public function getId(): string + { + return $this->id; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public function getState(): OperationStateEnum + { + return $this->state; + } + + /** + * Gets the operation result. + * + * @since n.e.x.t + * + * @return GenerativeAiResult|null The result or null if not yet complete. + */ + public function getResult(): ?GenerativeAiResult + { + return $this->result; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public static function getJsonSchema(): array + { + return [ + 'oneOf' => [ + // Succeeded state - has result + [ + 'type' => 'object', + 'properties' => [ + 'id' => [ + 'type' => 'string', + 'description' => 'Unique identifier for this operation.', + ], + 'state' => [ + 'type' => 'string', + 'const' => OperationStateEnum::succeeded()->value, + ], + 'result' => GenerativeAiResult::getJsonSchema(), + ], + 'required' => ['id', 'state', 'result'], + 'additionalProperties' => false, + ], + // All other states - no result + [ + 'type' => 'object', + 'properties' => [ + 'id' => [ + 'type' => 'string', + 'description' => 'Unique identifier for this operation.', + ], + 'state' => [ + 'type' => 'string', + 'enum' => [ + OperationStateEnum::starting()->value, + OperationStateEnum::processing()->value, + OperationStateEnum::failed()->value, + OperationStateEnum::canceled()->value, + ], + 'description' => 'The current state of the operation.', + ], + ], + 'required' => ['id', 'state'], + 'additionalProperties' => false, + ], + ], + ]; + } +} diff --git a/src/Results/Contracts/ResultInterface.php b/src/Results/Contracts/ResultInterface.php new file mode 100644 index 00000000..4da83eab --- /dev/null +++ b/src/Results/Contracts/ResultInterface.php @@ -0,0 +1,46 @@ + Provider metadata. + */ + public function getProviderMetadata(): array; +} diff --git a/src/Results/DTO/Candidate.php b/src/Results/DTO/Candidate.php new file mode 100644 index 00000000..bdb9360d --- /dev/null +++ b/src/Results/DTO/Candidate.php @@ -0,0 +1,118 @@ +getRole()->isModel()) { + throw new \InvalidArgumentException( + 'Message must be a model message.' + ); + } + + $this->message = $message; + $this->finishReason = $finishReason; + $this->tokenCount = $tokenCount; + } + + /** + * Gets the generated message. + * + * @since n.e.x.t + * + * @return Message The message. + */ + public function getMessage(): Message + { + return $this->message; + } + + /** + * Gets the finish reason. + * + * @since n.e.x.t + * + * @return FinishReasonEnum The finish reason. + */ + public function getFinishReason(): FinishReasonEnum + { + return $this->finishReason; + } + + /** + * Gets the token count. + * + * @since n.e.x.t + * + * @return int The token count. + */ + public function getTokenCount(): int + { + return $this->tokenCount; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'message' => Message::getJsonSchema(), + 'finishReason' => [ + 'type' => 'string', + 'enum' => FinishReasonEnum::getValues(), + 'description' => 'The reason generation stopped.', + ], + 'tokenCount' => [ + 'type' => 'integer', + 'description' => 'The number of tokens in this candidate.', + ], + ], + 'required' => ['message', 'finishReason', 'tokenCount'], + ]; + } +} diff --git a/src/Results/DTO/GenerativeAiResult.php b/src/Results/DTO/GenerativeAiResult.php new file mode 100644 index 00000000..b9a19d5b --- /dev/null +++ b/src/Results/DTO/GenerativeAiResult.php @@ -0,0 +1,381 @@ + Provider-specific metadata. + */ + private array $providerMetadata; + + /** + * Constructor. + * + * @since n.e.x.t + * + * @param string $id Unique identifier for this result. + * @param Candidate[] $candidates The generated candidates. + * @param TokenUsage $tokenUsage Token usage statistics. + * @param array $providerMetadata Provider-specific metadata. + * @throws \InvalidArgumentException If no candidates provided. + */ + public function __construct(string $id, array $candidates, TokenUsage $tokenUsage, array $providerMetadata = []) + { + if (empty($candidates)) { + throw new \InvalidArgumentException('At least one candidate must be provided'); + } + + $this->id = $id; + $this->candidates = $candidates; + $this->tokenUsage = $tokenUsage; + $this->providerMetadata = $providerMetadata; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public function getId(): string + { + return $this->id; + } + + /** + * Gets the generated candidates. + * + * @since n.e.x.t + * + * @return Candidate[] The candidates. + */ + public function getCandidates(): array + { + return $this->candidates; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public function getTokenUsage(): TokenUsage + { + return $this->tokenUsage; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public function getProviderMetadata(): array + { + return $this->providerMetadata; + } + + /** + * Gets the total number of candidates. + * + * @since n.e.x.t + * + * @return int The total number of candidates. + */ + public function getCandidateCount(): int + { + return count($this->candidates); + } + + /** + * Checks if the result has multiple candidates. + * + * @since n.e.x.t + * + * @return bool True if there are multiple candidates, false otherwise. + */ + public function hasMultipleCandidates(): bool + { + return $this->getCandidateCount() > 1; + } + + /** + * Converts the first candidate to text. + * + * @since n.e.x.t + * + * @return string The text content. + * @throws \RuntimeException If no text content. + */ + public function toText(): string + { + $message = $this->candidates[0]->getMessage(); + foreach ($message->getParts() as $part) { + $text = $part->getText(); + if ($text !== null) { + return $text; + } + } + + throw new \RuntimeException('No text content found in first candidate'); + } + + /** + * Converts the first candidate to a file. + * + * @since n.e.x.t + * + * @return File The file. + * @throws \RuntimeException If no file content. + */ + public function toFile(): File + { + $message = $this->candidates[0]->getMessage(); + foreach ($message->getParts() as $part) { + $file = $part->getFile(); + if ($file !== null) { + return $file; + } + } + + throw new \RuntimeException('No file content found in first candidate'); + } + + /** + * Converts the first candidate to an image file. + * + * @since n.e.x.t + * + * @return File The image file. + * @throws \RuntimeException If no image content. + */ + public function toImageFile(): File + { + $file = $this->toFile(); + + if (!$file->isImage()) { + throw new \RuntimeException( + sprintf('File is not an image. MIME type: %s', $file->getMimeType()) + ); + } + + return $file; + } + + /** + * Converts the first candidate to an audio file. + * + * @since n.e.x.t + * + * @return File The audio file. + * @throws \RuntimeException If no audio content. + */ + public function toAudioFile(): File + { + $file = $this->toFile(); + + if (!$file->isAudio()) { + throw new \RuntimeException( + sprintf('File is not an audio file. MIME type: %s', $file->getMimeType()) + ); + } + + return $file; + } + + /** + * Converts the first candidate to a video file. + * + * @since n.e.x.t + * + * @return File The video file. + * @throws \RuntimeException If no video content. + */ + public function toVideoFile(): File + { + $file = $this->toFile(); + + if (!$file->isVideo()) { + throw new \RuntimeException( + sprintf('File is not a video file. MIME type: %s', $file->getMimeType()) + ); + } + + return $file; + } + + /** + * Converts the first candidate to a message. + * + * @since n.e.x.t + * + * @return Message The message. + */ + public function toMessage(): Message + { + return $this->candidates[0]->getMessage(); + } + + /** + * Converts all candidates to text array. + * + * @since n.e.x.t + * + * @return string[] Array of text content. + */ + public function toTexts(): array + { + $texts = []; + foreach ($this->candidates as $candidate) { + $message = $candidate->getMessage(); + foreach ($message->getParts() as $part) { + $text = $part->getText(); + if ($text !== null) { + $texts[] = $text; + break; + } + } + } + return $texts; + } + + /** + * Converts all candidates to files. + * + * @since n.e.x.t + * + * @return File[] Array of files. + */ + public function toFiles(): array + { + $files = []; + foreach ($this->candidates as $candidate) { + $message = $candidate->getMessage(); + foreach ($message->getParts() as $part) { + $file = $part->getFile(); + if ($file !== null) { + $files[] = $file; + break; + } + } + } + return $files; + } + + /** + * Converts all candidates to image files. + * + * @since n.e.x.t + * + * @return File[] Array of image files. + */ + public function toImageFiles(): array + { + return array_values(array_filter( + $this->toFiles(), + fn(File $file) => $file->isImage() + )); + } + + /** + * Converts all candidates to audio files. + * + * @since n.e.x.t + * + * @return File[] Array of audio files. + */ + public function toAudioFiles(): array + { + return array_values(array_filter( + $this->toFiles(), + fn(File $file) => $file->isAudio() + )); + } + + /** + * Converts all candidates to video files. + * + * @since n.e.x.t + * + * @return File[] Array of video files. + */ + public function toVideoFiles(): array + { + return array_values(array_filter( + $this->toFiles(), + fn(File $file) => $file->isVideo() + )); + } + + /** + * Converts all candidates to messages. + * + * @since n.e.x.t + * + * @return Message[] Array of messages. + */ + public function toMessages(): array + { + return array_map(fn(Candidate $candidate) => $candidate->getMessage(), $this->candidates); + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'id' => [ + 'type' => 'string', + 'description' => 'Unique identifier for this result.', + ], + 'candidates' => [ + 'type' => 'array', + 'items' => Candidate::getJsonSchema(), + 'minItems' => 1, + 'description' => 'The generated candidates.', + ], + 'tokenUsage' => TokenUsage::getJsonSchema(), + 'providerMetadata' => [ + 'type' => 'object', + 'additionalProperties' => true, + 'description' => 'Provider-specific metadata.', + ], + ], + 'required' => ['id', 'candidates', 'tokenUsage'], + ]; + } +} diff --git a/src/Results/DTO/TokenUsage.php b/src/Results/DTO/TokenUsage.php new file mode 100644 index 00000000..da7d3714 --- /dev/null +++ b/src/Results/DTO/TokenUsage.php @@ -0,0 +1,112 @@ +promptTokens = $promptTokens; + $this->completionTokens = $completionTokens; + $this->totalTokens = $totalTokens; + } + + /** + * Gets the number of prompt tokens. + * + * @since n.e.x.t + * + * @return int The prompt token count. + */ + public function getPromptTokens(): int + { + return $this->promptTokens; + } + + /** + * Gets the number of completion tokens. + * + * @since n.e.x.t + * + * @return int The completion token count. + */ + public function getCompletionTokens(): int + { + return $this->completionTokens; + } + + /** + * Gets the total number of tokens. + * + * @since n.e.x.t + * + * @return int The total token count. + */ + public function getTotalTokens(): int + { + return $this->totalTokens; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'promptTokens' => [ + 'type' => 'integer', + 'description' => 'Number of tokens in the prompt.', + ], + 'completionTokens' => [ + 'type' => 'integer', + 'description' => 'Number of tokens in the completion.', + ], + 'totalTokens' => [ + 'type' => 'integer', + 'description' => 'Total number of tokens used.', + ], + ], + 'required' => ['promptTokens', 'completionTokens', 'totalTokens'], + ]; + } +} diff --git a/src/Tools/DTO/FunctionCall.php b/src/Tools/DTO/FunctionCall.php new file mode 100644 index 00000000..bcbcfd3b --- /dev/null +++ b/src/Tools/DTO/FunctionCall.php @@ -0,0 +1,128 @@ + The arguments to pass to the function. + */ + private array $args; + + /** + * Constructor. + * + * @since n.e.x.t + * + * @param string|null $id Unique identifier for this function call. + * @param string|null $name The name of the function to call. + * @param array $args The arguments to pass to the function. + * @throws \InvalidArgumentException If neither id nor name is provided. + */ + public function __construct(?string $id = null, ?string $name = null, array $args = []) + { + if ($id === null && $name === null) { + throw new \InvalidArgumentException('At least one of id or name must be provided.'); + } + + $this->id = $id; + $this->name = $name; + $this->args = $args; + } + + /** + * Gets the function call ID. + * + * @since n.e.x.t + * + * @return string|null The unique identifier. + */ + public function getId(): ?string + { + return $this->id; + } + + /** + * Gets the function name. + * + * @since n.e.x.t + * + * @return string|null The function name. + */ + public function getName(): ?string + { + return $this->name; + } + + /** + * Gets the function arguments. + * + * @since n.e.x.t + * + * @return array The function arguments. + */ + public function getArgs(): array + { + return $this->args; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'id' => [ + 'type' => 'string', + 'description' => 'Unique identifier for this function call.', + ], + 'name' => [ + 'type' => 'string', + 'description' => 'The name of the function to call.', + ], + 'args' => [ + 'type' => 'object', + 'description' => 'The arguments to pass to the function.', + 'additionalProperties' => true, + ], + ], + 'oneOf' => [ + [ + 'required' => ['id'], + ], + [ + 'required' => ['name'], + ], + [ + 'required' => ['id', 'name'], + ], + ], + ]; + } +} diff --git a/src/Tools/DTO/FunctionDeclaration.php b/src/Tools/DTO/FunctionDeclaration.php new file mode 100644 index 00000000..435ca021 --- /dev/null +++ b/src/Tools/DTO/FunctionDeclaration.php @@ -0,0 +1,112 @@ +name = $name; + $this->description = $description; + $this->parameters = $parameters; + } + + /** + * Gets the function name. + * + * @since n.e.x.t + * + * @return string The function name. + */ + public function getName(): string + { + return $this->name; + } + + /** + * Gets the function description. + * + * @since n.e.x.t + * + * @return string The function description. + */ + public function getDescription(): string + { + return $this->description; + } + + /** + * Gets the function parameters schema. + * + * @since n.e.x.t + * + * @return mixed|null The parameters schema. + */ + public function getParameters() + { + return $this->parameters; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'name' => [ + 'type' => 'string', + 'description' => 'The name of the function.', + ], + 'description' => [ + 'type' => 'string', + 'description' => 'A description of what the function does.', + ], + 'parameters' => [ + 'type' => ['string', 'number', 'boolean', 'object', 'array', 'null'], + 'description' => 'The JSON schema for the function parameters.', + ], + ], + 'required' => ['name', 'description'], + ]; + } +} diff --git a/src/Tools/DTO/FunctionResponse.php b/src/Tools/DTO/FunctionResponse.php new file mode 100644 index 00000000..42a1e617 --- /dev/null +++ b/src/Tools/DTO/FunctionResponse.php @@ -0,0 +1,112 @@ +id = $id; + $this->name = $name; + $this->response = $response; + } + + /** + * Gets the function call ID. + * + * @since n.e.x.t + * + * @return string The function call ID. + */ + public function getId(): string + { + return $this->id; + } + + /** + * Gets the function name. + * + * @since n.e.x.t + * + * @return string The function name. + */ + public function getName(): string + { + return $this->name; + } + + /** + * Gets the function response. + * + * @since n.e.x.t + * + * @return mixed The response data. + */ + public function getResponse() + { + return $this->response; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'id' => [ + 'type' => 'string', + 'description' => 'The ID of the function call this is responding to.', + ], + 'name' => [ + 'type' => 'string', + 'description' => 'The name of the function that was called.', + ], + 'response' => [ + 'type' => ['string', 'number', 'boolean', 'object', 'array', 'null'], + 'description' => 'The response data from the function.', + ], + ], + 'required' => ['id', 'name', 'response'], + ]; + } +} diff --git a/src/Tools/DTO/Tool.php b/src/Tools/DTO/Tool.php new file mode 100644 index 00000000..ccc2d108 --- /dev/null +++ b/src/Tools/DTO/Tool.php @@ -0,0 +1,135 @@ +type = ToolTypeEnum::functionDeclarations(); + $this->functionDeclarations = $content; + } elseif ($content instanceof WebSearch) { + $this->type = ToolTypeEnum::webSearch(); + $this->webSearch = $content; + } else { + throw new \InvalidArgumentException( + 'Tool content must be an array of FunctionDeclaration instances or a WebSearch instance' + ); + } + } + + + /** + * Gets the tool type. + * + * @since n.e.x.t + * + * @return ToolTypeEnum The tool type. + */ + public function getType(): ToolTypeEnum + { + return $this->type; + } + + /** + * Gets the function declarations. + * + * @since n.e.x.t + * + * @return FunctionDeclaration[]|null The function declarations or null if not a function tool. + */ + public function getFunctionDeclarations(): ?array + { + return $this->functionDeclarations; + } + + /** + * Gets the web search configuration. + * + * @since n.e.x.t + * + * @return WebSearch|null The web search configuration or null if not a web search tool. + */ + public function getWebSearch(): ?WebSearch + { + return $this->webSearch; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public static function getJsonSchema(): array + { + return [ + 'oneOf' => [ + [ + 'type' => 'object', + 'properties' => [ + 'type' => [ + 'type' => 'string', + 'const' => ToolTypeEnum::functionDeclarations()->value, + 'description' => 'The type of tool.', + ], + 'functionDeclarations' => [ + 'type' => 'array', + 'items' => FunctionDeclaration::getJsonSchema(), + 'description' => 'Function declarations.', + ], + ], + 'required' => ['type', 'functionDeclarations'], + ], + [ + 'type' => 'object', + 'properties' => [ + 'type' => [ + 'type' => 'string', + 'const' => ToolTypeEnum::webSearch()->value, + 'description' => 'The type of tool.', + ], + 'webSearch' => WebSearch::getJsonSchema(), + ], + 'required' => ['type', 'webSearch'], + ], + ], + ]; + } +} diff --git a/src/Tools/DTO/WebSearch.php b/src/Tools/DTO/WebSearch.php new file mode 100644 index 00000000..ecc1526f --- /dev/null +++ b/src/Tools/DTO/WebSearch.php @@ -0,0 +1,95 @@ +allowedDomains = $allowedDomains; + $this->disallowedDomains = $disallowedDomains; + } + + /** + * Gets the allowed domains. + * + * @since n.e.x.t + * + * @return string[] The allowed domains. + */ + public function getAllowedDomains(): array + { + return $this->allowedDomains; + } + + /** + * Gets the disallowed domains. + * + * @since n.e.x.t + * + * @return string[] The disallowed domains. + */ + public function getDisallowedDomains(): array + { + return $this->disallowedDomains; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'allowedDomains' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'string', + ], + 'description' => 'List of domains that are allowed for web search.', + ], + 'disallowedDomains' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'string', + ], + 'description' => 'List of domains that are disallowed for web search.', + ], + ], + 'required' => [], + ]; + } +} diff --git a/tests/unit/Common/AbstractEnumTest.php b/tests/unit/Common/AbstractEnumTest.php index 3aa82a1d..4902c4b0 100644 --- a/tests/unit/Common/AbstractEnumTest.php +++ b/tests/unit/Common/AbstractEnumTest.php @@ -173,10 +173,7 @@ public function testGetValuesReturnsAllValidValues(): void { $values = ValidTestEnum::getValues(); - $this->assertSame([ - 'FIRST_NAME' => 'first', - 'LAST_NAME' => 'last', - ], $values); + $this->assertSame(['first', 'last'], $values); } /** diff --git a/tests/unit/EnumTestTrait.php b/tests/unit/EnumTestTrait.php index 3d282fdf..b73c16cb 100644 --- a/tests/unit/EnumTestTrait.php +++ b/tests/unit/EnumTestTrait.php @@ -35,7 +35,10 @@ public function testEnumHasExpectedValues(): void $actualValues = $enumClass::getValues(); - $this->assertEquals($expectedValues, $actualValues); + // Since getValues() now returns just the values, we need to extract values from expected + $expectedValuesList = array_values($expectedValues); + + $this->assertEquals($expectedValuesList, $actualValues); } /** diff --git a/tests/unit/Files/DTO/FileTest.php b/tests/unit/Files/DTO/FileTest.php new file mode 100644 index 00000000..15ee2ae4 --- /dev/null +++ b/tests/unit/Files/DTO/FileTest.php @@ -0,0 +1,270 @@ +assertEquals(FileTypeEnum::remote(), $file->getFileType()); + $this->assertEquals($url, $file->getUrl()); + $this->assertNull($file->getBase64Data()); + $this->assertNull($file->getDataUri()); + $this->assertEquals($mimeType, $file->getMimeType()); + $this->assertTrue($file->isImage()); + } + + /** + * Tests creating a File from a URL with inferred MIME type. + * + * @return void + */ + public function testCreateFromUrlWithInferredMimeType(): void + { + $url = 'https://example.com/document.pdf'; + + $file = new File($url); + + $this->assertEquals(FileTypeEnum::remote(), $file->getFileType()); + $this->assertEquals($url, $file->getUrl()); + $this->assertEquals('application/pdf', $file->getMimeType()); + $this->assertFalse($file->isText()); + } + + /** + * Tests creating a File from a data URI. + * + * @return void + */ + public function testCreateFromDataUri(): void + { + $base64Data = 'SGVsbG8gV29ybGQ='; + $dataUri = 'data:text/plain;base64,' . $base64Data; + + $file = new File($dataUri); + + $this->assertEquals(FileTypeEnum::inline(), $file->getFileType()); + $this->assertNull($file->getUrl()); + $this->assertEquals($base64Data, $file->getBase64Data()); + $this->assertEquals($dataUri, $file->getDataUri()); + $this->assertEquals('text/plain', $file->getMimeType()); + $this->assertTrue($file->isText()); + } + + /** + * Tests creating a File from a data URI with provided MIME type override. + * + * @return void + */ + public function testCreateFromDataUriWithMimeTypeOverride(): void + { + $base64Data = 'SGVsbG8gV29ybGQ='; + $dataUri = 'data:text/plain;base64,' . $base64Data; + $overrideMimeType = 'text/html'; + + $file = new File($dataUri, $overrideMimeType); + + $this->assertEquals(FileTypeEnum::inline(), $file->getFileType()); + $this->assertEquals($base64Data, $file->getBase64Data()); + $this->assertEquals($overrideMimeType, $file->getMimeType()); + $this->assertEquals('data:text/html;base64,' . $base64Data, $file->getDataUri()); + } + + /** + * Tests creating a File from plain base64 data. + * + * @return void + */ + public function testCreateFromPlainBase64(): void + { + $base64Data = 'SGVsbG8gV29ybGQ='; + $mimeType = 'text/plain'; + + $file = new File($base64Data, $mimeType); + + $this->assertEquals(FileTypeEnum::inline(), $file->getFileType()); + $this->assertNull($file->getUrl()); + $this->assertEquals($base64Data, $file->getBase64Data()); + $this->assertEquals('data:text/plain;base64,' . $base64Data, $file->getDataUri()); + $this->assertEquals($mimeType, $file->getMimeType()); + } + + /** + * Tests that plain base64 without MIME type throws exception. + * + * @return void + */ + public function testPlainBase64WithoutMimeTypeThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('MIME type is required when providing plain base64 data without data URI format.'); + + new File('SGVsbG8gV29ybGQ='); + } + + /** + * Tests creating a File from a local file path. + * + * @return void + */ + public function testCreateFromLocalFile(): void + { + // Create a temporary file + $tempFile = tempnam(sys_get_temp_dir(), 'test'); + file_put_contents($tempFile, 'Hello World'); + + try { + $file = new File($tempFile, 'text/plain'); + + $this->assertEquals(FileTypeEnum::inline(), $file->getFileType()); + $this->assertNull($file->getUrl()); + $this->assertEquals(base64_encode('Hello World'), $file->getBase64Data()); + $this->assertEquals('text/plain', $file->getMimeType()); + } finally { + unlink($tempFile); + } + } + + /** + * Tests that invalid file format throws exception. + * + * @return void + */ + public function testInvalidFileFormatThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid file provided. Expected URL, base64 data, or valid local file path.'); + + new File('not-a-valid-file-or-url', 'text/plain'); + } + + /** + * Tests that non-existent local file throws exception. + * + * @return void + */ + public function testNonExistentLocalFileThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid file provided. Expected URL, base64 data, or valid local file path.'); + + new File('/path/to/non/existent/file.txt', 'text/plain'); + } + + /** + * Tests that passing a directory throws exception. + * + * @return void + */ + public function testDirectoryThrowsException(): void + { + // Create a directory instead of a file + $tempDir = sys_get_temp_dir() . '/test_dir_' . uniqid(); + mkdir($tempDir); + + try { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid file provided. Expected URL, base64 data, or valid local file path.'); + + new File($tempDir, 'text/plain'); + } finally { + rmdir($tempDir); + } + } + + /** + * Tests MIME type methods. + * + * @return void + */ + public function testMimeTypeMethods(): void + { + $file = new File('https://example.com/video.mp4'); + + $this->assertEquals('video/mp4', $file->getMimeType()); + $this->assertInstanceOf(\WordPress\AiClient\Files\ValueObjects\MimeType::class, $file->getMimeTypeObject()); + $this->assertTrue($file->isVideo()); + $this->assertFalse($file->isImage()); + $this->assertFalse($file->isAudio()); + $this->assertFalse($file->isText()); + } + + /** + * Tests JSON schema. + * + * @return void + */ + public function testJsonSchema(): void + { + $schema = File::getJsonSchema(); + + $this->assertIsArray($schema); + $this->assertEquals('object', $schema['type']); + $this->assertArrayHasKey('oneOf', $schema); + $this->assertCount(2, $schema['oneOf']); + + // 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']); + + // 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']); + } + + /** + * Tests data URI without MIME type defaults correctly. + * + * @return void + */ + public function testDataUriWithoutMimeType(): void + { + $base64Data = 'SGVsbG8gV29ybGQ='; + $dataUri = 'data:;base64,' . $base64Data; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unable to determine MIME type. Please provide it explicitly.'); + + new File($dataUri); + } + + /** + * Tests URL with unknown extension. + * + * @return void + */ + public function testUrlWithUnknownExtension(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unable to determine MIME type. Please provide it explicitly.'); + + new File('https://example.com/file.unknown'); + } +} \ No newline at end of file diff --git a/tests/unit/Files/Enums/FileTypeEnumTest.php b/tests/unit/Files/Enums/FileTypeEnumTest.php new file mode 100644 index 00000000..c80101d2 --- /dev/null +++ b/tests/unit/Files/Enums/FileTypeEnumTest.php @@ -0,0 +1,56 @@ + 'inline', + 'REMOTE' => 'remote', + ]; + } + + /** + * Tests the specific enum methods. + * + * @return void + */ + public function testSpecificEnumMethods(): void + { + $inline = FileTypeEnum::inline(); + $this->assertTrue($inline->isInline()); + $this->assertFalse($inline->isRemote()); + + $remote = FileTypeEnum::remote(); + $this->assertTrue($remote->isRemote()); + $this->assertFalse($remote->isInline()); + } +} \ No newline at end of file diff --git a/tests/unit/Files/ValueObjects/MimeTypeTest.php b/tests/unit/Files/ValueObjects/MimeTypeTest.php new file mode 100644 index 00000000..42ffc2c0 --- /dev/null +++ b/tests/unit/Files/ValueObjects/MimeTypeTest.php @@ -0,0 +1,284 @@ +assertEquals($expected, (string) $mimeType); + } + + /** + * Provides valid MIME types. + * + * @return array + */ + public function validMimeTypeProvider(): array + { + return [ + 'simple type' => ['text/plain', 'text/plain'], + 'with uppercase' => ['TEXT/HTML', 'text/html'], + 'complex type' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'], + ]; + } + + /** + * Tests invalid MIME type throws exception. + * + * @dataProvider invalidMimeTypeProvider + * @param string $input + * @return void + */ + public function testInvalidMimeTypeThrowsException(string $input): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid MIME type: ' . $input); + + new MimeType($input); + } + + /** + * Provides invalid MIME types. + * + * @return array + */ + public function invalidMimeTypeProvider(): array + { + return [ + 'empty string' => [''], + 'no slash' => ['textplain'], + 'multiple slashes' => ['text/plain/extra'], + 'starts with slash' => ['/text/plain'], + 'ends with slash' => ['text/plain/'], + 'only type' => ['text/'], + 'only subtype' => ['/plain'], + 'invalid characters' => ['text/pl@in'], + ]; + } + + /** + * Tests creating MimeType from file extension. + * + * @dataProvider extensionProvider + * @param string $extension + * @param string $expectedMimeType + * @return void + */ + public function testFromExtension(string $extension, string $expectedMimeType): void + { + $mimeType = MimeType::fromExtension($extension); + $this->assertEquals($expectedMimeType, (string) $mimeType); + } + + /** + * Provides file extensions and expected MIME types. + * + * @return array + */ + public function extensionProvider(): array + { + return [ + // Text + ['txt', 'text/plain'], + ['html', 'text/html'], + ['css', 'text/css'], + ['js', 'application/javascript'], + ['json', 'application/json'], + ['xml', 'application/xml'], + ['csv', 'text/csv'], + + // Images + ['jpg', 'image/jpeg'], + ['jpeg', 'image/jpeg'], + ['png', 'image/png'], + ['gif', 'image/gif'], + ['webp', 'image/webp'], + ['svg', 'image/svg+xml'], + ['ico', 'image/x-icon'], + + // Documents + ['pdf', 'application/pdf'], + ['doc', 'application/msword'], + ['docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'], + ['xls', 'application/vnd.ms-excel'], + ['xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'], + + // Audio + ['mp3', 'audio/mpeg'], + ['wav', 'audio/wav'], + ['ogg', 'audio/ogg'], + + // Video + ['mp4', 'video/mp4'], + ['avi', 'video/x-msvideo'], + ['webm', 'video/webm'], + + // Archives + ['zip', 'application/zip'], + ['tar', 'application/x-tar'], + ['gz', 'application/gzip'], + ]; + } + + /** + * Tests unknown extension throws exception. + * + * @return void + */ + public function testUnknownExtensionThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown file extension: xyz'); + + MimeType::fromExtension('xyz'); + } + + /** + * Tests isValid method. + * + * @return void + */ + public function testIsValid(): void + { + $this->assertTrue(MimeType::isValid('text/plain')); + $this->assertTrue(MimeType::isValid('application/json')); + $this->assertFalse(MimeType::isValid('invalid')); + $this->assertFalse(MimeType::isValid('')); + } + + /** + * Tests isImage method. + * + * @return void + */ + public function testIsImage(): void + { + $this->assertTrue((new MimeType('image/jpeg'))->isImage()); + $this->assertTrue((new MimeType('image/png'))->isImage()); + $this->assertTrue((new MimeType('image/gif'))->isImage()); + $this->assertFalse((new MimeType('text/plain'))->isImage()); + $this->assertFalse((new MimeType('video/mp4'))->isImage()); + } + + /** + * Tests isVideo method. + * + * @return void + */ + public function testIsVideo(): void + { + $this->assertTrue((new MimeType('video/mp4'))->isVideo()); + $this->assertTrue((new MimeType('video/webm'))->isVideo()); + $this->assertFalse((new MimeType('image/jpeg'))->isVideo()); + $this->assertFalse((new MimeType('audio/mp3'))->isVideo()); + } + + /** + * Tests isAudio method. + * + * @return void + */ + public function testIsAudio(): void + { + $this->assertTrue((new MimeType('audio/mpeg'))->isAudio()); + $this->assertTrue((new MimeType('audio/wav'))->isAudio()); + $this->assertFalse((new MimeType('video/mp4'))->isAudio()); + $this->assertFalse((new MimeType('text/plain'))->isAudio()); + } + + /** + * Tests isText method. + * + * @return void + */ + public function testIsText(): void + { + $this->assertTrue((new MimeType('text/plain'))->isText()); + $this->assertTrue((new MimeType('text/html'))->isText()); + $this->assertFalse((new MimeType('application/json'))->isText()); + $this->assertFalse((new MimeType('application/xml'))->isText()); + $this->assertFalse((new MimeType('image/jpeg'))->isText()); + $this->assertFalse((new MimeType('video/mp4'))->isText()); + } + + /** + * Tests isDocument method. + * + * @return void + */ + public function testIsDocument(): void + { + $this->assertTrue((new MimeType('application/pdf'))->isDocument()); + $this->assertTrue((new MimeType('application/msword'))->isDocument()); + $this->assertTrue((new MimeType('application/vnd.openxmlformats-officedocument.wordprocessingml.document'))->isDocument()); + $this->assertFalse((new MimeType('text/plain'))->isDocument()); + $this->assertFalse((new MimeType('image/jpeg'))->isDocument()); + } + + /** + * Tests equals method. + * + * @return void + */ + public function testEquals(): void + { + $mimeType1 = new MimeType('text/plain'); + $mimeType2 = new MimeType('text/plain'); + $mimeType3 = new MimeType('text/html'); + + // Test with MimeType objects + $this->assertTrue($mimeType1->equals($mimeType2)); + $this->assertFalse($mimeType1->equals($mimeType3)); + + // Test with strings + $this->assertTrue($mimeType1->equals('text/plain')); + $this->assertTrue($mimeType1->equals('TEXT/PLAIN')); + $this->assertFalse($mimeType1->equals('text/html')); + + // Test with invalid types + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid MIME type comparison: integer'); + $mimeType1->equals(123); + } + + /** + * Tests toString method. + * + * @return void + */ + public function testToString(): void + { + $mimeType = new MimeType('TEXT/HTML'); + $this->assertEquals('text/html', (string) $mimeType); + } + + /** + * Tests normalizing values. + * + * @return void + */ + public function testNormalizesValues(): void + { + $mimeType = new MimeType('IMAGE/JPEG'); + $this->assertEquals('image/jpeg', (string) $mimeType); + } +} \ No newline at end of file diff --git a/tests/unit/Messages/DTO/MessagePartTest.php b/tests/unit/Messages/DTO/MessagePartTest.php new file mode 100644 index 00000000..0bdbccdf --- /dev/null +++ b/tests/unit/Messages/DTO/MessagePartTest.php @@ -0,0 +1,233 @@ +assertEquals(MessagePartTypeEnum::text(), $part->getType()); + $this->assertEquals($text, $part->getText()); + $this->assertNull($part->getFile()); + $this->assertNull($part->getFunctionCall()); + $this->assertNull($part->getFunctionResponse()); + } + + /** + * Tests creating MessagePart with File content. + * + * @return void + */ + public function testCreateWithFileContent(): void + { + $file = new File('https://example.com/image.jpg', 'image/jpeg'); + $part = new MessagePart($file); + + $this->assertEquals(MessagePartTypeEnum::file(), $part->getType()); + $this->assertNull($part->getText()); + $this->assertSame($file, $part->getFile()); + $this->assertNull($part->getFunctionCall()); + $this->assertNull($part->getFunctionResponse()); + } + + /** + * Tests creating MessagePart with FunctionCall content. + * + * @return void + */ + public function testCreateWithFunctionCallContent(): void + { + $functionCall = new FunctionCall('func_123', 'testFunction', ['param' => 'value']); + $part = new MessagePart($functionCall); + + $this->assertEquals(MessagePartTypeEnum::functionCall(), $part->getType()); + $this->assertNull($part->getText()); + $this->assertNull($part->getFile()); + $this->assertSame($functionCall, $part->getFunctionCall()); + $this->assertNull($part->getFunctionResponse()); + } + + /** + * Tests creating MessagePart with FunctionResponse content. + * + * @return void + */ + public function testCreateWithFunctionResponseContent(): void + { + $functionResponse = new FunctionResponse('func_123', 'testFunction', ['result' => 'success']); + $part = new MessagePart($functionResponse); + + $this->assertEquals(MessagePartTypeEnum::functionResponse(), $part->getType()); + $this->assertNull($part->getText()); + $this->assertNull($part->getFile()); + $this->assertNull($part->getFunctionCall()); + $this->assertSame($functionResponse, $part->getFunctionResponse()); + } + + /** + * Tests creating MessagePart with empty string. + * + * @return void + */ + public function testCreateWithEmptyString(): void + { + $part = new MessagePart(''); + + $this->assertEquals(MessagePartTypeEnum::text(), $part->getType()); + $this->assertEquals('', $part->getText()); + } + + /** + * Tests that unsupported content type throws exception. + * + * @dataProvider unsupportedContentProvider + * @param mixed $content + * @param string $expectedType + * @return void + */ + public function testUnsupportedContentThrowsException($content, string $expectedType): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf( + 'Unsupported content type %s. Expected string, File, FunctionCall, or FunctionResponse.', + $expectedType + )); + + new MessagePart($content); + } + + /** + * Provides unsupported content types. + * + * @return array + */ + public function unsupportedContentProvider(): array + { + return [ + 'integer' => [123, 'integer'], + 'float' => [3.14, 'double'], + 'boolean' => [true, 'boolean'], + 'array' => [['key' => 'value'], 'array'], + 'null' => [null, 'NULL'], + 'stdClass' => [new \stdClass(), 'stdClass'], + ]; + } + + /** + * Tests JSON schema. + * + * @return void + */ + public function testJsonSchema(): void + { + $schema = MessagePart::getJsonSchema(); + + $this->assertIsArray($schema); + $this->assertArrayHasKey('oneOf', $schema); + $this->assertCount(4, $schema['oneOf']); // text, file, function_call, function_response + + // 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']); + + // 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']); + + // 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']); + + // 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']); + } + + /** + * Tests with different file types. + * + * @return void + */ + public function testWithDifferentFileTypes(): void + { + // Remote file + $remoteFile = new File('https://example.com/doc.pdf', 'application/pdf'); + $part1 = new MessagePart($remoteFile); + $this->assertEquals('https://example.com/doc.pdf', $part1->getFile()->getUrl()); + + // Inline file + $inlineFile = new File('SGVsbG8gV29ybGQ=', 'text/plain'); + $part2 = new MessagePart($inlineFile); + $this->assertEquals('SGVsbG8gV29ybGQ=', $part2->getFile()->getBase64Data()); + } + + /** + * Tests with complex function call. + * + * @return void + */ + public function testWithComplexFunctionCall(): void + { + $complexArgs = [ + 'query' => 'SELECT * FROM users WHERE active = ?', + 'params' => [true], + 'options' => [ + 'timeout' => 30, + 'retries' => 3, + 'cache' => false + ] + ]; + + $functionCall = new FunctionCall('db_123', 'executeQuery', $complexArgs); + $part = new MessagePart($functionCall); + + $retrievedCall = $part->getFunctionCall(); + $this->assertNotNull($retrievedCall); + $this->assertEquals($complexArgs, $retrievedCall->getArgs()); + } + + /** + * Tests with Unicode text. + * + * @return void + */ + public function testWithUnicodeText(): void + { + $unicodeText = '你好世界 🌍 مرحبا بالعالم'; + $part = new MessagePart($unicodeText); + + $this->assertEquals($unicodeText, $part->getText()); + } +} \ No newline at end of file diff --git a/tests/unit/Messages/DTO/MessageTest.php b/tests/unit/Messages/DTO/MessageTest.php new file mode 100644 index 00000000..763696a7 --- /dev/null +++ b/tests/unit/Messages/DTO/MessageTest.php @@ -0,0 +1,230 @@ +assertEquals($role, $message->getRole()); + $this->assertCount(1, $message->getParts()); + $this->assertSame($part, $message->getParts()[0]); + } + + /** + * Tests creating Message with multiple parts. + * + * @return void + */ + public function testCreateWithMultipleParts(): void + { + $role = MessageRoleEnum::model(); + $parts = [ + new MessagePart('Here is the information you requested:'), + new MessagePart(new File('https://example.com/data.json', 'application/json')), + new MessagePart('Let me know if you need anything else.'), + ]; + + $message = new Message($role, $parts); + + $this->assertEquals($role, $message->getRole()); + $this->assertCount(3, $message->getParts()); + $this->assertEquals($parts, $message->getParts()); + } + + /** + * Tests creating Message with empty parts array. + * + * @return void + */ + public function testCreateWithEmptyParts(): void + { + $role = MessageRoleEnum::system(); + $message = new Message($role, []); + + $this->assertEquals($role, $message->getRole()); + $this->assertCount(0, $message->getParts()); + $this->assertEquals([], $message->getParts()); + } + + /** + * Tests with different roles. + * + * @dataProvider roleProvider + * @param MessageRoleEnum $role + * @return void + */ + public function testWithDifferentRoles(MessageRoleEnum $role): void + { + $part = new MessagePart('Test message'); + $message = new Message($role, [$part]); + + $this->assertEquals($role, $message->getRole()); + } + + /** + * Provides different message roles. + * + * @return array + */ + public function roleProvider(): array + { + return [ + 'system' => [MessageRoleEnum::system()], + 'user' => [MessageRoleEnum::user()], + 'model' => [MessageRoleEnum::model()], + ]; + } + + /** + * Tests complex message with all part types. + * + * @return void + */ + public function testComplexMessageWithAllPartTypes(): void + { + $role = MessageRoleEnum::model(); + $parts = [ + new MessagePart('I\'ll help you with that. Let me search for the information.'), + new MessagePart(new FunctionCall('search_123', 'webSearch', ['query' => 'latest PHP news'])), + new MessagePart(new FunctionResponse('search_123', 'webSearch', ['results' => ['item1', 'item2']])), + new MessagePart('Based on my search, here are the latest PHP news:'), + new MessagePart(new File('data:text/plain;base64,SGVsbG8=', 'text/plain')), + ]; + + $message = new Message($role, $parts); + + $this->assertCount(5, $message->getParts()); + + // Verify each part type + $this->assertEquals('I\'ll help you with that. Let me search for the information.', $message->getParts()[0]->getText()); + $this->assertInstanceOf(FunctionCall::class, $message->getParts()[1]->getFunctionCall()); + $this->assertInstanceOf(FunctionResponse::class, $message->getParts()[2]->getFunctionResponse()); + $this->assertEquals('Based on my search, here are the latest PHP news:', $message->getParts()[3]->getText()); + $this->assertInstanceOf(File::class, $message->getParts()[4]->getFile()); + } + + /** + * Tests JSON schema. + * + * @return void + */ + public function testJsonSchema(): void + { + $schema = Message::getJsonSchema(); + + $this->assertIsArray($schema); + $this->assertEquals('object', $schema['type']); + + // Check properties + $this->assertArrayHasKey('properties', $schema); + $this->assertArrayHasKey('role', $schema['properties']); + $this->assertArrayHasKey('parts', $schema['properties']); + + // Check role property + $roleSchema = $schema['properties']['role']; + $this->assertEquals('string', $roleSchema['type']); + $this->assertArrayHasKey('enum', $roleSchema); + $this->assertContains('system', $roleSchema['enum']); + $this->assertContains('user', $roleSchema['enum']); + $this->assertContains('model', $roleSchema['enum']); + + // Check parts property + $partsSchema = $schema['properties']['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']); + } + + /** + * Tests message with large number of parts. + * + * @return void + */ + public function testMessageWithManyParts(): void + { + $role = MessageRoleEnum::user(); + $parts = []; + + // Create 100 parts + for ($i = 0; $i < 100; $i++) { + $parts[] = new MessagePart("Part number $i"); + } + + $message = new Message($role, $parts); + + $this->assertCount(100, $message->getParts()); + $this->assertEquals('Part number 0', $message->getParts()[0]->getText()); + $this->assertEquals('Part number 99', $message->getParts()[99]->getText()); + } + + /** + * Tests preserving part order. + * + * @return void + */ + public function testPreservesPartOrder(): void + { + $parts = [ + new MessagePart('First'), + new MessagePart('Second'), + new MessagePart('Third'), + new MessagePart('Fourth'), + ]; + + $message = new Message(MessageRoleEnum::user(), $parts); + $retrievedParts = $message->getParts(); + + $this->assertEquals('First', $retrievedParts[0]->getText()); + $this->assertEquals('Second', $retrievedParts[1]->getText()); + $this->assertEquals('Third', $retrievedParts[2]->getText()); + $this->assertEquals('Fourth', $retrievedParts[3]->getText()); + } + + /** + * Tests model message with function response. + * + * @return void + */ + public function testModelMessageWithFunctionResponse(): void + { + $role = MessageRoleEnum::model(); + $functionResponse = new FunctionResponse( + 'calc_123', + 'calculate', + ['result' => 42, 'formula' => '6 * 7'] + ); + $part = new MessagePart($functionResponse); + + $message = new Message($role, [$part]); + + $this->assertTrue($message->getRole()->isModel()); + $this->assertNotNull($message->getParts()[0]->getFunctionResponse()); + } +} \ No newline at end of file diff --git a/tests/unit/Messages/DTO/ModelMessageTest.php b/tests/unit/Messages/DTO/ModelMessageTest.php new file mode 100644 index 00000000..9536b0e7 --- /dev/null +++ b/tests/unit/Messages/DTO/ModelMessageTest.php @@ -0,0 +1,117 @@ +assertEquals(MessageRoleEnum::model(), $message->getRole()); + $this->assertTrue($message->getRole()->isModel()); + } + + /** + * Tests ModelMessage with multiple parts. + * + * @return void + */ + public function testWithMultipleParts(): void + { + $parts = [ + new MessagePart('Let me help you with that.'), + new MessagePart('Here are the steps:'), + new MessagePart('1. First step'), + new MessagePart('2. Second step'), + ]; + + $message = new ModelMessage($parts); + + $this->assertCount(4, $message->getParts()); + $this->assertEquals($parts, $message->getParts()); + } + + /** + * Tests ModelMessage with empty parts. + * + * @return void + */ + public function testWithEmptyParts(): void + { + $message = new ModelMessage([]); + + $this->assertEquals(MessageRoleEnum::model(), $message->getRole()); + $this->assertCount(0, $message->getParts()); + } + + /** + * Tests ModelMessage inherits from Message. + * + * @return void + */ + public function testInheritsFromMessage(): void + { + $message = new ModelMessage([]); + + $this->assertInstanceOf(\WordPress\AiClient\Messages\DTO\Message::class, $message); + } + + /** + * Tests ModelMessage with various content types. + * + * @return 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' => []]); + + $parts = [ + new MessagePart('I found the following:'), + new MessagePart($file), + new MessagePart($functionCall), + new MessagePart($functionResponse), + ]; + + $message = new ModelMessage($parts); + + $this->assertEquals('I found the following:', $message->getParts()[0]->getText()); + $this->assertSame($file, $message->getParts()[1]->getFile()); + $this->assertSame($functionCall, $message->getParts()[2]->getFunctionCall()); + $this->assertSame($functionResponse, $message->getParts()[3]->getFunctionResponse()); + } + + /** + * Tests JSON schema is inherited from parent. + * + * @return void + */ + public function testJsonSchemaInheritance(): void + { + $schema = ModelMessage::getJsonSchema(); + $parentSchema = \WordPress\AiClient\Messages\DTO\Message::getJsonSchema(); + + $this->assertEquals($parentSchema, $schema); + } +} \ No newline at end of file diff --git a/tests/unit/Messages/DTO/SystemMessageTest.php b/tests/unit/Messages/DTO/SystemMessageTest.php new file mode 100644 index 00000000..5d80ef2b --- /dev/null +++ b/tests/unit/Messages/DTO/SystemMessageTest.php @@ -0,0 +1,166 @@ +assertEquals(MessageRoleEnum::system(), $message->getRole()); + $this->assertTrue($message->getRole()->isSystem()); + } + + /** + * Tests SystemMessage with multiple instruction parts. + * + * @return void + */ + public function testWithMultipleInstructionParts(): void + { + $parts = [ + new MessagePart('You are an expert in PHP programming.'), + new MessagePart('Always provide code examples when explaining concepts.'), + new MessagePart('Be concise and clear in your explanations.'), + new MessagePart('Follow PSR-12 coding standards.'), + ]; + + $message = new SystemMessage($parts); + + $this->assertCount(4, $message->getParts()); + $this->assertEquals($parts, $message->getParts()); + } + + /** + * Tests SystemMessage with empty parts. + * + * @return void + */ + public function testWithEmptyParts(): void + { + $message = new SystemMessage([]); + + $this->assertEquals(MessageRoleEnum::system(), $message->getRole()); + $this->assertCount(0, $message->getParts()); + } + + /** + * Tests SystemMessage inherits from Message. + * + * @return void + */ + public function testInheritsFromMessage(): void + { + $message = new SystemMessage([]); + + $this->assertInstanceOf(\WordPress\AiClient\Messages\DTO\Message::class, $message); + } + + /** + * Tests SystemMessage with complex instructions. + * + * @return void + */ + public function testWithComplexInstructions(): void + { + $parts = [ + new MessagePart('You are a specialized code review assistant with expertise in:'), + new MessagePart('- Security best practices'), + new MessagePart('- Performance optimization'), + new MessagePart('- Code maintainability'), + new MessagePart('When reviewing code, always check for:'), + new MessagePart('1. SQL injection vulnerabilities'), + new MessagePart('2. XSS vulnerabilities'), + new MessagePart('3. Performance bottlenecks'), + ]; + + $message = new SystemMessage($parts); + + $this->assertCount(8, $message->getParts()); + + // Verify each part + $this->assertEquals('You are a specialized code review assistant with expertise in:', $message->getParts()[0]->getText()); + $this->assertEquals('- Security best practices', $message->getParts()[1]->getText()); + $this->assertEquals('- Performance optimization', $message->getParts()[2]->getText()); + $this->assertEquals('- Code maintainability', $message->getParts()[3]->getText()); + $this->assertEquals('When reviewing code, always check for:', $message->getParts()[4]->getText()); + $this->assertEquals('1. SQL injection vulnerabilities', $message->getParts()[5]->getText()); + $this->assertEquals('2. XSS vulnerabilities', $message->getParts()[6]->getText()); + $this->assertEquals('3. Performance bottlenecks', $message->getParts()[7]->getText()); + } + + /** + * Tests JSON schema is inherited from parent. + * + * @return void + */ + public function testJsonSchemaInheritance(): void + { + $schema = SystemMessage::getJsonSchema(); + $parentSchema = \WordPress\AiClient\Messages\DTO\Message::getJsonSchema(); + + $this->assertEquals($parentSchema, $schema); + } + + /** + * Tests SystemMessage with single long instruction. + * + * @return void + */ + public function testWithSingleLongInstruction(): void + { + $longInstruction = 'You are an AI assistant specialized in helping developers understand ' . + 'and work with PHP code. Always provide clear explanations, use proper ' . + 'terminology, and ensure your code examples follow PSR-12 standards. ' . + 'When explaining complex concepts, break them down into simpler parts ' . + 'and provide practical examples. Be patient and thorough in your responses.'; + + $message = new SystemMessage([new MessagePart($longInstruction)]); + + $this->assertCount(1, $message->getParts()); + $this->assertEquals($longInstruction, $message->getParts()[0]->getText()); + } + + /** + * Tests that parts are preserved in order. + * + * @return void + */ + public function testPreservesPartOrder(): void + { + $parts = [ + new MessagePart('First instruction'), + new MessagePart('Second instruction'), + new MessagePart('Third instruction'), + new MessagePart('Fourth instruction'), + ]; + + $message = new SystemMessage($parts); + $retrievedParts = $message->getParts(); + + $this->assertEquals('First instruction', $retrievedParts[0]->getText()); + $this->assertEquals('Second instruction', $retrievedParts[1]->getText()); + $this->assertEquals('Third instruction', $retrievedParts[2]->getText()); + $this->assertEquals('Fourth instruction', $retrievedParts[3]->getText()); + } +} \ No newline at end of file diff --git a/tests/unit/Messages/DTO/UserMessageTest.php b/tests/unit/Messages/DTO/UserMessageTest.php new file mode 100644 index 00000000..2249d58c --- /dev/null +++ b/tests/unit/Messages/DTO/UserMessageTest.php @@ -0,0 +1,226 @@ +assertEquals(MessageRoleEnum::user(), $message->getRole()); + $this->assertTrue($message->getRole()->isUser()); + } + + /** + * Tests UserMessage with multiple parts. + * + * @return void + */ + public function testWithMultipleParts(): void + { + $parts = [ + new MessagePart('I have a question about this code:'), + new MessagePart('```php'), + new MessagePart('function calculateSum($a, $b) {'), + new MessagePart(' return $a + $b;'), + new MessagePart('}'), + new MessagePart('```'), + new MessagePart('How can I add type hints?'), + ]; + + $message = new UserMessage($parts); + + $this->assertCount(7, $message->getParts()); + $this->assertEquals($parts, $message->getParts()); + } + + /** + * Tests UserMessage with empty parts. + * + * @return void + */ + public function testWithEmptyParts(): void + { + $message = new UserMessage([]); + + $this->assertEquals(MessageRoleEnum::user(), $message->getRole()); + $this->assertCount(0, $message->getParts()); + } + + /** + * Tests UserMessage inherits from Message. + * + * @return void + */ + public function testInheritsFromMessage(): void + { + $message = new UserMessage([]); + + $this->assertInstanceOf(\WordPress\AiClient\Messages\DTO\Message::class, $message); + } + + /** + * Tests UserMessage with file attachment. + * + * @return void + */ + public function testWithFileAttachment(): void + { + $file = new File('https://example.com/document.pdf', 'application/pdf'); + + $parts = [ + new MessagePart('Can you analyze this document for me?'), + new MessagePart($file), + new MessagePart('I need a summary of the key points.'), + ]; + + $message = new UserMessage($parts); + + $this->assertCount(3, $message->getParts()); + $this->assertEquals('Can you analyze this document for me?', $message->getParts()[0]->getText()); + $this->assertSame($file, $message->getParts()[1]->getFile()); + $this->assertEquals('I need a summary of the key points.', $message->getParts()[2]->getText()); + } + + /** + * Tests UserMessage with image and text. + * + * @return void + */ + public function testWithImageAndText(): void + { + $imageFile = new File('data:image/png;base64,iVBORw0KGgoAAAANS', 'image/png'); + + $parts = [ + new MessagePart('What do you see in this image?'), + new MessagePart($imageFile), + ]; + + $message = new UserMessage($parts); + + $this->assertCount(2, $message->getParts()); + $this->assertNotNull($message->getParts()[0]->getText()); + $this->assertNotNull($message->getParts()[1]->getFile()); + $this->assertEquals('image/png', $message->getParts()[1]->getFile()->getMimeType()); + } + + /** + * Tests JSON schema is inherited from parent. + * + * @return void + */ + public function testJsonSchemaInheritance(): void + { + $schema = UserMessage::getJsonSchema(); + $parentSchema = \WordPress\AiClient\Messages\DTO\Message::getJsonSchema(); + + $this->assertEquals($parentSchema, $schema); + } + + /** + * Tests UserMessage with single question. + * + * @return void + */ + public function testWithSingleQuestion(): void + { + $question = 'What is the difference between abstract classes and interfaces in PHP?'; + + $message = new UserMessage([new MessagePart($question)]); + + $this->assertCount(1, $message->getParts()); + $this->assertEquals($question, $message->getParts()[0]->getText()); + } + + /** + * Tests UserMessage with code example request. + * + * @return void + */ + public function testWithCodeExampleRequest(): void + { + $parts = [ + new MessagePart('Can you show me an example of the Singleton pattern in PHP?'), + new MessagePart('Please include:'), + new MessagePart('1. Private constructor'), + new MessagePart('2. Static instance method'), + new MessagePart('3. Clone prevention'), + ]; + + $message = new UserMessage($parts); + + $this->assertCount(5, $message->getParts()); + $this->assertTrue($message->getRole()->isUser()); + } + + /** + * Tests that parts are preserved in order. + * + * @return void + */ + public function testPreservesPartOrder(): void + { + $parts = [ + new MessagePart('First part'), + new MessagePart('Second part'), + new MessagePart('Third part'), + new MessagePart('Fourth part'), + ]; + + $message = new UserMessage($parts); + $retrievedParts = $message->getParts(); + + $this->assertEquals('First part', $retrievedParts[0]->getText()); + $this->assertEquals('Second part', $retrievedParts[1]->getText()); + $this->assertEquals('Third part', $retrievedParts[2]->getText()); + $this->assertEquals('Fourth part', $retrievedParts[3]->getText()); + } + + /** + * Tests UserMessage with multiple files. + * + * @return void + */ + public function testWithMultipleFiles(): void + { + $file1 = new File('https://example.com/image1.jpg', 'image/jpeg'); + $file2 = new File('https://example.com/image2.png', 'image/png'); + $file3 = new File('data:application/pdf;base64,JVBERi0xLjMNCg==', 'application/pdf'); + + $parts = [ + new MessagePart('Please compare these images:'), + new MessagePart($file1), + new MessagePart($file2), + new MessagePart('And review this document:'), + new MessagePart($file3), + ]; + + $message = new UserMessage($parts); + + $this->assertCount(5, $message->getParts()); + $this->assertInstanceOf(File::class, $message->getParts()[1]->getFile()); + $this->assertInstanceOf(File::class, $message->getParts()[2]->getFile()); + $this->assertInstanceOf(File::class, $message->getParts()[4]->getFile()); + } +} \ No newline at end of file diff --git a/tests/unit/Messages/Enums/MessagePartTypeEnumTest.php b/tests/unit/Messages/Enums/MessagePartTypeEnumTest.php index 937c6dc7..e0551d24 100644 --- a/tests/unit/Messages/Enums/MessagePartTypeEnumTest.php +++ b/tests/unit/Messages/Enums/MessagePartTypeEnumTest.php @@ -34,8 +34,7 @@ protected function getExpectedValues(): array { return [ 'TEXT' => 'text', - 'INLINE_FILE' => 'inline_file', - 'REMOTE_FILE' => 'remote_file', + 'FILE' => 'file', 'FUNCTION_CALL' => 'function_call', 'FUNCTION_RESPONSE' => 'function_response', ]; @@ -50,11 +49,11 @@ public function testSpecificEnumMethods(): void { $text = MessagePartTypeEnum::text(); $this->assertTrue($text->isText()); - $this->assertFalse($text->isInlineFile()); + $this->assertFalse($text->isFile()); - $inlineFile = MessagePartTypeEnum::inlineFile(); - $this->assertTrue($inlineFile->isInlineFile()); - $this->assertFalse($inlineFile->isRemoteFile()); + $file = MessagePartTypeEnum::file(); + $this->assertTrue($file->isFile()); + $this->assertFalse($file->isText()); $functionCall = MessagePartTypeEnum::functionCall(); $this->assertTrue($functionCall->isFunctionCall()); diff --git a/tests/unit/Operations/DTO/GenerativeAiOperationTest.php b/tests/unit/Operations/DTO/GenerativeAiOperationTest.php new file mode 100644 index 00000000..be2d7970 --- /dev/null +++ b/tests/unit/Operations/DTO/GenerativeAiOperationTest.php @@ -0,0 +1,291 @@ +assertEquals('op_123', $operation->getId()); + $this->assertEquals(OperationStateEnum::starting(), $operation->getState()); + $this->assertNull($operation->getResult()); + } + + /** + * Tests creating operation in processing state. + * + * @return void + */ + public function testCreateInProcessingState(): void + { + $operation = new GenerativeAiOperation( + 'op_456', + OperationStateEnum::processing() + ); + + $this->assertEquals('op_456', $operation->getId()); + $this->assertTrue($operation->getState()->isProcessing()); + $this->assertNull($operation->getResult()); + } + + /** + * Tests creating operation in succeeded state with result. + * + * @return void + */ + public function testCreateInSucceededStateWithResult(): void + { + $modelMessage = new ModelMessage([ + new MessagePart('Generated content') + ]); + $candidate = new Candidate( + $modelMessage, + FinishReasonEnum::stop(), + 42 + ); + $tokenUsage = new TokenUsage(10, 42, 52); + $result = new GenerativeAiResult( + 'result_123', + [$candidate], + $tokenUsage, + ['provider' => 'test'] + ); + + $operation = new GenerativeAiOperation( + 'op_789', + OperationStateEnum::succeeded(), + $result + ); + + $this->assertEquals('op_789', $operation->getId()); + $this->assertTrue($operation->getState()->isSucceeded()); + $this->assertSame($result, $operation->getResult()); + } + + /** + * Tests creating operation in failed state. + * + * @return void + */ + public function testCreateInFailedState(): void + { + $operation = new GenerativeAiOperation( + 'op_failed', + OperationStateEnum::failed() + ); + + $this->assertEquals('op_failed', $operation->getId()); + $this->assertTrue($operation->getState()->isFailed()); + $this->assertNull($operation->getResult()); + } + + /** + * Tests creating operation in canceled state. + * + * @return void + */ + public function testCreateInCanceledState(): void + { + $operation = new GenerativeAiOperation( + 'op_canceled', + OperationStateEnum::canceled() + ); + + $this->assertEquals('op_canceled', $operation->getId()); + $this->assertTrue($operation->getState()->isCanceled()); + $this->assertNull($operation->getResult()); + } + + /** + * Tests operation implements OperationInterface. + * + * @return void + */ + public function testImplementsOperationInterface(): void + { + $operation = new GenerativeAiOperation( + 'op_test', + OperationStateEnum::starting() + ); + + $this->assertInstanceOf( + \WordPress\AiClient\Operations\Contracts\OperationInterface::class, + $operation + ); + } + + /** + * Tests operation with different ID formats. + * + * @dataProvider idProvider + * @param string $id + * @return void + */ + public function testWithDifferentIdFormats(string $id): void + { + $operation = new GenerativeAiOperation( + $id, + OperationStateEnum::processing() + ); + + $this->assertEquals($id, $operation->getId()); + } + + /** + * Provides different ID formats. + * + * @return array + */ + public function idProvider(): array + { + return [ + 'uuid' => ['550e8400-e29b-41d4-a716-446655440000'], + 'alphanumeric' => ['op_abc123xyz'], + 'numeric' => ['123456789'], + 'with_special_chars' => ['op-2024-01-15_15:30:45'], + 'short' => ['op1'], + 'long' => ['operation_very_long_identifier_with_many_parts_12345'], + ]; + } + + /** + * Tests operation state transitions. + * + * @return void + */ + public function testStateTransitions(): void + { + // Starting -> Processing + $operation1 = new GenerativeAiOperation( + 'op_transition_1', + OperationStateEnum::starting() + ); + $this->assertTrue($operation1->getState()->isStarting()); + + // Processing -> Succeeded with result + $modelMessage = new ModelMessage([ + new MessagePart('Result') + ]); + $tokenUsage = new TokenUsage(5, 10, 15); + $result = new GenerativeAiResult( + 'result_transition', + [new Candidate($modelMessage, FinishReasonEnum::stop(), 10)], + $tokenUsage + ); + $operation2 = new GenerativeAiOperation( + 'op_transition_2', + OperationStateEnum::succeeded(), + $result + ); + $this->assertTrue($operation2->getState()->isSucceeded()); + $this->assertNotNull($operation2->getResult()); + + // Processing -> Failed + $operation3 = new GenerativeAiOperation( + 'op_transition_3', + OperationStateEnum::failed() + ); + $this->assertTrue($operation3->getState()->isFailed()); + $this->assertNull($operation3->getResult()); + } + + /** + * Tests JSON schema for succeeded state. + * + * @return void + */ + public function testJsonSchemaForSucceededState(): void + { + $schema = GenerativeAiOperation::getJsonSchema(); + + $this->assertArrayHasKey('oneOf', $schema); + $this->assertCount(2, $schema['oneOf']); + + // First schema is for succeeded state with result + $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']); + + // State should be const for succeeded + $this->assertEquals( + OperationStateEnum::succeeded()->value, + $succeededSchema['properties']['state']['const'] + ); + + // Required fields + $this->assertEquals(['id', 'state', 'result'], $succeededSchema['required']); + } + + /** + * Tests JSON schema for non-succeeded states. + * + * @return void + */ + public function testJsonSchemaForNonSucceededStates(): void + { + $schema = GenerativeAiOperation::getJsonSchema(); + + // Second schema is for all other states without result + $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']); + + // State should be enum for other states + $stateEnum = $otherStatesSchema['properties']['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']); + } + + /** + * Tests operation with empty string ID. + * + * @return void + */ + public function testWithEmptyStringId(): void + { + $operation = new GenerativeAiOperation( + '', + OperationStateEnum::starting() + ); + + $this->assertEquals('', $operation->getId()); + } +} \ No newline at end of file diff --git a/tests/unit/Results/DTO/CandidateTest.php b/tests/unit/Results/DTO/CandidateTest.php new file mode 100644 index 00000000..7a1fcd75 --- /dev/null +++ b/tests/unit/Results/DTO/CandidateTest.php @@ -0,0 +1,332 @@ +assertSame($message, $candidate->getMessage()); + $this->assertEquals(FinishReasonEnum::stop(), $candidate->getFinishReason()); + $this->assertEquals(25, $candidate->getTokenCount()); + } + + /** + * Tests candidate with different finish reasons. + * + * @dataProvider finishReasonProvider + * @param FinishReasonEnum $finishReason + * @return void + */ + public function testWithDifferentFinishReasons(FinishReasonEnum $finishReason): void + { + $message = new ModelMessage([new MessagePart('Response')]); + + $candidate = new Candidate($message, $finishReason, 10); + + $this->assertEquals($finishReason, $candidate->getFinishReason()); + } + + /** + * Provides different finish reasons. + * + * @return array + */ + public function finishReasonProvider(): array + { + return [ + 'stop' => [FinishReasonEnum::stop()], + 'length' => [FinishReasonEnum::length()], + 'content_filter' => [FinishReasonEnum::contentFilter()], + 'tool_calls' => [FinishReasonEnum::toolCalls()], + 'error' => [FinishReasonEnum::error()], + ]; + } + + /** + * Tests candidate with complex message. + * + * @return void + */ + public function testWithComplexMessage(): void + { + $functionCall = new FunctionCall( + 'func_123', + 'searchWeb', + ['query' => 'PHP best practices'] + ); + + $message = new ModelMessage([ + new MessagePart('Let me search for that information.'), + new MessagePart($functionCall), + new MessagePart('Based on my search, here are the PHP best practices:'), + new MessagePart('1. Follow PSR standards'), + new MessagePart('2. Use type declarations'), + new MessagePart('3. Write unit tests'), + ]); + + $candidate = new Candidate( + $message, + FinishReasonEnum::toolCalls(), + 150 + ); + + $this->assertCount(6, $candidate->getMessage()->getParts()); + $this->assertTrue($candidate->getFinishReason()->isToolCalls()); + $this->assertEquals(150, $candidate->getTokenCount()); + } + + /** + * Tests candidate with message containing files. + * + * @return void + */ + public function testWithMessageContainingFiles(): void + { + $file = new File('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVQI12P4DwABAQEAG7buVgAAAABJRU5ErkJggg==', 'image/png'); + + $message = new ModelMessage([ + new MessagePart('I\'ve generated the requested image:'), + new MessagePart($file), + new MessagePart('The image shows a flowchart of the process.'), + ]); + + $candidate = new Candidate( + $message, + FinishReasonEnum::stop(), + 85 + ); + + $parts = $candidate->getMessage()->getParts(); + $this->assertEquals('I\'ve generated the requested image:', $parts[0]->getText()); + $this->assertSame($file, $parts[1]->getFile()); + $this->assertEquals('The image shows a flowchart of the process.', $parts[2]->getText()); + } + + /** + * Tests candidate with different token counts. + * + * @dataProvider tokenCountProvider + * @param int $tokenCount + * @return void + */ + public function testWithDifferentTokenCounts(int $tokenCount): void + { + $message = new ModelMessage([new MessagePart('Response')]); + + $candidate = new Candidate( + $message, + FinishReasonEnum::stop(), + $tokenCount + ); + + $this->assertEquals($tokenCount, $candidate->getTokenCount()); + } + + /** + * Provides different token counts. + * + * @return array + */ + public function tokenCountProvider(): array + { + return [ + 'zero' => [0], + 'small' => [10], + 'medium' => [500], + 'large' => [4000], + 'very_large' => [100000], + ]; + } + + /** + * Tests candidate rejects non-model message. + * + * @return void + */ + public function testRejectsNonModelMessage(): void + { + $userMessage = new UserMessage([ + new MessagePart('This is a user message.') + ]); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Message must be a model message.'); + + new Candidate( + $userMessage, + FinishReasonEnum::stop(), + 10 + ); + } + + /** + * Tests candidate with message using different role. + * + * @return void + */ + public function testRejectsMessageWithDifferentRole(): void + { + $message = new Message( + MessageRoleEnum::user(), + [new MessagePart('User message')] + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Message must be a model message.'); + + new Candidate( + $message, + FinishReasonEnum::stop(), + 10 + ); + } + + /** + * Tests JSON schema. + * + * @return void + */ + public function testJsonSchema(): void + { + $schema = Candidate::getJsonSchema(); + + $this->assertIsArray($schema); + $this->assertEquals('object', $schema['type']); + + // Check properties + $this->assertArrayHasKey('properties', $schema); + $this->assertArrayHasKey('message', $schema['properties']); + $this->assertArrayHasKey('finishReason', $schema['properties']); + $this->assertArrayHasKey('tokenCount', $schema['properties']); + + // Check finishReason property + $finishReasonSchema = $schema['properties']['finishReason']; + $this->assertEquals('string', $finishReasonSchema['type']); + $this->assertArrayHasKey('enum', $finishReasonSchema); + $this->assertContains('stop', $finishReasonSchema['enum']); + $this->assertContains('length', $finishReasonSchema['enum']); + $this->assertContains('content_filter', $finishReasonSchema['enum']); + $this->assertContains('tool_calls', $finishReasonSchema['enum']); + $this->assertContains('error', $finishReasonSchema['enum']); + + // Check tokenCount property + $tokenCountSchema = $schema['properties']['tokenCount']; + $this->assertEquals('integer', $tokenCountSchema['type']); + + // Check required fields + $this->assertArrayHasKey('required', $schema); + $this->assertEquals(['message', 'finishReason', 'tokenCount'], $schema['required']); + } + + /** + * Tests candidate with empty message parts. + * + * @return void + */ + public function testWithEmptyMessageParts(): void + { + $message = new ModelMessage([]); + + $candidate = new Candidate( + $message, + FinishReasonEnum::stop(), + 0 + ); + + $this->assertCount(0, $candidate->getMessage()->getParts()); + $this->assertEquals(0, $candidate->getTokenCount()); + } + + /** + * Tests candidate with max length finish reason. + * + * @return void + */ + public function testWithMaxLengthFinishReason(): void + { + $message = new ModelMessage([ + new MessagePart('This is a long response that was cut off due to reaching the maximum token limit...') + ]); + + $candidate = new Candidate( + $message, + FinishReasonEnum::length(), + 4096 + ); + + $this->assertTrue($candidate->getFinishReason()->isLength()); + $this->assertEquals(4096, $candidate->getTokenCount()); + } + + /** + * Tests candidate with content filter finish reason. + * + * @return void + */ + public function testWithContentFilterFinishReason(): void + { + $message = new ModelMessage([ + new MessagePart('I cannot provide that information.') + ]); + + $candidate = new Candidate( + $message, + FinishReasonEnum::contentFilter(), + 8 + ); + + $this->assertTrue($candidate->getFinishReason()->isContentFilter()); + } + + /** + * Tests candidate with error finish reason. + * + * @return void + */ + public function testWithErrorFinishReason(): void + { + $message = new ModelMessage([ + new MessagePart('An error occurred while generating the response.') + ]); + + $candidate = new Candidate( + $message, + FinishReasonEnum::error(), + 9 + ); + + $this->assertTrue($candidate->getFinishReason()->isError()); + } +} \ No newline at end of file diff --git a/tests/unit/Results/DTO/GenerativeAiResultTest.php b/tests/unit/Results/DTO/GenerativeAiResultTest.php new file mode 100644 index 00000000..61d67580 --- /dev/null +++ b/tests/unit/Results/DTO/GenerativeAiResultTest.php @@ -0,0 +1,597 @@ +assertEquals('result_123', $result->getId()); + $this->assertCount(1, $result->getCandidates()); + $this->assertSame($candidate, $result->getCandidates()[0]); + $this->assertSame($tokenUsage, $result->getTokenUsage()); + $this->assertEquals([], $result->getProviderMetadata()); + } + + /** + * Tests creating result with multiple candidates. + * + * @return void + */ + public function testCreateWithMultipleCandidates(): void + { + $candidates = []; + for ($i = 1; $i <= 3; $i++) { + $message = new ModelMessage([ + new MessagePart("Response variant $i") + ]); + $candidates[] = new Candidate($message, FinishReasonEnum::stop(), $i * 10); + } + $tokenUsage = new TokenUsage(20, 90, 110); + + $result = new GenerativeAiResult( + 'result_multi', + $candidates, + $tokenUsage + ); + + $this->assertCount(3, $result->getCandidates()); + $this->assertEquals(3, $result->getCandidateCount()); + $this->assertTrue($result->hasMultipleCandidates()); + } + + /** + * Tests creating result with provider metadata. + * + * @return void + */ + public function testCreateWithProviderMetadata(): void + { + $message = new ModelMessage([new MessagePart('Response')]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 5); + $tokenUsage = new TokenUsage(10, 5, 15); + $metadata = [ + 'model' => 'gpt-4', + 'temperature' => 0.7, + 'max_tokens' => 1000, + 'custom_data' => ['key' => 'value'] + ]; + + $result = new GenerativeAiResult( + 'result_meta', + [$candidate], + $tokenUsage, + $metadata + ); + + $this->assertEquals($metadata, $result->getProviderMetadata()); + } + + /** + * Tests result rejects empty candidates array. + * + * @return void + */ + public function testRejectsEmptyCandidatesArray(): void + { + $tokenUsage = new TokenUsage(0, 0, 0); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('At least one candidate must be provided'); + + new GenerativeAiResult('result_empty', [], $tokenUsage); + } + + /** + * Tests toText method. + * + * @return void + */ + public function testToText(): void + { + $text = 'This is the extracted text content.'; + $message = new ModelMessage([ + new MessagePart($text) + ]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 8); + $tokenUsage = new TokenUsage(10, 8, 18); + + $result = new GenerativeAiResult( + 'result_text', + [$candidate], + $tokenUsage + ); + + $this->assertEquals($text, $result->toText()); + } + + /** + * Tests toText throws exception when no text content. + * + * @return void + */ + public function testToTextThrowsExceptionWhenNoTextContent(): void + { + $file = new File('https://example.com/image.jpg', 'image/jpeg'); + $message = new ModelMessage([ + new MessagePart($file) + ]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 5); + $tokenUsage = new TokenUsage(10, 5, 15); + + $result = new GenerativeAiResult( + 'result_no_text', + [$candidate], + $tokenUsage + ); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('No text content found in first candidate'); + + $result->toText(); + } + + /** + * Tests toFile method. + * + * @return void + */ + public function testToFile(): void + { + $file = new File('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVQI12P4DwABAQEAG7buVgAAAABJRU5ErkJggg==', 'image/png'); + $message = new ModelMessage([ + new MessagePart('Here is the generated image:'), + new MessagePart($file) + ]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 20); + $tokenUsage = new TokenUsage(15, 20, 35); + + $result = new GenerativeAiResult( + 'result_file', + [$candidate], + $tokenUsage + ); + + $this->assertSame($file, $result->toFile()); + } + + /** + * Tests toFile throws exception when no file content. + * + * @return void + */ + public function testToFileThrowsExceptionWhenNoFileContent(): void + { + $message = new ModelMessage([ + new MessagePart('Just text, no file.') + ]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 5); + $tokenUsage = new TokenUsage(10, 5, 15); + + $result = new GenerativeAiResult( + 'result_no_file', + [$candidate], + $tokenUsage + ); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('No file content found in first candidate'); + + $result->toFile(); + } + + /** + * Tests toImageFile method. + * + * @return void + */ + public function testToImageFile(): void + { + $imageFile = new File('https://example.com/photo.jpg', 'image/jpeg'); + $message = new ModelMessage([ + new MessagePart($imageFile) + ]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 10); + $tokenUsage = new TokenUsage(5, 10, 15); + + $result = new GenerativeAiResult( + 'result_image', + [$candidate], + $tokenUsage + ); + + $this->assertSame($imageFile, $result->toImageFile()); + } + + /** + * Tests toImageFile throws exception for non-image file. + * + * @return void + */ + public function testToImageFileThrowsExceptionForNonImageFile(): void + { + $pdfFile = new File('https://example.com/document.pdf', 'application/pdf'); + $message = new ModelMessage([ + new MessagePart($pdfFile) + ]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 10); + $tokenUsage = new TokenUsage(5, 10, 15); + + $result = new GenerativeAiResult( + 'result_pdf', + [$candidate], + $tokenUsage + ); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('File is not an image. MIME type: application/pdf'); + + $result->toImageFile(); + } + + /** + * Tests toAudioFile method. + * + * @return void + */ + public function testToAudioFile(): void + { + $audioFile = new File('https://example.com/song.mp3', 'audio/mpeg'); + $message = new ModelMessage([ + new MessagePart($audioFile) + ]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 10); + $tokenUsage = new TokenUsage(5, 10, 15); + + $result = new GenerativeAiResult( + 'result_audio', + [$candidate], + $tokenUsage + ); + + $this->assertSame($audioFile, $result->toAudioFile()); + } + + /** + * Tests toVideoFile method. + * + * @return void + */ + public function testToVideoFile(): void + { + $videoFile = new File('https://example.com/video.mp4', 'video/mp4'); + $message = new ModelMessage([ + new MessagePart($videoFile) + ]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 10); + $tokenUsage = new TokenUsage(5, 10, 15); + + $result = new GenerativeAiResult( + 'result_video', + [$candidate], + $tokenUsage + ); + + $this->assertSame($videoFile, $result->toVideoFile()); + } + + /** + * Tests toMessage method. + * + * @return void + */ + public function testToMessage(): void + { + $message = new ModelMessage([ + new MessagePart('Response message') + ]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 3); + $tokenUsage = new TokenUsage(5, 3, 8); + + $result = new GenerativeAiResult( + 'result_msg', + [$candidate], + $tokenUsage + ); + + $this->assertSame($message, $result->toMessage()); + } + + /** + * Tests toTexts method with multiple candidates. + * + * @return void + */ + public function testToTextsWithMultipleCandidates(): void + { + $texts = ['First response', 'Second response', 'Third response']; + $candidates = []; + + foreach ($texts as $text) { + $message = new ModelMessage([ + new MessagePart($text) + ]); + $candidates[] = new Candidate($message, FinishReasonEnum::stop(), 5); + } + + $tokenUsage = new TokenUsage(20, 15, 35); + $result = new GenerativeAiResult( + 'result_texts', + $candidates, + $tokenUsage + ); + + $this->assertEquals($texts, $result->toTexts()); + } + + /** + * Tests toFiles method with multiple candidates. + * + * @return void + */ + public function testToFilesWithMultipleCandidates(): void + { + $file1 = new File('https://example.com/image1.jpg', 'image/jpeg'); + $file2 = new File('https://example.com/image2.png', 'image/png'); + $file3 = new File('https://example.com/doc.pdf', 'application/pdf'); + + $candidates = []; + foreach ([$file1, $file2, $file3] as $file) { + $message = new ModelMessage([ + new MessagePart('Generated file:'), + new MessagePart($file) + ]); + $candidates[] = new Candidate($message, FinishReasonEnum::stop(), 10); + } + + $tokenUsage = new TokenUsage(30, 30, 60); + $result = new GenerativeAiResult( + 'result_files', + $candidates, + $tokenUsage + ); + + $files = $result->toFiles(); + $this->assertCount(3, $files); + $this->assertSame($file1, $files[0]); + $this->assertSame($file2, $files[1]); + $this->assertSame($file3, $files[2]); + } + + /** + * Tests toImageFiles filters only image files. + * + * @return void + */ + public function testToImageFilesFiltersOnlyImages(): void + { + $imageFile1 = new File('https://example.com/image1.jpg', 'image/jpeg'); + $pdfFile = new File('https://example.com/doc.pdf', 'application/pdf'); + $imageFile2 = new File('https://example.com/image2.png', 'image/png'); + + $candidates = []; + foreach ([$imageFile1, $pdfFile, $imageFile2] as $file) { + $message = new ModelMessage([new MessagePart($file)]); + $candidates[] = new Candidate($message, FinishReasonEnum::stop(), 10); + } + + $tokenUsage = new TokenUsage(30, 30, 60); + $result = new GenerativeAiResult( + 'result_mixed', + $candidates, + $tokenUsage + ); + + $images = $result->toImageFiles(); + $this->assertCount(2, $images); + $this->assertSame($imageFile1, $images[0]); + $this->assertSame($imageFile2, $images[1]); + } + + /** + * Tests toAudioFiles filters only audio files. + * + * @return void + */ + public function testToAudioFilesFiltersOnlyAudio(): void + { + $audioFile1 = new File('https://example.com/song.mp3', 'audio/mpeg'); + $imageFile = new File('https://example.com/image.jpg', 'image/jpeg'); + $audioFile2 = new File('https://example.com/podcast.wav', 'audio/wav'); + + $candidates = []; + foreach ([$audioFile1, $imageFile, $audioFile2] as $file) { + $message = new ModelMessage([new MessagePart($file)]); + $candidates[] = new Candidate($message, FinishReasonEnum::stop(), 10); + } + + $tokenUsage = new TokenUsage(30, 30, 60); + $result = new GenerativeAiResult( + 'result_audio_mix', + $candidates, + $tokenUsage + ); + + $audioFiles = $result->toAudioFiles(); + $this->assertCount(2, $audioFiles); + $this->assertSame($audioFile1, $audioFiles[0]); + $this->assertSame($audioFile2, $audioFiles[1]); + } + + /** + * Tests toVideoFiles filters only video files. + * + * @return void + */ + public function testToVideoFilesFiltersOnlyVideo(): void + { + $videoFile1 = new File('https://example.com/movie.mp4', 'video/mp4'); + $imageFile = new File('https://example.com/image.jpg', 'image/jpeg'); + $videoFile2 = new File('https://example.com/clip.webm', 'video/webm'); + + $candidates = []; + foreach ([$videoFile1, $imageFile, $videoFile2] as $file) { + $message = new ModelMessage([new MessagePart($file)]); + $candidates[] = new Candidate($message, FinishReasonEnum::stop(), 10); + } + + $tokenUsage = new TokenUsage(30, 30, 60); + $result = new GenerativeAiResult( + 'result_video_mix', + $candidates, + $tokenUsage + ); + + $videoFiles = $result->toVideoFiles(); + $this->assertCount(2, $videoFiles); + $this->assertSame($videoFile1, $videoFiles[0]); + $this->assertSame($videoFile2, $videoFiles[1]); + } + + /** + * Tests toMessages method. + * + * @return void + */ + public function testToMessages(): void + { + $messages = []; + $candidates = []; + + for ($i = 1; $i <= 3; $i++) { + $message = new ModelMessage([ + new MessagePart("Message $i") + ]); + $messages[] = $message; + $candidates[] = new Candidate($message, FinishReasonEnum::stop(), 5); + } + + $tokenUsage = new TokenUsage(15, 15, 30); + $result = new GenerativeAiResult( + 'result_messages', + $candidates, + $tokenUsage + ); + + $extractedMessages = $result->toMessages(); + $this->assertCount(3, $extractedMessages); + foreach ($messages as $index => $message) { + $this->assertSame($message, $extractedMessages[$index]); + } + } + + /** + * Tests JSON schema. + * + * @return void + */ + public function testJsonSchema(): void + { + $schema = GenerativeAiResult::getJsonSchema(); + + $this->assertIsArray($schema); + $this->assertEquals('object', $schema['type']); + + // 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']); + + // Check id property + $this->assertEquals('string', $schema['properties']['id']['type']); + + // Check candidates property + $candidatesSchema = $schema['properties']['candidates']; + $this->assertEquals('array', $candidatesSchema['type']); + $this->assertEquals(1, $candidatesSchema['minItems']); + + // Check providerMetadata property + $metadataSchema = $schema['properties']['providerMetadata']; + $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']); + } + + /** + * Tests result implements ResultInterface. + * + * @return void + */ + public function testImplementsResultInterface(): void + { + $message = new ModelMessage([new MessagePart('Test')]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 1); + $tokenUsage = new TokenUsage(1, 1, 2); + + $result = new GenerativeAiResult( + 'result_interface', + [$candidate], + $tokenUsage + ); + + $this->assertInstanceOf( + \WordPress\AiClient\Results\Contracts\ResultInterface::class, + $result + ); + } + + /** + * Tests hasMultipleCandidates returns false for single candidate. + * + * @return void + */ + public function testHasMultipleCandidatesReturnsFalseForSingle(): void + { + $message = new ModelMessage([new MessagePart('Single response')]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 3); + $tokenUsage = new TokenUsage(5, 3, 8); + + $result = new GenerativeAiResult( + 'result_single', + [$candidate], + $tokenUsage + ); + + $this->assertFalse($result->hasMultipleCandidates()); + $this->assertEquals(1, $result->getCandidateCount()); + } +} \ No newline at end of file diff --git a/tests/unit/Results/DTO/TokenUsageTest.php b/tests/unit/Results/DTO/TokenUsageTest.php new file mode 100644 index 00000000..af299c1f --- /dev/null +++ b/tests/unit/Results/DTO/TokenUsageTest.php @@ -0,0 +1,235 @@ +assertEquals(100, $tokenUsage->getPromptTokens()); + $this->assertEquals(50, $tokenUsage->getCompletionTokens()); + $this->assertEquals(150, $tokenUsage->getTotalTokens()); + } + + /** + * Tests creating TokenUsage with zero values. + * + * @return void + */ + public function testCreateWithZeroValues(): void + { + $tokenUsage = new TokenUsage(0, 0, 0); + + $this->assertEquals(0, $tokenUsage->getPromptTokens()); + $this->assertEquals(0, $tokenUsage->getCompletionTokens()); + $this->assertEquals(0, $tokenUsage->getTotalTokens()); + } + + /** + * Tests creating TokenUsage with large values. + * + * @return void + */ + public function testCreateWithLargeValues(): void + { + $tokenUsage = new TokenUsage(1000000, 500000, 1500000); + + $this->assertEquals(1000000, $tokenUsage->getPromptTokens()); + $this->assertEquals(500000, $tokenUsage->getCompletionTokens()); + $this->assertEquals(1500000, $tokenUsage->getTotalTokens()); + } + + /** + * Tests different token usage scenarios. + * + * @dataProvider tokenUsageScenarioProvider + * @param int $promptTokens + * @param int $completionTokens + * @param int $totalTokens + * @return void + */ + public function testDifferentTokenUsageScenarios(int $promptTokens, int $completionTokens, int $totalTokens): void + { + $tokenUsage = new TokenUsage($promptTokens, $completionTokens, $totalTokens); + + $this->assertEquals($promptTokens, $tokenUsage->getPromptTokens()); + $this->assertEquals($completionTokens, $tokenUsage->getCompletionTokens()); + $this->assertEquals($totalTokens, $tokenUsage->getTotalTokens()); + } + + /** + * Provides different token usage scenarios. + * + * @return array + */ + public function tokenUsageScenarioProvider(): array + { + return [ + 'small_prompt_large_completion' => [10, 1000, 1010], + 'large_prompt_small_completion' => [1000, 10, 1010], + 'equal_prompt_and_completion' => [500, 500, 1000], + 'only_prompt_tokens' => [100, 0, 100], + 'only_completion_tokens' => [0, 100, 100], + 'typical_chat_response' => [250, 750, 1000], + 'code_generation' => [50, 2000, 2050], + 'summarization' => [5000, 150, 5150], + 'max_context_window' => [4096, 4096, 8192], + 'minimal_usage' => [1, 1, 2], + ]; + } + + /** + * Tests JSON schema. + * + * @return void + */ + public function testJsonSchema(): void + { + $schema = TokenUsage::getJsonSchema(); + + $this->assertIsArray($schema); + $this->assertEquals('object', $schema['type']); + + // Check properties + $this->assertArrayHasKey('properties', $schema); + $this->assertArrayHasKey('promptTokens', $schema['properties']); + $this->assertArrayHasKey('completionTokens', $schema['properties']); + $this->assertArrayHasKey('totalTokens', $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']); + + // Check descriptions + $this->assertArrayHasKey('description', $schema['properties']['promptTokens']); + $this->assertArrayHasKey('description', $schema['properties']['completionTokens']); + $this->assertArrayHasKey('description', $schema['properties']['totalTokens']); + + // Check required fields + $this->assertArrayHasKey('required', $schema); + $this->assertEquals(['promptTokens', 'completionTokens', 'totalTokens'], $schema['required']); + } + + /** + * Tests TokenUsage with GPT-3.5 typical usage. + * + * @return void + */ + public function testGpt35TypicalUsage(): void + { + // Typical GPT-3.5 conversation + $tokenUsage = new TokenUsage(127, 89, 216); + + $this->assertEquals(127, $tokenUsage->getPromptTokens()); + $this->assertEquals(89, $tokenUsage->getCompletionTokens()); + $this->assertEquals(216, $tokenUsage->getTotalTokens()); + } + + /** + * Tests TokenUsage with GPT-4 typical usage. + * + * @return void + */ + public function testGpt4TypicalUsage(): void + { + // Typical GPT-4 conversation with more context + $tokenUsage = new TokenUsage(512, 256, 768); + + $this->assertEquals(512, $tokenUsage->getPromptTokens()); + $this->assertEquals(256, $tokenUsage->getCompletionTokens()); + $this->assertEquals(768, $tokenUsage->getTotalTokens()); + } + + /** + * Tests TokenUsage for embedding models. + * + * @return void + */ + public function testEmbeddingModelUsage(): void + { + // Embedding models only use prompt tokens + $tokenUsage = new TokenUsage(1536, 0, 1536); + + $this->assertEquals(1536, $tokenUsage->getPromptTokens()); + $this->assertEquals(0, $tokenUsage->getCompletionTokens()); + $this->assertEquals(1536, $tokenUsage->getTotalTokens()); + } + + /** + * Tests TokenUsage implements WithJsonSchemaInterface. + * + * @return void + */ + public function testImplementsWithJsonSchemaInterface(): void + { + $tokenUsage = new TokenUsage(10, 20, 30); + + $this->assertInstanceOf( + \WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface::class, + $tokenUsage + ); + } + + /** + * Tests creating multiple TokenUsage instances. + * + * @return void + */ + public function testMultipleInstances(): void + { + $usage1 = new TokenUsage(100, 50, 150); + $usage2 = new TokenUsage(200, 100, 300); + $usage3 = new TokenUsage(100, 50, 150); + + // Different instances with different values + $this->assertNotSame($usage1, $usage2); + $this->assertNotEquals($usage1->getPromptTokens(), $usage2->getPromptTokens()); + + // Different instances with same values + $this->assertNotSame($usage1, $usage3); + $this->assertEquals($usage1->getPromptTokens(), $usage3->getPromptTokens()); + $this->assertEquals($usage1->getCompletionTokens(), $usage3->getCompletionTokens()); + $this->assertEquals($usage1->getTotalTokens(), $usage3->getTotalTokens()); + } + + /** + * Tests TokenUsage with streaming response simulation. + * + * @return void + */ + public function testStreamingResponseUsage(): void + { + // Simulating a streaming response where tokens accumulate + $initialUsage = new TokenUsage(50, 10, 60); + $midUsage = new TokenUsage(50, 50, 100); + $finalUsage = new TokenUsage(50, 150, 200); + + // Prompt tokens stay the same + $this->assertEquals($initialUsage->getPromptTokens(), $midUsage->getPromptTokens()); + $this->assertEquals($midUsage->getPromptTokens(), $finalUsage->getPromptTokens()); + + // Completion tokens increase + $this->assertLessThan($midUsage->getCompletionTokens(), $initialUsage->getCompletionTokens()); + $this->assertLessThan($finalUsage->getCompletionTokens(), $midUsage->getCompletionTokens()); + + // Total tokens increase accordingly + $this->assertLessThan($midUsage->getTotalTokens(), $initialUsage->getTotalTokens()); + $this->assertLessThan($finalUsage->getTotalTokens(), $midUsage->getTotalTokens()); + } +} \ No newline at end of file diff --git a/tests/unit/Tools/DTO/FunctionCallTest.php b/tests/unit/Tools/DTO/FunctionCallTest.php new file mode 100644 index 00000000..0a387515 --- /dev/null +++ b/tests/unit/Tools/DTO/FunctionCallTest.php @@ -0,0 +1,164 @@ + 'New York', 'units' => 'celsius']; + + $functionCall = new FunctionCall($id, $name, $args); + + $this->assertEquals($id, $functionCall->getId()); + $this->assertEquals($name, $functionCall->getName()); + $this->assertEquals($args, $functionCall->getArgs()); + } + + /** + * Tests creating FunctionCall with only ID. + * + * @return void + */ + public function testCreateWithOnlyId(): void + { + $id = 'func_123'; + $args = ['param' => 'value']; + + $functionCall = new FunctionCall($id, null, $args); + + $this->assertEquals($id, $functionCall->getId()); + $this->assertNull($functionCall->getName()); + $this->assertEquals($args, $functionCall->getArgs()); + } + + /** + * Tests creating FunctionCall with only name. + * + * @return void + */ + public function testCreateWithOnlyName(): void + { + $name = 'calculateTotal'; + $args = ['items' => [1, 2, 3]]; + + $functionCall = new FunctionCall(null, $name, $args); + + $this->assertNull($functionCall->getId()); + $this->assertEquals($name, $functionCall->getName()); + $this->assertEquals($args, $functionCall->getArgs()); + } + + /** + * Tests creating FunctionCall without args. + * + * @return void + */ + public function testCreateWithoutArgs(): void + { + $functionCall = new FunctionCall('func_123', 'getTime'); + + $this->assertEquals('func_123', $functionCall->getId()); + $this->assertEquals('getTime', $functionCall->getName()); + $this->assertEquals([], $functionCall->getArgs()); + } + + /** + * Tests that creating without ID or name throws exception. + * + * @return void + */ + public function testCreateWithoutIdOrNameThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('At least one of id or name must be provided.'); + + new FunctionCall(null, null, []); + } + + /** + * Tests JSON schema. + * + * @return void + */ + public function testJsonSchema(): void + { + $schema = FunctionCall::getJsonSchema(); + + $this->assertIsArray($schema); + $this->assertEquals('object', $schema['type']); + + // Check properties + $this->assertArrayHasKey('properties', $schema); + $this->assertArrayHasKey('id', $schema['properties']); + $this->assertArrayHasKey('name', $schema['properties']); + $this->assertArrayHasKey('args', $schema['properties']); + + // Check id property + $this->assertEquals('string', $schema['properties']['id']['type']); + $this->assertArrayHasKey('description', $schema['properties']['id']); + + // Check name property + $this->assertEquals('string', $schema['properties']['name']['type']); + $this->assertArrayHasKey('description', $schema['properties']['name']); + + // Check args property + $this->assertEquals('object', $schema['properties']['args']['type']); + $this->assertTrue($schema['properties']['args']['additionalProperties']); + + // Check oneOf for required fields + $this->assertArrayHasKey('oneOf', $schema); + $this->assertCount(3, $schema['oneOf']); + + // First option: only id required + $this->assertEquals(['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']); + } + + /** + * Tests with complex args. + * + * @return void + */ + public function testWithComplexArgs(): void + { + $args = [ + 'string' => 'value', + 'number' => 42, + 'float' => 3.14, + 'boolean' => true, + 'null' => null, + 'array' => [1, 2, 3], + 'object' => ['key' => 'value'], + 'nested' => [ + 'deep' => [ + 'value' => 'test' + ] + ] + ]; + + $functionCall = new FunctionCall('id', 'name', $args); + + $this->assertEquals($args, $functionCall->getArgs()); + } +} \ No newline at end of file diff --git a/tests/unit/Tools/DTO/FunctionDeclarationTest.php b/tests/unit/Tools/DTO/FunctionDeclarationTest.php new file mode 100644 index 00000000..b5a2f3d7 --- /dev/null +++ b/tests/unit/Tools/DTO/FunctionDeclarationTest.php @@ -0,0 +1,188 @@ + 'object', + 'properties' => [ + 'a' => ['type' => 'number', 'description' => 'First number'], + 'b' => ['type' => 'number', 'description' => 'Second number'], + ], + 'required' => ['a', 'b'], + ]; + + $declaration = new FunctionDeclaration($name, $description, $parameters); + + $this->assertEquals($name, $declaration->getName()); + $this->assertEquals($description, $declaration->getDescription()); + $this->assertEquals($parameters, $declaration->getParameters()); + } + + /** + * Tests creating FunctionDeclaration without parameters. + * + * @return void + */ + public function testCreateWithoutParameters(): void + { + $name = 'getCurrentTime'; + $description = 'Gets the current system time'; + + $declaration = new FunctionDeclaration($name, $description); + + $this->assertEquals($name, $declaration->getName()); + $this->assertEquals($description, $declaration->getDescription()); + $this->assertNull($declaration->getParameters()); + } + + /** + * Tests with various parameter types. + * + * @dataProvider parameterTypesProvider + * @param mixed $parameters + * @return void + */ + public function testWithVariousParameterTypes($parameters): void + { + $declaration = new FunctionDeclaration('test', 'test function', $parameters); + + $this->assertSame($parameters, $declaration->getParameters()); + } + + /** + * Provides various parameter types. + * + * @return array + */ + public function parameterTypesProvider(): array + { + return [ + 'null' => [null], + 'string' => ['simple string parameter'], + 'number' => [42], + 'float' => [3.14], + 'boolean' => [true], + 'array' => [['key' => 'value']], + 'object' => [(object) ['property' => 'value']], + 'complex schema' => [[ + 'type' => 'object', + 'properties' => [ + 'nested' => [ + 'type' => 'object', + 'properties' => [ + 'value' => ['type' => 'string'] + ] + ] + ] + ]], + ]; + } + + /** + * Tests JSON schema. + * + * @return void + */ + public function testJsonSchema(): void + { + $schema = FunctionDeclaration::getJsonSchema(); + + $this->assertIsArray($schema); + $this->assertEquals('object', $schema['type']); + + // Check properties + $this->assertArrayHasKey('properties', $schema); + $this->assertArrayHasKey('name', $schema['properties']); + $this->assertArrayHasKey('description', $schema['properties']); + $this->assertArrayHasKey('parameters', $schema['properties']); + + // Check name property + $this->assertEquals('string', $schema['properties']['name']['type']); + $this->assertArrayHasKey('description', $schema['properties']['name']); + + // Check description property + $this->assertEquals('string', $schema['properties']['description']['type']); + $this->assertArrayHasKey('description', $schema['properties']['description']); + + // Check parameters property allows multiple types + $paramTypes = $schema['properties']['parameters']['type']; + $this->assertIsArray($paramTypes); + $this->assertContains('string', $paramTypes); + $this->assertContains('number', $paramTypes); + $this->assertContains('boolean', $paramTypes); + $this->assertContains('object', $paramTypes); + $this->assertContains('array', $paramTypes); + $this->assertContains('null', $paramTypes); + + // Check required fields - parameters should NOT be required + $this->assertArrayHasKey('required', $schema); + $this->assertEquals(['name', 'description'], $schema['required']); + $this->assertNotContains('parameters', $schema['required']); + } + + /** + * Tests empty string values. + * + * @return void + */ + public function testEmptyStringValues(): void + { + $declaration = new FunctionDeclaration('', ''); + + $this->assertEquals('', $declaration->getName()); + $this->assertEquals('', $declaration->getDescription()); + $this->assertNull($declaration->getParameters()); + } + + /** + * Tests with OpenAPI-style parameter schema. + * + * @return void + */ + public function testWithOpenApiStyleSchema(): void + { + $parameters = [ + 'type' => 'object', + 'properties' => [ + 'location' => [ + 'type' => 'string', + 'description' => 'The city and state, e.g. San Francisco, CA' + ], + 'unit' => [ + 'type' => 'string', + 'enum' => ['celsius', 'fahrenheit'], + 'default' => 'fahrenheit' + ] + ], + 'required' => ['location'], + 'additionalProperties' => false + ]; + + $declaration = new FunctionDeclaration( + 'get_weather', + 'Get the current weather in a given location', + $parameters + ); + + $this->assertEquals($parameters, $declaration->getParameters()); + } +} \ No newline at end of file diff --git a/tests/unit/Tools/DTO/FunctionResponseTest.php b/tests/unit/Tools/DTO/FunctionResponseTest.php new file mode 100644 index 00000000..20180ab9 --- /dev/null +++ b/tests/unit/Tools/DTO/FunctionResponseTest.php @@ -0,0 +1,183 @@ + 22, + 'condition' => 'sunny', + 'humidity' => 65, + ]; + + $functionResponse = new FunctionResponse($id, $name, $response); + + $this->assertEquals($id, $functionResponse->getId()); + $this->assertEquals($name, $functionResponse->getName()); + $this->assertEquals($response, $functionResponse->getResponse()); + } + + /** + * Tests with various response types. + * + * @dataProvider responseTypesProvider + * @param mixed $response + * @return void + */ + public function testWithVariousResponseTypes($response): void + { + $functionResponse = new FunctionResponse('id', 'name', $response); + + $this->assertSame($response, $functionResponse->getResponse()); + } + + /** + * Provides various response types. + * + * @return array + */ + public function responseTypesProvider(): array + { + return [ + 'null' => [null], + 'string' => ['success'], + 'number' => [42], + 'float' => [3.14159], + 'boolean true' => [true], + 'boolean false' => [false], + 'empty array' => [[]], + 'indexed array' => [[1, 2, 3]], + 'associative array' => [['key' => 'value', 'another' => 'test']], + 'nested array' => [[ + 'level1' => [ + 'level2' => [ + 'level3' => 'deep value' + ] + ] + ]], + 'object' => [(object) ['property' => 'value']], + 'mixed array' => [[ + 'string' => 'text', + 'number' => 123, + 'boolean' => true, + 'null' => null, + 'array' => [1, 2, 3] + ]], + ]; + } + + /** + * Tests JSON schema. + * + * @return void + */ + public function testJsonSchema(): void + { + $schema = FunctionResponse::getJsonSchema(); + + $this->assertIsArray($schema); + $this->assertEquals('object', $schema['type']); + + // Check properties + $this->assertArrayHasKey('properties', $schema); + $this->assertArrayHasKey('id', $schema['properties']); + $this->assertArrayHasKey('name', $schema['properties']); + $this->assertArrayHasKey('response', $schema['properties']); + + // Check id property + $this->assertEquals('string', $schema['properties']['id']['type']); + $this->assertArrayHasKey('description', $schema['properties']['id']); + + // Check name property + $this->assertEquals('string', $schema['properties']['name']['type']); + $this->assertArrayHasKey('description', $schema['properties']['name']); + + // Check response property allows multiple types + $responseTypes = $schema['properties']['response']['type']; + $this->assertIsArray($responseTypes); + $this->assertContains('string', $responseTypes); + $this->assertContains('number', $responseTypes); + $this->assertContains('boolean', $responseTypes); + $this->assertContains('object', $responseTypes); + $this->assertContains('array', $responseTypes); + $this->assertContains('null', $responseTypes); + + // Check required fields + $this->assertArrayHasKey('required', $schema); + $this->assertEquals(['id', 'name', 'response'], $schema['required']); + } + + /** + * Tests with empty string values. + * + * @return void + */ + public function testWithEmptyStringValues(): void + { + $response = new FunctionResponse('', '', ''); + + $this->assertEquals('', $response->getId()); + $this->assertEquals('', $response->getName()); + $this->assertEquals('', $response->getResponse()); + } + + /** + * Tests with error response. + * + * @return void + */ + public function testWithErrorResponse(): void + { + $errorResponse = [ + 'error' => true, + 'message' => 'Function execution failed', + 'code' => 'EXEC_ERROR', + 'details' => [ + 'timestamp' => '2024-01-01T00:00:00Z', + 'trace' => 'stack trace here' + ] + ]; + + $response = new FunctionResponse('func_456', 'failingFunction', $errorResponse); + + $this->assertEquals('func_456', $response->getId()); + $this->assertEquals('failingFunction', $response->getName()); + $this->assertEquals($errorResponse, $response->getResponse()); + } + + /** + * Tests with large response data. + * + * @return void + */ + public function testWithLargeResponseData(): void + { + // Create a large array + $largeData = []; + for ($i = 0; $i < 1000; $i++) { + $largeData["key_$i"] = "value_$i"; + } + + $response = new FunctionResponse('id', 'name', $largeData); + + $this->assertEquals($largeData, $response->getResponse()); + $this->assertCount(1000, $response->getResponse()); + } +} \ No newline at end of file diff --git a/tests/unit/Tools/DTO/ToolTest.php b/tests/unit/Tools/DTO/ToolTest.php new file mode 100644 index 00000000..74058ade --- /dev/null +++ b/tests/unit/Tools/DTO/ToolTest.php @@ -0,0 +1,298 @@ + ['type' => 'string', 'description' => 'Search query']] + ); + $function2 = new FunctionDeclaration( + 'sendEmail', + 'Sends an email', + ['to' => ['type' => 'string'], 'subject' => ['type' => 'string']] + ); + + $tool = new Tool([$function1, $function2]); + + $this->assertEquals(ToolTypeEnum::functionDeclarations(), $tool->getType()); + $this->assertTrue($tool->getType()->isFunctionDeclarations()); + $this->assertCount(2, $tool->getFunctionDeclarations()); + $this->assertSame([$function1, $function2], $tool->getFunctionDeclarations()); + $this->assertNull($tool->getWebSearch()); + } + + /** + * Tests creating tool with single function declaration. + * + * @return void + */ + public function testCreateWithSingleFunctionDeclaration(): void + { + $function = new FunctionDeclaration( + 'getCurrentWeather', + 'Gets the current weather for a location' + ); + + $tool = new Tool([$function]); + + $this->assertEquals(ToolTypeEnum::functionDeclarations(), $tool->getType()); + $this->assertCount(1, $tool->getFunctionDeclarations()); + $this->assertSame($function, $tool->getFunctionDeclarations()[0]); + } + + /** + * Tests creating tool with empty function declarations array. + * + * @return void + */ + public function testCreateWithEmptyFunctionDeclarationsArray(): void + { + $tool = new Tool([]); + + $this->assertEquals(ToolTypeEnum::functionDeclarations(), $tool->getType()); + $this->assertCount(0, $tool->getFunctionDeclarations()); + $this->assertEquals([], $tool->getFunctionDeclarations()); + } + + /** + * Tests creating tool with web search. + * + * @return void + */ + public function testCreateWithWebSearch(): void + { + $webSearch = new WebSearch( + ['example.com', 'docs.example.com'], + ['spam.com', 'malware.com'] + ); + + $tool = new Tool($webSearch); + + $this->assertEquals(ToolTypeEnum::webSearch(), $tool->getType()); + $this->assertTrue($tool->getType()->isWebSearch()); + $this->assertSame($webSearch, $tool->getWebSearch()); + $this->assertNull($tool->getFunctionDeclarations()); + } + + /** + * Tests creating tool with invalid content throws exception. + * + * @return void + */ + public function testCreateWithInvalidContentThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Tool content must be an array of FunctionDeclaration instances or a WebSearch instance' + ); + + new Tool('invalid content'); + } + + /** + * Tests creating tool with object that is not WebSearch throws exception. + * + * @return void + */ + public function testCreateWithInvalidObjectThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Tool content must be an array of FunctionDeclaration instances or a WebSearch instance' + ); + + new Tool(new \stdClass()); + } + + /** + * Tests JSON schema for function declarations tool. + * + * @return void + */ + public function testJsonSchemaForFunctionDeclarationsTool(): void + { + $schema = Tool::getJsonSchema(); + + $this->assertArrayHasKey('oneOf', $schema); + $this->assertCount(2, $schema['oneOf']); + + // First schema is for function declarations + $functionSchema = $schema['oneOf'][0]; + $this->assertEquals('object', $functionSchema['type']); + $this->assertArrayHasKey('properties', $functionSchema); + $this->assertArrayHasKey('type', $functionSchema['properties']); + $this->assertArrayHasKey('functionDeclarations', $functionSchema['properties']); + + // Type property + $typeProperty = $functionSchema['properties']['type']; + $this->assertEquals('string', $typeProperty['type']); + $this->assertEquals(ToolTypeEnum::functionDeclarations()->value, $typeProperty['const']); + + // Function declarations property + $functionsProperty = $functionSchema['properties']['functionDeclarations']; + $this->assertEquals('array', $functionsProperty['type']); + $this->assertArrayHasKey('items', $functionsProperty); + + // Required fields + $this->assertEquals(['type', 'functionDeclarations'], $functionSchema['required']); + } + + /** + * Tests JSON schema for web search tool. + * + * @return void + */ + public function testJsonSchemaForWebSearchTool(): void + { + $schema = Tool::getJsonSchema(); + + // Second schema is for web search + $webSearchSchema = $schema['oneOf'][1]; + $this->assertEquals('object', $webSearchSchema['type']); + $this->assertArrayHasKey('properties', $webSearchSchema); + $this->assertArrayHasKey('type', $webSearchSchema['properties']); + $this->assertArrayHasKey('webSearch', $webSearchSchema['properties']); + + // Type property + $typeProperty = $webSearchSchema['properties']['type']; + $this->assertEquals('string', $typeProperty['type']); + $this->assertEquals(ToolTypeEnum::webSearch()->value, $typeProperty['const']); + + // Web search property + $this->assertArrayHasKey('webSearch', $webSearchSchema['properties']); + + // Required fields + $this->assertEquals(['type', 'webSearch'], $webSearchSchema['required']); + } + + /** + * Tests tool with multiple complex function declarations. + * + * @return void + */ + public function testWithMultipleComplexFunctionDeclarations(): void + { + $functions = [ + new FunctionDeclaration( + 'createUser', + 'Creates a new user in the system', + [ + 'username' => [ + 'type' => 'string', + 'description' => 'The username', + 'minLength' => 3, + 'maxLength' => 20 + ], + 'email' => [ + 'type' => 'string', + 'format' => 'email', + 'description' => 'The user email' + ], + 'role' => [ + 'type' => 'string', + 'enum' => ['admin', 'user', 'guest'], + 'description' => 'The user role' + ] + ] + ), + new FunctionDeclaration( + 'deleteUser', + 'Deletes a user from the system', + [ + 'userId' => [ + 'type' => 'integer', + 'description' => 'The user ID to delete' + ] + ] + ), + new FunctionDeclaration( + 'listUsers', + 'Lists all users with optional filtering', + [ + 'role' => [ + 'type' => 'string', + 'enum' => ['admin', 'user', 'guest'], + 'description' => 'Filter by role' + ], + 'limit' => [ + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => 100, + 'default' => 10 + ] + ] + ) + ]; + + $tool = new Tool($functions); + + $this->assertCount(3, $tool->getFunctionDeclarations()); + $this->assertEquals('createUser', $tool->getFunctionDeclarations()[0]->getName()); + $this->assertEquals('deleteUser', $tool->getFunctionDeclarations()[1]->getName()); + $this->assertEquals('listUsers', $tool->getFunctionDeclarations()[2]->getName()); + } + + /** + * Tests tool implements WithJsonSchemaInterface. + * + * @return void + */ + public function testImplementsWithJsonSchemaInterface(): void + { + $tool = new Tool([]); + + $this->assertInstanceOf( + \WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface::class, + $tool + ); + } + + /** + * Tests creating multiple tool instances. + * + * @return void + */ + public function testMultipleToolInstances(): void + { + $function = new FunctionDeclaration('test', 'Test function'); + $webSearch = new WebSearch(['example.com'], ['spam.com']); + + $tool1 = new Tool([$function]); + $tool2 = new Tool($webSearch); + $tool3 = new Tool([$function]); + + // Different tool types + $this->assertNotEquals($tool1->getType(), $tool2->getType()); + + // Same content type but different instances + $this->assertNotSame($tool1, $tool3); + $this->assertEquals($tool1->getType(), $tool3->getType()); + + // Check content accessors + $this->assertNotNull($tool1->getFunctionDeclarations()); + $this->assertNull($tool1->getWebSearch()); + $this->assertNull($tool2->getFunctionDeclarations()); + $this->assertNotNull($tool2->getWebSearch()); + } +} \ No newline at end of file diff --git a/tests/unit/Tools/DTO/WebSearchTest.php b/tests/unit/Tools/DTO/WebSearchTest.php new file mode 100644 index 00000000..0a9aee97 --- /dev/null +++ b/tests/unit/Tools/DTO/WebSearchTest.php @@ -0,0 +1,294 @@ +assertEquals($allowedDomains, $webSearch->getAllowedDomains()); + $this->assertEquals($disallowedDomains, $webSearch->getDisallowedDomains()); + } + + /** + * Tests creating WebSearch with only allowed domains. + * + * @return void + */ + public function testCreateWithOnlyAllowedDomains(): void + { + $allowedDomains = ['example.com', 'test.org']; + + $webSearch = new WebSearch($allowedDomains); + + $this->assertEquals($allowedDomains, $webSearch->getAllowedDomains()); + $this->assertEquals([], $webSearch->getDisallowedDomains()); + } + + /** + * Tests creating WebSearch with only disallowed domains. + * + * @return void + */ + public function testCreateWithOnlyDisallowedDomains(): void + { + $disallowedDomains = ['bad.com', 'blocked.org']; + + $webSearch = new WebSearch([], $disallowedDomains); + + $this->assertEquals([], $webSearch->getAllowedDomains()); + $this->assertEquals($disallowedDomains, $webSearch->getDisallowedDomains()); + } + + /** + * Tests creating WebSearch with no domain restrictions. + * + * @return void + */ + public function testCreateWithNoDomainRestrictions(): void + { + $webSearch = new WebSearch(); + + $this->assertEquals([], $webSearch->getAllowedDomains()); + $this->assertEquals([], $webSearch->getDisallowedDomains()); + } + + /** + * Tests WebSearch with various domain formats. + * + * @return void + */ + public function testWithVariousDomainFormats(): void + { + $allowedDomains = [ + 'example.com', + 'subdomain.example.com', + 'deep.subdomain.example.com', + 'example.co.uk', + 'example.org', + 'localhost', + '192.168.1.1', + 'example-with-dash.com', + 'UPPERCASE.COM' + ]; + + $webSearch = new WebSearch($allowedDomains); + + $this->assertEquals($allowedDomains, $webSearch->getAllowedDomains()); + } + + /** + * Tests WebSearch with duplicate domains. + * + * @return void + */ + public function testWithDuplicateDomains(): void + { + $allowedDomains = ['example.com', 'test.org', 'example.com']; + $disallowedDomains = ['bad.com', 'bad.com', 'worse.com']; + + $webSearch = new WebSearch($allowedDomains, $disallowedDomains); + + // Note: WebSearch doesn't deduplicate - that's up to the implementation + $this->assertEquals($allowedDomains, $webSearch->getAllowedDomains()); + $this->assertEquals($disallowedDomains, $webSearch->getDisallowedDomains()); + } + + /** + * Tests JSON schema. + * + * @return void + */ + public function testJsonSchema(): void + { + $schema = WebSearch::getJsonSchema(); + + $this->assertIsArray($schema); + $this->assertEquals('object', $schema['type']); + + // Check properties + $this->assertArrayHasKey('properties', $schema); + $this->assertArrayHasKey('allowedDomains', $schema['properties']); + $this->assertArrayHasKey('disallowedDomains', $schema['properties']); + + // Check allowedDomains property + $allowedSchema = $schema['properties']['allowedDomains']; + $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']; + $this->assertEquals('array', $disallowedSchema['type']); + $this->assertArrayHasKey('items', $disallowedSchema); + $this->assertEquals('string', $disallowedSchema['items']['type']); + $this->assertArrayHasKey('description', $disallowedSchema); + + // Check required fields (should be empty array) + $this->assertArrayHasKey('required', $schema); + $this->assertEquals([], $schema['required']); + } + + /** + * Tests WebSearch with empty strings in arrays. + * + * @return void + */ + public function testWithEmptyStringsInArrays(): void + { + $allowedDomains = ['example.com', '', 'test.org']; + $disallowedDomains = ['', 'bad.com', '']; + + $webSearch = new WebSearch($allowedDomains, $disallowedDomains); + + $this->assertEquals($allowedDomains, $webSearch->getAllowedDomains()); + $this->assertEquals($disallowedDomains, $webSearch->getDisallowedDomains()); + } + + /** + * Tests WebSearch with single domain in each list. + * + * @return void + */ + public function testWithSingleDomainInEachList(): void + { + $webSearch = new WebSearch(['trusted.com'], ['untrusted.com']); + + $this->assertCount(1, $webSearch->getAllowedDomains()); + $this->assertCount(1, $webSearch->getDisallowedDomains()); + $this->assertEquals('trusted.com', $webSearch->getAllowedDomains()[0]); + $this->assertEquals('untrusted.com', $webSearch->getDisallowedDomains()[0]); + } + + /** + * Tests WebSearch with many domains. + * + * @return void + */ + public function testWithManyDomains(): void + { + $allowedDomains = []; + $disallowedDomains = []; + + // Create 100 allowed domains + for ($i = 0; $i < 100; $i++) { + $allowedDomains[] = "allowed-domain-$i.com"; + } + + // Create 50 disallowed domains + for ($i = 0; $i < 50; $i++) { + $disallowedDomains[] = "blocked-domain-$i.com"; + } + + $webSearch = new WebSearch($allowedDomains, $disallowedDomains); + + $this->assertCount(100, $webSearch->getAllowedDomains()); + $this->assertCount(50, $webSearch->getDisallowedDomains()); + $this->assertEquals('allowed-domain-0.com', $webSearch->getAllowedDomains()[0]); + $this->assertEquals('allowed-domain-99.com', $webSearch->getAllowedDomains()[99]); + $this->assertEquals('blocked-domain-0.com', $webSearch->getDisallowedDomains()[0]); + $this->assertEquals('blocked-domain-49.com', $webSearch->getDisallowedDomains()[49]); + } + + /** + * Tests WebSearch implements WithJsonSchemaInterface. + * + * @return void + */ + public function testImplementsWithJsonSchemaInterface(): void + { + $webSearch = new WebSearch(); + + $this->assertInstanceOf( + \WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface::class, + $webSearch + ); + } + + /** + * Tests creating multiple WebSearch instances. + * + * @return void + */ + public function testMultipleInstances(): void + { + $webSearch1 = new WebSearch(['a.com'], ['b.com']); + $webSearch2 = new WebSearch(['c.com'], ['d.com']); + $webSearch3 = new WebSearch(['a.com'], ['b.com']); + + // Different instances + $this->assertNotSame($webSearch1, $webSearch2); + $this->assertNotSame($webSearch1, $webSearch3); + + // Different content + $this->assertNotEquals($webSearch1->getAllowedDomains(), $webSearch2->getAllowedDomains()); + $this->assertNotEquals($webSearch1->getDisallowedDomains(), $webSearch2->getDisallowedDomains()); + + // Same content but different instances + $this->assertEquals($webSearch1->getAllowedDomains(), $webSearch3->getAllowedDomains()); + $this->assertEquals($webSearch1->getDisallowedDomains(), $webSearch3->getDisallowedDomains()); + } + + /** + * Tests WebSearch with common domain patterns. + * + * @return void + */ + public function testWithCommonDomainPatterns(): void + { + $allowedDomains = [ + // News sites + 'cnn.com', + 'bbc.co.uk', + 'reuters.com', + + // Documentation sites + 'docs.microsoft.com', + 'developer.mozilla.org', + 'stackoverflow.com', + + // Academic sites + 'arxiv.org', + 'scholar.google.com', + 'pubmed.ncbi.nlm.nih.gov' + ]; + + $disallowedDomains = [ + // Social media + 'facebook.com', + 'twitter.com', + 'instagram.com', + + // Video platforms + 'youtube.com', + 'vimeo.com', + 'tiktok.com' + ]; + + $webSearch = new WebSearch($allowedDomains, $disallowedDomains); + + $this->assertCount(9, $webSearch->getAllowedDomains()); + $this->assertCount(6, $webSearch->getDisallowedDomains()); + $this->assertContains('stackoverflow.com', $webSearch->getAllowedDomains()); + $this->assertContains('youtube.com', $webSearch->getDisallowedDomains()); + } +} \ No newline at end of file