diff --git a/composer.json b/composer.json index e5b53a0c..a159ceb7 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,10 @@ "autoload": { "psr-4": { "WordPress\\AI\\": "includes/" - } + }, + "files": [ + "includes/helpers.php" + ] }, "autoload-dev": { "psr-4": { @@ -47,7 +50,6 @@ "dealerdirect/phpcodesniffer-composer-installer": true, "php-http/discovery": true, "phpstan/extension-installer": true - }, "platform": { "php": "7.4" diff --git a/composer.lock b/composer.lock index e965116b..439c1c09 100644 --- a/composer.lock +++ b/composer.lock @@ -71,6 +71,84 @@ }, "time": "2025-10-06T10:32:52+00:00" }, + { + "name": "nyholm/psr7", + "version": "1.8.2", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7.git", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "provide": { + "php-http/message-factory-implementation": "1.0", + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "http-interop/http-factory-tests": "^0.9", + "php-http/message-factory": "^1.0", + "php-http/psr7-integration-tests": "^1.0", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4", + "symfony/error-handler": "^4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.8-dev" + } + }, + "autoload": { + "psr-4": { + "Nyholm\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "A fast PHP7 implementation of PSR-7", + "homepage": "https://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/Nyholm/psr7/issues", + "source": "https://github.com/Nyholm/psr7/tree/1.8.2" + }, + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2024-09-09T07:06:30+00:00" + }, { "name": "php-http/discovery", "version": "1.20.0", @@ -553,12 +631,12 @@ "source": { "type": "git", "url": "https://github.com/WordPress/mcp-adapter.git", - "reference": "0e2910780a95ac855931a4df7b0a9de7ca7b05eb" + "reference": "b55221f9d31307ae69943a094394a126579017f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/WordPress/mcp-adapter/zipball/0e2910780a95ac855931a4df7b0a9de7ca7b05eb", - "reference": "0e2910780a95ac855931a4df7b0a9de7ca7b05eb", + "url": "https://api.github.com/repos/WordPress/mcp-adapter/zipball/b55221f9d31307ae69943a094394a126579017f6", + "reference": "b55221f9d31307ae69943a094394a126579017f6", "shasum": "" }, "require": { @@ -620,20 +698,20 @@ "issues": "https://github.com/wordpress/mcp-adapter/issues", "source": "https://github.com/wordpress/mcp-adapter" }, - "time": "2025-10-23T05:56:26+00:00" + "time": "2025-11-03T17:53:17+00:00" }, { "name": "wordpress/php-ai-client", - "version": "0.1.0", + "version": "0.2.0", "source": { "type": "git", "url": "https://github.com/WordPress/php-ai-client.git", - "reference": "9ec56e70e692791493a3eaff1b69f25f4daeded7" + "reference": "81a104a9bc5f887e3fbecea6e0d9cd8eab3be0b2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/WordPress/php-ai-client/zipball/9ec56e70e692791493a3eaff1b69f25f4daeded7", - "reference": "9ec56e70e692791493a3eaff1b69f25f4daeded7", + "url": "https://api.github.com/repos/WordPress/php-ai-client/zipball/81a104a9bc5f887e3fbecea6e0d9cd8eab3be0b2", + "reference": "81a104a9bc5f887e3fbecea6e0d9cd8eab3be0b2", "shasum": "" }, "require": { @@ -687,7 +765,7 @@ "issues": "https://github.com/WordPress/php-ai-client/issues", "source": "https://github.com/WordPress/php-ai-client" }, - "time": "2025-08-29T22:46:54+00:00" + "time": "2025-10-21T00:05:14+00:00" }, { "name": "wordpress/wp-ai-client", @@ -695,27 +773,31 @@ "source": { "type": "git", "url": "https://github.com/WordPress/wp-ai-client.git", - "reference": "3d8a386ad2988fba88fa6f44dd1045a58e0c8f30" + "reference": "a41ef6075c1cbb56dfc380fe5c13a347b265d193" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/WordPress/wp-ai-client/zipball/3d8a386ad2988fba88fa6f44dd1045a58e0c8f30", - "reference": "3d8a386ad2988fba88fa6f44dd1045a58e0c8f30", + "url": "https://api.github.com/repos/WordPress/wp-ai-client/zipball/a41ef6075c1cbb56dfc380fe5c13a347b265d193", + "reference": "a41ef6075c1cbb56dfc380fe5c13a347b265d193", "shasum": "" }, "require": { "ext-json": "*", + "nyholm/psr7": "^1.5", "php": ">=7.4", - "wordpress/php-ai-client": "^0.1" + "wordpress/php-ai-client": "^0.2" }, "require-dev": { "automattic/vipwpcs": "^3.0", "dealerdirect/phpcodesniffer-composer-installer": "^1.0", "phpcompatibility/phpcompatibility-wp": "^2.1", - "phpstan/phpstan": "~2.1", + "phpstan/phpstan": "^1.10 | ^2.1", "slevomat/coding-standard": "^8.0", "squizlabs/php_codesniffer": "^3.7", - "wp-coding-standards/wpcs": "^3.0" + "szepeviktor/phpstan-wordpress": "^2.0", + "wp-coding-standards/wpcs": "^3.0", + "wp-phpunit/wp-phpunit": "^6.8", + "yoast/phpunit-polyfills": "^4.0" }, "default-branch": true, "type": "library", @@ -724,6 +806,11 @@ "WordPress\\AI_Client\\": "includes/" } }, + "autoload-dev": { + "psr-4": { + "WordPress\\AI_Client\\PHPUnit\\Includes\\": "tests/phpunit/includes" + } + }, "scripts": { "lint": [ "@phpcs", @@ -736,7 +823,7 @@ "phpcbf" ], "phpstan": [ - "phpstan analyze --memory-limit=256M" + "phpstan analyze --memory-limit=512M" ] }, "license": [ @@ -760,7 +847,7 @@ "issues": "https://github.com/WordPress/wp-ai-client/issues", "source": "https://github.com/WordPress/wp-ai-client" }, - "time": "2025-09-08T16:26:36+00:00" + "time": "2025-11-13T00:12:21+00:00" } ], "packages-dev": [ @@ -1368,16 +1455,16 @@ }, { "name": "php-stubs/wordpress-stubs", - "version": "v6.8.2", + "version": "v6.8.3", "source": { "type": "git", "url": "https://github.com/php-stubs/wordpress-stubs.git", - "reference": "9c8e22e437463197c1ec0d5eaa9ddd4a0eb6d7f8" + "reference": "abeb5a8b58fda7ac21f15ee596f302f2959a7114" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/9c8e22e437463197c1ec0d5eaa9ddd4a0eb6d7f8", - "reference": "9c8e22e437463197c1ec0d5eaa9ddd4a0eb6d7f8", + "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/abeb5a8b58fda7ac21f15ee596f302f2959a7114", + "reference": "abeb5a8b58fda7ac21f15ee596f302f2959a7114", "shasum": "" }, "conflict": { @@ -1413,9 +1500,9 @@ ], "support": { "issues": "https://github.com/php-stubs/wordpress-stubs/issues", - "source": "https://github.com/php-stubs/wordpress-stubs/tree/v6.8.2" + "source": "https://github.com/php-stubs/wordpress-stubs/tree/v6.8.3" }, - "time": "2025-07-16T06:41:00+00:00" + "time": "2025-09-30T20:58:47+00:00" }, { "name": "phpcompatibility/php-compatibility", @@ -1423,12 +1510,12 @@ "source": { "type": "git", "url": "https://github.com/PHPCompatibility/PHPCompatibility.git", - "reference": "4423c33f26c907cad43582df7ec02706c523856d" + "reference": "ff4efdd80e094a81fd6329b570c9a632f21d42b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibility/zipball/4423c33f26c907cad43582df7ec02706c523856d", - "reference": "4423c33f26c907cad43582df7ec02706c523856d", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibility/zipball/ff4efdd80e094a81fd6329b570c9a632f21d42b4", + "reference": "ff4efdd80e094a81fd6329b570c9a632f21d42b4", "shasum": "" }, "require": { @@ -1509,7 +1596,7 @@ "type": "thanks_dev" } ], - "time": "2025-10-27T20:28:17+00:00" + "time": "2025-10-30T20:24:19+00:00" }, { "name": "phpcompatibility/phpcompatibility-paragonie", @@ -1664,16 +1751,16 @@ }, { "name": "phpcsstandards/phpcsextra", - "version": "1.4.1", + "version": "1.4.2", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHPCSExtra.git", - "reference": "882b8c947ada27eb002870fe77fee9ce0a454cdb" + "reference": "8e89a01c7b8fed84a12a2a7f5a23a44cdbe4f62e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/882b8c947ada27eb002870fe77fee9ce0a454cdb", - "reference": "882b8c947ada27eb002870fe77fee9ce0a454cdb", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/8e89a01c7b8fed84a12a2a7f5a23a44cdbe4f62e", + "reference": "8e89a01c7b8fed84a12a2a7f5a23a44cdbe4f62e", "shasum": "" }, "require": { @@ -1742,7 +1829,7 @@ "type": "thanks_dev" } ], - "time": "2025-09-05T06:54:52+00:00" + "time": "2025-10-28T17:00:02+00:00" }, { "name": "phpcsstandards/phpcsutils", diff --git a/includes/Abilities/Title_Generation.php b/includes/Abilities/Title_Generation/Title_Generation.php similarity index 61% rename from includes/Abilities/Title_Generation.php rename to includes/Abilities/Title_Generation/Title_Generation.php index 750dffb8..4076fc61 100644 --- a/includes/Abilities/Title_Generation.php +++ b/includes/Abilities/Title_Generation/Title_Generation.php @@ -7,10 +7,15 @@ declare( strict_types=1 ); -namespace WordPress\AI\Abilities; +namespace WordPress\AI\Abilities\Title_Generation; use WP_Error; use WordPress\AI\Abstracts\Abstract_Ability; +use WordPress\AI_Client\AI_Client; + +use function WordPress\AI\get_post_context; +use function WordPress\AI\get_preferred_models; +use function WordPress\AI\normalize_content; /** * Title generation WordPress Ability. @@ -20,15 +25,13 @@ class Title_Generation extends Abstract_Ability { /** - * Returns the category of the ability. + * The default number of candidates to generate. * * @since 0.1.0 * - * @return string The category of the ability. + * @var int */ - protected function category(): string { - return 'ai-experiments'; // TODO: add a reusable way to get the category slug? - } + protected const CANDIDATES_DEFAULT = 3; /** * Returns the input schema of the ability. @@ -41,20 +44,21 @@ protected function input_schema(): array { return array( 'type' => 'object', 'properties' => array( - 'content' => array( + 'content' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'description' => esc_html__( 'Content to generate title suggestions for.', 'ai' ), ), - 'post_id' => array( + 'post_id' => array( 'type' => 'integer', 'sanitize_callback' => 'absint', 'description' => esc_html__( 'Content from this post will be used to generate title suggestions. This overrides the content parameter if both are provided.', 'ai' ), ), - 'n' => array( + 'candidates' => array( 'type' => 'integer', 'minimum' => 1, 'maximum' => 10, + 'default' => self::CANDIDATES_DEFAULT, 'sanitize_callback' => 'absint', 'description' => esc_html__( 'Number of titles to generate', 'ai' ), ), @@ -90,16 +94,16 @@ protected function output_schema(): array { * @since 0.1.0 * * @param mixed $input The input arguments to the ability. - * @return mixed|\WP_Error The result of the ability execution, or a WP_Error on failure. + * @return array{titles: array}|\WP_Error The result of the ability execution, or a WP_Error on failure. */ protected function execute_callback( $input ) { // Default arguments. $args = wp_parse_args( $input, array( - 'content' => null, - 'post_id' => null, - 'n' => 1, + 'content' => null, + 'post_id' => null, + 'candidates' => self::CANDIDATES_DEFAULT, ), ); @@ -115,26 +119,51 @@ protected function execute_callback( $input ) { ); } - $args['content'] = $post->post_content; + // Get the post context. + $context = get_post_context( $args['post_id'] ); + + // Default to the passed in content if it exists. + if ( $args['content'] ) { + $context['content'] = normalize_content( $args['content'] ); + } + } else { + $context = array( + 'content' => normalize_content( $args['content'] ?? '' ), + ); } // If we have no content, return an error. - if ( ! $args['content'] ) { + if ( empty( $context['content'] ) ) { return new WP_Error( 'content_not_provided', esc_html__( 'Content is required to generate title suggestions.', 'ai' ) ); } - // TODO: Implement the title generation logic. + // Generate the titles. + $result = $this->generate_titles( $context, $args['candidates'] ); + + // If we have an error, return it. + if ( is_wp_error( $result ) ) { + return $result; + } + + // If we have no results, return an error. + if ( empty( $result ) ) { + return new WP_Error( + 'no_results', + esc_html__( 'No title suggestions were generated.', 'ai' ) + ); + } + // Return the titles in the format the Ability expects. return array( - 'name' => $this->get_name(), - 'label' => $this->get_label(), - 'description' => $this->get_description(), - 'content' => wp_kses_post( $args['content'] ), - 'post_id' => $args['post_id'] ? absint( $args['post_id'] ) : esc_html__( 'Not provided', 'ai' ), - 'n' => absint( $args['n'] ), + 'titles' => array_map( + static function ( $title ) { + return sanitize_text_field( trim( $title, ' "\'' ) ); + }, + $result + ), ); } @@ -204,4 +233,41 @@ protected function meta(): array { 'show_in_rest' => true, ); } + + /** + * Generates title suggestions from the given content. + * + * @since 0.1.0 + * + * @param string|array $context The context to generate a title from. + * @param int $candidates The number of titles to generate. + * @return array|\WP_Error The generated titles, or a WP_Error if there was an error. + */ + protected function generate_titles( $context, int $candidates = 1 ) { + // Convert the context to a string if it's an array. + if ( is_array( $context ) ) { + $context = implode( + "\n", + array_map( + static function ( $key, $value ) { + return sprintf( + '%s: %s', + ucwords( str_replace( '_', ' ', $key ) ), + $value + ); + }, + array_keys( $context ), + $context + ) + ); + } + + // Generate the titles using the AI client. + return AI_Client::prompt_with_wp_error( '"""' . $context . '"""' ) + ->using_system_instruction( $this->get_system_instruction() ) + ->using_temperature( 0.7 ) + ->using_candidate_count( (int) $candidates ) + ->using_model_preference( ...get_preferred_models() ) + ->generate_texts(); + } } diff --git a/includes/Abilities/Title_Generation/system-instruction.php b/includes/Abilities/Title_Generation/system-instruction.php new file mode 100644 index 00000000..e4539bce --- /dev/null +++ b/includes/Abilities/Title_Generation/system-instruction.php @@ -0,0 +1,22 @@ + The meta of the ability. */ abstract protected function meta(): array; + + /** + * Gets the system instruction for the feature. + * + * @since 0.1.0 + * + * @param string|null $filename Optional. Explicit filename to load. If not provided, + * attempts to load `system-instruction.php` or `prompt.php`. + * @return string The system instruction for the feature. + */ + public function get_system_instruction( ?string $filename = null ): string { + return $this->load_system_instruction_from_file( $filename ); + } + + /** + * Loads system instruction from a PHP file in the feature's directory. + * + * PHP files should return a string directly, e.g.: + * ```php + * getFileName(); + + if ( ! $file_name ) { + return ''; + } + + $feature_dir = dirname( $file_name ); + + // If explicit filename provided, use it. + if ( null !== $filename ) { + $file_path = trailingslashit( $feature_dir ) . $filename; + + if ( file_exists( $file_path ) && is_readable( $file_path ) ) { + // PHP files should return a string directly. + $content = require_once $file_path; // phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.UsingVariable + + return is_string( $content ) ? wp_strip_all_tags( $content ) : ''; + } + + return ''; + } + + // Automatic detection if no filename provided. + $file_path = trailingslashit( $feature_dir ) . 'system-instruction.php'; + + if ( file_exists( $file_path ) && is_readable( $file_path ) ) { + // PHP files should return a string directly. + $content = require $file_path; // phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.UsingVariable + + return is_string( $content ) ? wp_strip_all_tags( $content ) : ''; + } + + return ''; + } } diff --git a/includes/Features/Title_Generation/Title_Generation.php b/includes/Features/Title_Generation/Title_Generation.php index efeb9a62..80da628c 100644 --- a/includes/Features/Title_Generation/Title_Generation.php +++ b/includes/Features/Title_Generation/Title_Generation.php @@ -9,7 +9,7 @@ namespace WordPress\AI\Features\Title_Generation; -use WordPress\AI\Abilities\Title_Generation as Title_Generation_Ability; +use WordPress\AI\Abilities\Title_Generation\Title_Generation as Title_Generation_Ability; use WordPress\AI\Abstracts\Abstract_Feature; /** diff --git a/includes/bootstrap.php b/includes/bootstrap.php index 140282e8..267f6a83 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -11,6 +11,8 @@ namespace WordPress\AI; +use WordPress\AI_Client\AI_Client; + // Exit if accessed directly. if ( ! defined( 'ABSPATH' ) ) { exit; @@ -175,6 +177,9 @@ function initialize_features(): void { $loader->register_default_features(); $loader->initialize_features(); + // Initialize the WP AI Client. + AI_Client::init(); + add_action( 'wp_abilities_api_categories_init', static function () { diff --git a/includes/helpers.php b/includes/helpers.php new file mode 100644 index 00000000..34d0f612 --- /dev/null +++ b/includes/helpers.php @@ -0,0 +1,160 @@ +#', "\n\n", (string) $content ); + + // Strip all HTML tags. + $content = wp_strip_all_tags( (string) $content ); + + // Remove unrendered shortcode tags. + $content = preg_replace( '#\[.+\](.+)\[/.+\]#', '$1', $content ); + + /** + * Filters the normalized content to allow for additional cleanup. + * + * @since 0.1.0 + * @hook ai_normalize_content + * + * @param string $content The normalized content. + * + * @return string The filtered normalized content. + */ + $content = (string) apply_filters( 'ai_normalize_content', (string) $content ); + + return trim( $content ); +} + +/** + * Returns the context for the given post ID. + * + * @since 0.1.0 + * + * @param int $post_id The ID of the post to get the context for. + * @return array The context for the given post ID. + */ +function get_post_context( int $post_id ): array { + $post = get_post( $post_id ); + $context = array(); + + // If the post doesn't exist, return early. + if ( ! $post ) { + return $context; + } + + /** + * TODO: Might be interesting to add simple Abilities for the following, + * just as a way to demonstrate a different approach to registering Abilities, + * how to call Abilities via PHP and how multiple Abilities can be used together. + * + * Example: Get post content Ability; get post author Ability; get post terms Ability. + */ + + if ( $post->post_content ) { + $context['content'] = normalize_content( (string) apply_filters( 'the_content', $post->post_content ) ); + } + + if ( $post->post_title ) { + $context['current_title'] = $post->post_title; + } + + if ( $post->post_name ) { + $context['slug'] = $post->post_name; + } + + $author = get_user_by( 'ID', $post->post_author ); + if ( $author ) { + $context['author'] = $author->display_name; + } + + if ( $post->post_type ) { + $context['content_type'] = $post->post_type; + } + + if ( $post->post_excerpt ) { + $context['excerpt'] = $post->post_excerpt; + } + + $categories = get_the_terms( $post_id, 'category' ); + if ( $categories && ! is_wp_error( $categories ) ) { + $context['categories'] = implode( ', ', wp_list_pluck( $categories, 'name' ) ); + } + + $tags = get_the_terms( $post_id, 'post_tag' ); + if ( $tags && ! is_wp_error( $tags ) ) { + $context['tags'] = implode( ', ', wp_list_pluck( $tags, 'name' ) ); + } + + return $context; +} + +/** + * Returns the preferred models. + * + * @since 0.1.0 + * + * @return array The preferred models. + */ +function get_preferred_models(): array { + $preferred_models = array( + array( + 'anthropic', + 'claude-haiku-4-5', + ), + array( + 'google', + 'gemini-2.5-flash', + ), + array( + 'openai', + 'gpt-4o-mini', + ), + array( + 'openai', + 'gpt-4.1', + ), + ); + + /** + * Filters the preferred models. + * + * @since 0.1.0 + * @hook ai_preferred_models + * + * @param array $preferred_models The preferred models. + * @return array The filtered preferred models. + */ + return (array) apply_filters( 'ai_preferred_models', $preferred_models ); +} diff --git a/tests/Integration/Includes/Abilities/Title_GenerationTest.php b/tests/Integration/Includes/Abilities/Title_GenerationTest.php index 7af717b6..6066f69f 100644 --- a/tests/Integration/Includes/Abilities/Title_GenerationTest.php +++ b/tests/Integration/Includes/Abilities/Title_GenerationTest.php @@ -7,7 +7,7 @@ namespace WordPress\AI\Tests\Integration\Includes\Abilities; -use WordPress\AI\Abilities\Title_Generation; +use WordPress\AI\Abilities\Title_Generation\Title_Generation; use WordPress\AI\Abstracts\Abstract_Feature; use WP_Error; use WP_UnitTestCase; @@ -41,6 +41,7 @@ protected function load_feature_metadata(): array { public function register(): void { // No-op for testing. } + } /** @@ -124,7 +125,7 @@ public function test_input_schema_returns_expected_structure() { $this->assertArrayHasKey( 'properties', $schema, 'Schema should have properties' ); $this->assertArrayHasKey( 'content', $schema['properties'], 'Schema should have content property' ); $this->assertArrayHasKey( 'post_id', $schema['properties'], 'Schema should have post_id property' ); - $this->assertArrayHasKey( 'n', $schema['properties'], 'Schema should have n property' ); + $this->assertArrayHasKey( 'candidates', $schema['properties'], 'Schema should have candidates property' ); // Verify content property. $this->assertEquals( 'string', $schema['properties']['content']['type'], 'Content should be string type' ); @@ -134,10 +135,10 @@ public function test_input_schema_returns_expected_structure() { $this->assertEquals( 'integer', $schema['properties']['post_id']['type'], 'Post ID should be integer type' ); $this->assertEquals( 'absint', $schema['properties']['post_id']['sanitize_callback'], 'Post ID should use absint' ); - // Verify n property. - $this->assertEquals( 'integer', $schema['properties']['n']['type'], 'n should be integer type' ); - $this->assertEquals( 1, $schema['properties']['n']['minimum'], 'n minimum should be 1' ); - $this->assertEquals( 10, $schema['properties']['n']['maximum'], 'n maximum should be 10' ); + // Verify candidates property. + $this->assertEquals( 'integer', $schema['properties']['candidates']['type'], 'candidates should be integer type' ); + $this->assertEquals( 1, $schema['properties']['candidates']['minimum'], 'candidates minimum should be 1' ); + $this->assertEquals( 10, $schema['properties']['candidates']['maximum'], 'candidates maximum should be 10' ); } /** @@ -161,6 +162,19 @@ public function test_output_schema_returns_expected_structure() { $this->assertEquals( 'string', $schema['properties']['titles']['items']['type'], 'Title items should be string type' ); } + /** + * Test that get_system_instruction() returns the system instruction. + * + * @since 0.1.0 + */ + public function test_get_system_instruction_returns_system_instruction() { + $system_instruction = $this->ability->get_system_instruction(); + + // System instruction may be empty if file doesn't exist, or contain content if it does. + // We just verify it returns a string. + $this->assertIsString( $system_instruction, 'System instruction should be a string' ); + } + /** * Test that execute_callback() handles content parameter correctly. * @@ -172,17 +186,27 @@ public function test_execute_callback_with_content() { $method->setAccessible( true ); $input = array( - 'content' => 'This is some test content.', - 'n' => 3, + 'content' => 'This is some test content.', + 'candidates' => 3, ); - $result = $method->invoke( $this->ability, $input ); + + try { + $result = $method->invoke( $this->ability, $input ); + } catch ( \Throwable $e ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $e->getMessage() ); + return; + } + + // Result may be array (success) or WP_Error (if AI client unavailable). + if ( is_wp_error( $result ) ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $result->get_error_message() ); + return; + } $this->assertIsArray( $result, 'Result should be an array' ); - $this->assertEquals( 'ai/title-generation', $result['name'], 'Feature name should match' ); - $this->assertEquals( 'Title Generation', $result['label'], 'Label should match' ); - $this->assertEquals( 'Generates title suggestions from content', $result['description'], 'Description should match' ); - $this->assertEquals( 'This is some test content.', $result['content'], 'Content should match input' ); - $this->assertEquals( 3, $result['n'], 'n should match input' ); + $this->assertArrayHasKey( 'titles', $result, 'Result should have titles key' ); + $this->assertIsArray( $result['titles'], 'Titles should be an array' ); + $this->assertCount( 3, $result['titles'], 'Should have 3 titles' ); } /** @@ -204,14 +228,27 @@ public function test_execute_callback_with_post_id() { ); $input = array( - 'post_id' => $post_id, - 'n' => 2, + 'post_id' => $post_id, + 'candidates' => 2, ); - $result = $method->invoke( $this->ability, $input ); + + try { + $result = $method->invoke( $this->ability, $input ); + } catch ( \Throwable $e ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $e->getMessage() ); + return; + } + + // Result may be array (success) or WP_Error (if AI client unavailable). + if ( is_wp_error( $result ) ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $result->get_error_message() ); + return; + } $this->assertIsArray( $result, 'Result should be an array' ); - $this->assertEquals( 'This is post content.', $result['content'], 'Content should come from post' ); - $this->assertEquals( $post_id, $result['post_id'], 'Post ID should match' ); + $this->assertArrayHasKey( 'titles', $result, 'Result should have titles key' ); + $this->assertIsArray( $result['titles'], 'Titles should be an array' ); + $this->assertCount( 2, $result['titles'], 'Should have 2 titles' ); } /** @@ -263,10 +300,24 @@ public function test_execute_callback_uses_defaults() { $input = array( 'content' => 'Test content', ); - $result = $method->invoke( $this->ability, $input ); + + try { + $result = $method->invoke( $this->ability, $input ); + } catch ( \Throwable $e ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $e->getMessage() ); + return; + } + + // Result may be array (success) or WP_Error (if AI client unavailable). + if ( is_wp_error( $result ) ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $result->get_error_message() ); + return; + } $this->assertIsArray( $result, 'Result should be an array' ); - $this->assertEquals( 1, $result['n'], 'n should default to 1' ); + $this->assertArrayHasKey( 'titles', $result, 'Result should have titles key' ); + $this->assertIsArray( $result['titles'], 'Titles should be an array' ); + $this->assertCount( 3, $result['titles'], 'Should have 3 titles by default' ); } /** @@ -291,10 +342,25 @@ public function test_execute_callback_post_id_overrides_content() { 'content' => 'This content should be ignored.', 'post_id' => $post_id, ); - $result = $method->invoke( $this->ability, $input ); + + try { + $result = $method->invoke( $this->ability, $input ); + } catch ( \Throwable $e ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $e->getMessage() ); + return; + } + + // Result may be array (success) or WP_Error (if AI client unavailable). + if ( is_wp_error( $result ) ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $result->get_error_message() ); + return; + } $this->assertIsArray( $result, 'Result should be an array' ); - $this->assertEquals( 'Post content takes priority.', $result['content'], 'Post content should override provided content' ); + $this->assertArrayHasKey( 'titles', $result, 'Result should have titles key' ); + $this->assertIsArray( $result['titles'], 'Titles should be an array' ); + // The feature's generate_titles uses the post content, verified by titles being generated. + $this->assertNotEmpty( $result['titles'], 'Should generate titles from post content' ); } /** @@ -355,6 +421,119 @@ public function test_permission_callback_for_logged_out_user() { $this->assertEquals( 'insufficient_capabilities', $result->get_error_code(), 'Error code should be insufficient_capabilities' ); } + /** + * Test that permission_callback() returns true for user with read_post capability. + * + * @since 0.1.0 + */ + public function test_permission_callback_with_post_id_and_read_capability() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + // Create a test post. + $post_id = $this->factory->post->create( + array( + 'post_content' => 'Test content', + 'post_status' => 'publish', + ) + ); + + // Create a user with read capability. + $user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array( 'post_id' => $post_id ) ); + + $this->assertTrue( $result, 'Permission should be granted for user with read_post capability' ); + } + + /** + * Test that permission_callback() returns error for user without read_post capability. + * + * @since 0.1.0 + */ + public function test_permission_callback_with_post_id_without_read_capability() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + // Create a private test post. + $post_id = $this->factory->post->create( + array( + 'post_content' => 'Test content', + 'post_status' => 'private', + ) + ); + + // Create a user without read capability for this post. + $user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array( 'post_id' => $post_id ) ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'insufficient_capabilities', $result->get_error_code(), 'Error code should be insufficient_capabilities' ); + } + + /** + * Test that permission_callback() returns error for non-existent post. + * + * @since 0.1.0 + */ + public function test_permission_callback_with_nonexistent_post_id() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + $user_id = $this->factory->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array( 'post_id' => 99999 ) ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'post_not_found', $result->get_error_code(), 'Error code should be post_not_found' ); + } + + /** + * Test that permission_callback() returns false for post type without show_in_rest. + * + * @since 0.1.0 + */ + public function test_permission_callback_with_post_type_without_show_in_rest() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + // Register a custom post type without show_in_rest. + register_post_type( + 'test_no_rest', + array( + 'public' => true, + 'show_in_rest' => false, + ) + ); + + // Create a test post with this post type. + $post_id = $this->factory->post->create( + array( + 'post_content' => 'Test content', + 'post_type' => 'test_no_rest', + 'post_status' => 'publish', + ) + ); + + $user_id = $this->factory->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array( 'post_id' => $post_id ) ); + + $this->assertFalse( $result, 'Permission should be denied for post type without show_in_rest' ); + + // Clean up. + unregister_post_type( 'test_no_rest' ); + } + /** * Test that meta() returns the expected meta structure. * diff --git a/tests/Integration/Includes/Abstracts/Abstract_AbilityTest.php b/tests/Integration/Includes/Abstracts/Abstract_AbilityTest.php index 913d993f..3b7b0f17 100644 --- a/tests/Integration/Includes/Abstracts/Abstract_AbilityTest.php +++ b/tests/Integration/Includes/Abstracts/Abstract_AbilityTest.php @@ -240,5 +240,28 @@ public function test_constructor_requires_label() { // Attempting to construct without a label should fail because. new Test_Ability( 'test-ability', array() ); } + + /** + * Test that get_system_instruction() returns empty string when no system instruction file exists. + * + * @since 0.1.0 + */ + public function test_get_system_instruction_returns_empty_when_no_file() { + $feature = new Test_Ability_Feature(); + $ability = new Test_Ability( + 'test-ability', + array( + 'label' => $feature->get_label(), + 'description' => $feature->get_description(), + 'feature' => $feature, + ) + ); + + $system_instruction = $ability->get_system_instruction(); + + // Test ability doesn't have a system instruction file, so should return empty string. + $this->assertIsString( $system_instruction, 'System instruction should be a string' ); + $this->assertEquals( '', $system_instruction, 'System instruction should be empty when no file exists' ); + } } diff --git a/tests/Integration/Includes/HelpersTest.php b/tests/Integration/Includes/HelpersTest.php new file mode 100644 index 00000000..971df5f9 --- /dev/null +++ b/tests/Integration/Includes/HelpersTest.php @@ -0,0 +1,293 @@ +assertStringNotContainsString( '&', $result, 'Should remove HTML entities' ); + $this->assertStringNotContainsString( '<', $result, 'Should remove HTML entities' ); + $this->assertStringNotContainsString( '>', $result, 'Should remove HTML entities' ); + } + + /** + * Test that normalize_content() replaces HTML linebreaks with newlines. + * + * @since 0.1.0 + */ + public function test_normalize_content_replaces_linebreaks() { + $content = 'Line 1
Line 2
Line 3'; + $result = \WordPress\AI\normalize_content( $content ); + + $this->assertStringNotContainsString( '
', $result, 'Should remove br tags' ); + $this->assertStringContainsString( "\n\n", $result, 'Should replace br with newlines' ); + } + + /** + * Test that normalize_content() strips HTML tags. + * + * @since 0.1.0 + */ + public function test_normalize_content_strips_html_tags() { + $content = '

Test content with HTML

'; + $result = \WordPress\AI\normalize_content( $content ); + + $this->assertStringNotContainsString( '

', $result, 'Should remove HTML tags' ); + $this->assertStringNotContainsString( '', $result, 'Should remove HTML tags' ); + $this->assertStringNotContainsString( '', $result, 'Should remove HTML tags' ); + $this->assertStringContainsString( 'Test content with HTML', $result, 'Should preserve text content' ); + } + + /** + * Test that normalize_content() removes unrendered shortcode tags. + * + * @since 0.1.0 + */ + public function test_normalize_content_removes_shortcode_tags() { + $content = '[shortcode]content[/shortcode]'; + $result = \WordPress\AI\normalize_content( $content ); + + $this->assertStringNotContainsString( '[shortcode]', $result, 'Should remove shortcode tags' ); + $this->assertStringContainsString( 'content', $result, 'Should preserve shortcode content' ); + } + + /** + * Test that normalize_content() trims whitespace. + * + * @since 0.1.0 + */ + public function test_normalize_content_trims_whitespace() { + $content = ' Test content '; + $result = \WordPress\AI\normalize_content( $content ); + + $this->assertEquals( 'Test content', $result, 'Should trim whitespace' ); + } + + /** + * Test that normalize_content() applies filters. + * + * @since 0.1.0 + */ + public function test_normalize_content_applies_filters() { + add_filter( 'ai_pre_normalize_content', function( $content ) { + return 'Filtered: ' . $content; + } ); + + $result = \WordPress\AI\normalize_content( 'test' ); + + $this->assertStringContainsString( 'Filtered:', $result, 'Should apply pre-normalize filter' ); + + remove_all_filters( 'ai_pre_normalize_content' ); + } + + /** + * Test that get_post_context() returns empty array for non-existent post. + * + * @since 0.1.0 + */ + public function test_get_post_context_returns_empty_for_nonexistent_post() { + $context = \WordPress\AI\get_post_context( 99999 ); + + $this->assertIsArray( $context, 'Should return an array' ); + $this->assertEmpty( $context, 'Should return empty array for non-existent post' ); + } + + /** + * Test that get_post_context() returns post content. + * + * @since 0.1.0 + */ + public function test_get_post_context_returns_post_content() { + $post_id = $this->factory->post->create( + array( + 'post_content' => 'Test post content', + 'post_title' => 'Test Post', + ) + ); + + $context = \WordPress\AI\get_post_context( $post_id ); + + $this->assertIsArray( $context, 'Should return an array' ); + $this->assertArrayHasKey( 'content', $context, 'Should have content key' ); + $this->assertNotEmpty( $context['content'], 'Content should not be empty' ); + } + + /** + * Test that get_post_context() returns post metadata. + * + * @since 0.1.0 + */ + public function test_get_post_context_returns_post_metadata() { + $author_id = $this->factory->user->create( array( 'display_name' => 'Test Author' ) ); + $post_id = $this->factory->post->create( + array( + 'post_title' => 'Test Post Title', + 'post_name' => 'test-post-slug', + 'post_author' => $author_id, + 'post_type' => 'post', + 'post_excerpt' => 'Test excerpt', + ) + ); + + $context = \WordPress\AI\get_post_context( $post_id ); + + $this->assertArrayHasKey( 'current_title', $context, 'Should have current_title' ); + $this->assertEquals( 'Test Post Title', $context['current_title'], 'Title should match' ); + $this->assertArrayHasKey( 'slug', $context, 'Should have slug' ); + $this->assertEquals( 'test-post-slug', $context['slug'], 'Slug should match' ); + $this->assertArrayHasKey( 'author', $context, 'Should have author' ); + $this->assertEquals( 'Test Author', $context['author'], 'Author should match' ); + $this->assertArrayHasKey( 'content_type', $context, 'Should have content_type' ); + $this->assertEquals( 'post', $context['content_type'], 'Content type should match' ); + $this->assertArrayHasKey( 'excerpt', $context, 'Should have excerpt' ); + $this->assertEquals( 'Test excerpt', $context['excerpt'], 'Excerpt should match' ); + } + + /** + * Test that get_post_context() includes categories and tags. + * + * @since 0.1.0 + */ + public function test_get_post_context_includes_categories_and_tags() { + $category_id = $this->factory->category->create( array( 'name' => 'Test Category' ) ); + $tag_id = $this->factory->tag->create( array( 'name' => 'Test Tag' ) ); + $post_id = $this->factory->post->create( + array( + 'post_content' => 'Test content', + ) + ); + + wp_set_post_categories( $post_id, array( $category_id ) ); + wp_set_post_tags( $post_id, array( 'Test Tag' ) ); + + $context = \WordPress\AI\get_post_context( $post_id ); + + $this->assertArrayHasKey( 'categories', $context, 'Should have categories' ); + $this->assertStringContainsString( 'Test Category', $context['categories'], 'Should include category name' ); + $this->assertArrayHasKey( 'tags', $context, 'Should have tags' ); + $this->assertStringContainsString( 'Test Tag', $context['tags'], 'Should include tag name' ); + } + + /** + * Test that get_preferred_models() returns an array. + * + * @since 0.1.0 + */ + public function test_get_preferred_models_returns_array() { + $result = \WordPress\AI\get_preferred_models(); + + $this->assertIsArray( $result, 'Should return an array' ); + $this->assertNotEmpty( $result, 'Should not be empty' ); + } + + /** + * Test that get_preferred_models() returns expected default models. + * + * @since 0.1.0 + */ + public function test_get_preferred_models_returns_default_models() { + $result = \WordPress\AI\get_preferred_models(); + + $this->assertCount( 4, $result, 'Should have 4 preferred models' ); + + // Check first model (anthropic). + $this->assertIsArray( $result[0], 'First model should be an array' ); + $this->assertCount( 2, $result[0], 'First model should have 2 elements' ); + $this->assertEquals( 'anthropic', $result[0][0], 'First model provider should be anthropic' ); + $this->assertEquals( 'claude-haiku-4-5', $result[0][1], 'First model name should be claude-haiku-4-5' ); + + // Check second model (google). + $this->assertIsArray( $result[1], 'Second model should be an array' ); + $this->assertCount( 2, $result[1], 'Second model should have 2 elements' ); + $this->assertEquals( 'google', $result[1][0], 'Second model provider should be google' ); + $this->assertEquals( 'gemini-2.5-flash', $result[1][1], 'Second model name should be gemini-2.5-flash' ); + + // Check third model (openai). + $this->assertIsArray( $result[2], 'Third model should be an array' ); + $this->assertCount( 2, $result[2], 'Third model should have 2 elements' ); + $this->assertEquals( 'openai', $result[2][0], 'Third model provider should be openai' ); + $this->assertEquals( 'gpt-4o-mini', $result[2][1], 'Third model name should be gpt-4o-mini' ); + + // Check fourth model (openai). + $this->assertIsArray( $result[3], 'Fourth model should be an array' ); + $this->assertCount( 2, $result[3], 'Fourth model should have 2 elements' ); + $this->assertEquals( 'openai', $result[3][0], 'Fourth model provider should be openai' ); + $this->assertEquals( 'gpt-4.1', $result[3][1], 'Fourth model name should be gpt-4.1' ); + } + + /** + * Test that get_preferred_models() applies filter. + * + * @since 0.1.0 + */ + public function test_get_preferred_models_applies_filter() { + add_filter( + 'ai_preferred_models', + function( $models ) { + // Add a custom model. + $models[] = array( + 'custom', + 'custom-model', + ); + return $models; + } + ); + + $result = \WordPress\AI\get_preferred_models(); + + $this->assertCount( 5, $result, 'Should have 5 models after filter' ); + $this->assertEquals( 'custom', $result[4][0], 'Fifth model provider should be custom' ); + $this->assertEquals( 'custom-model', $result[4][1], 'Fifth model name should be custom-model' ); + + remove_all_filters( 'ai_preferred_models' ); + } + + /** + * Test that get_preferred_models() filter can replace models. + * + * @since 0.1.0 + */ + public function test_get_preferred_models_filter_can_replace_models() { + add_filter( + 'ai_preferred_models', + function( $models ) { + // Replace with a single model. + return array( + array( + 'test', + 'test-model', + ), + ); + } + ); + + $result = \WordPress\AI\get_preferred_models(); + + $this->assertCount( 1, $result, 'Should have 1 model after filter replacement' ); + $this->assertEquals( 'test', $result[0][0], 'Model provider should be test' ); + $this->assertEquals( 'test-model', $result[0][1], 'Model name should be test-model' ); + + remove_all_filters( 'ai_preferred_models' ); + } +} +