diff --git a/composer.json b/composer.json index 58f3169..e5b53a0 100644 --- a/composer.json +++ b/composer.json @@ -7,7 +7,7 @@ "automattic/jetpack-autoloader": "^5.0", "ext-json": "*", "php": ">=7.4", - "wordpress/abilities-api": "^0.4.0-rc", + "wordpress/abilities-api": "^0.4.0", "wordpress/mcp-adapter": "dev-trunk", "wordpress/wp-ai-client": "dev-trunk" }, diff --git a/composer.lock b/composer.lock index 2feba3f..e965116 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ce5ebe43c6d811e5f62e93a397a25621", + "content-hash": "f275018de0bc539ae98c534ab9e40c5a", "packages": [ { "name": "automattic/jetpack-autoloader", @@ -476,16 +476,16 @@ }, { "name": "wordpress/abilities-api", - "version": "v0.4.0-rc", + "version": "v0.4.0", "source": { "type": "git", "url": "https://github.com/WordPress/abilities-api.git", - "reference": "c5b5b1b900c5748ba4a5e615d448b2b6c2a756da" + "reference": "0759075aed37c4247adbf273bdebec096d52e825" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/WordPress/abilities-api/zipball/c5b5b1b900c5748ba4a5e615d448b2b6c2a756da", - "reference": "c5b5b1b900c5748ba4a5e615d448b2b6c2a756da", + "url": "https://api.github.com/repos/WordPress/abilities-api/zipball/0759075aed37c4247adbf273bdebec096d52e825", + "reference": "0759075aed37c4247adbf273bdebec096d52e825", "shasum": "" }, "require": { @@ -545,7 +545,7 @@ "issues": "https://github.com/WordPress/abilities-api/issues", "source": "https://github.com/WordPress/abilities-api" }, - "time": "2025-10-27T17:04:11+00:00" + "time": "2025-10-29T05:35:31+00:00" }, { "name": "wordpress/mcp-adapter", diff --git a/includes/Abilities/Title_Generation.php b/includes/Abilities/Title_Generation.php new file mode 100644 index 0000000..750dffb --- /dev/null +++ b/includes/Abilities/Title_Generation.php @@ -0,0 +1,207 @@ + The input schema of the ability. + */ + protected function input_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'content' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => esc_html__( 'Content to generate title suggestions for.', 'ai' ), + ), + '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( + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => 10, + 'sanitize_callback' => 'absint', + 'description' => esc_html__( 'Number of titles to generate', 'ai' ), + ), + ), + ); + } + + /** + * Returns the output schema of the ability. + * + * @since 0.1.0 + * + * @return array The output schema of the ability. + */ + protected function output_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'titles' => array( + 'type' => 'array', + 'description' => esc_html__( 'Generated title suggestions.', 'ai' ), + 'items' => array( + 'type' => 'string', + ), + ), + ), + ); + } + + /** + * Executes the ability with the given input arguments. + * + * @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. + */ + protected function execute_callback( $input ) { + // Default arguments. + $args = wp_parse_args( + $input, + array( + 'content' => null, + 'post_id' => null, + 'n' => 1, + ), + ); + + // If a post ID is provided, ensure the post exists before using its' content. + if ( $args['post_id'] ) { + $post = get_post( $args['post_id'] ); + + if ( ! $post ) { + return new WP_Error( + 'post_not_found', + /* translators: %d: Post ID. */ + sprintf( esc_html__( 'Post with ID %d not found.', 'ai' ), absint( $args['post_id'] ) ) + ); + } + + $args['content'] = $post->post_content; + } + + // If we have no content, return an error. + if ( ! $args['content'] ) { + return new WP_Error( + 'content_not_provided', + esc_html__( 'Content is required to generate title suggestions.', 'ai' ) + ); + } + + // TODO: Implement the title generation logic. + + 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'] ), + ); + } + + /** + * Returns the permission callback of the ability. + * + * @since 0.1.0 + * + * @param mixed $args The input arguments to the ability. + * @return bool|\WP_Error True if the user has permission, WP_Error otherwise. + */ + protected function permission_callback( $args ) { + $post_id = isset( $args['post_id'] ) ? absint( $args['post_id'] ) : null; + + if ( $post_id ) { + $post = get_post( $args['post_id'] ); + + // Ensure the post exists. + if ( ! $post ) { + return new WP_Error( + 'post_not_found', + /* translators: %d: Post ID. */ + sprintf( esc_html__( 'Post with ID %d not found.', 'ai' ), absint( $args['post_id'] ) ) + ); + } + + // Ensure the user has permission to read this particular post. + if ( ! current_user_can( 'read_post', $post_id ) ) { + return new WP_Error( + 'insufficient_capabilities', + esc_html__( 'You do not have permission to generate titles for this post.', 'ai' ) + ); + } + + // Ensure the post type is allowed in REST endpoints. + $post_type = get_post_type( $post_id ); + + if ( ! $post_type ) { + return false; + } + + $post_type_obj = get_post_type_object( $post_type ); + + if ( ! $post_type_obj || empty( $post_type_obj->show_in_rest ) ) { + return false; + } + } elseif ( ! current_user_can( 'edit_posts' ) ) { + // Ensure the user has permission to edit posts in general. + return new WP_Error( + 'insufficient_capabilities', + esc_html__( 'You do not have permission to generate titles.', 'ai' ) + ); + } + + return true; + } + + /** + * Returns the meta of the ability. + * + * @since 0.1.0 + * + * @return array The meta of the ability. + */ + protected function meta(): array { + return array( + 'show_in_rest' => true, + ); + } +} diff --git a/includes/Abstracts/Abstract_Ability.php b/includes/Abstracts/Abstract_Ability.php new file mode 100644 index 0000000..8413c25 --- /dev/null +++ b/includes/Abstracts/Abstract_Ability.php @@ -0,0 +1,100 @@ + $properties The properties of the ability. Must include `label`. + */ + public function __construct( string $name, array $properties = array() ) { + parent::__construct( + $name, + array( + 'label' => $properties['label'] ?? '', + 'description' => $properties['description'] ?? '', + 'category' => $this->category(), + 'input_schema' => $this->input_schema(), + 'output_schema' => $this->output_schema(), + 'execute_callback' => array( $this, 'execute_callback' ), + 'permission_callback' => array( $this, 'permission_callback' ), + 'meta' => $this->meta(), + ) + ); + } + + /** + * Returns the category of the ability. + * + * @since 0.1.0 + * + * @return string The category of the ability. + */ + abstract protected function category(): string; + + /** + * Returns the input schema of the ability. + * + * @since 0.1.0 + * + * @return array The input schema of the ability. + */ + abstract protected function input_schema(): array; + + /** + * Returns the output schema of the ability. + * + * @since 0.1.0 + * + * @return array The output schema of the ability. + */ + abstract protected function output_schema(): array; + + /** + * Executes the ability with the given input arguments. + * + * @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. + */ + abstract protected function execute_callback( $input ); + + /** + * Checks whether the current user has permission to execute the ability with the given input arguments. + * + * @since 0.1.0 + * + * @param mixed $input The input arguments to the ability. + * @return bool|\WP_Error True if the user has permission, WP_Error otherwise. + */ + abstract protected function permission_callback( $input ); + + /** + * Returns the meta of the ability. + * + * @since 0.1.0 + * + * @return array The meta of the ability. + */ + abstract protected function meta(): array; +} diff --git a/includes/Feature_Loader.php b/includes/Feature_Loader.php index 14500e3..1ee336e 100644 --- a/includes/Feature_Loader.php +++ b/includes/Feature_Loader.php @@ -102,7 +102,8 @@ public function register_default_features(): void { */ private function get_default_features(): array { $feature_classes = array( - 'WordPress\AI\Features\Example_Feature\Example_Feature', + \WordPress\AI\Features\Example_Feature\Example_Feature::class, + \WordPress\AI\Features\Title_Generation\Title_Generation::class, ); /** diff --git a/includes/Features/Title_Generation/Title_Generation.php b/includes/Features/Title_Generation/Title_Generation.php new file mode 100644 index 0000000..efeb9a6 --- /dev/null +++ b/includes/Features/Title_Generation/Title_Generation.php @@ -0,0 +1,61 @@ + 'title-generation', + 'label' => __( 'Title Generation', 'ai' ), + 'description' => __( 'Generates title suggestions from content', 'ai' ), + ); + } + + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public function register(): void { + add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) ); + } + + /** + * Registers any needed abilities. + * + * @since 0.1.0 + */ + public function register_abilities(): void { + wp_register_ability( + 'ai/' . $this->get_id(), + array( + 'label' => $this->get_label(), + 'description' => $this->get_description(), + 'ability_class' => Title_Generation_Ability::class, + ), + ); + } +} diff --git a/includes/bootstrap.php b/includes/bootstrap.php index 70e6537..bcaf62e 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -172,6 +172,24 @@ function initialize_features(): void { $loader = new Feature_Loader( $registry ); $loader->register_default_features(); $loader->initialize_features(); + + add_action( + 'wp_abilities_api_categories_init', + static function () { + /** + * Register a generic catch-all category that all + * Abilities we register can use. Can re-evaluate this + * in the future if we need/want more specific categories. + */ + wp_register_ability_category( + 'ai-experiments', + array( + 'label' => __( 'AI Experiments', 'ai' ), + 'description' => __( 'Various AI experiment features.', 'ai' ), + ), + ); + } + ); } catch ( \Throwable $t ) { _doing_it_wrong( __NAMESPACE__ . '\initialize_features', diff --git a/phpstan.neon.dist b/phpstan.neon.dist index c482cd9..2691f55 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -22,9 +22,11 @@ parameters: paths: - ai.php - includes/ + - vendor/wordpress/abilities-api/includes/ excludePaths: analyse: - tests/ - - vendor/ + - vendor/**/* + - '!vendor/wordpress/abilities-api/**' analyseAndScan: - node_modules (?) diff --git a/tests/Integration/Includes/Abilities/Title_GenerationTest.php b/tests/Integration/Includes/Abilities/Title_GenerationTest.php new file mode 100644 index 0000000..7af717b --- /dev/null +++ b/tests/Integration/Includes/Abilities/Title_GenerationTest.php @@ -0,0 +1,375 @@ + 'title-generation', + 'label' => 'Title Generation', + 'description' => 'Generates title suggestions from content', + ); + } + + /** + * Registers the feature. + * + * @since 0.1.0 + */ + public function register(): void { + // No-op for testing. + } +} + +/** + * Title_Generation Ability test case. + * + * @since 0.1.0 + */ +class Title_GenerationTest extends WP_UnitTestCase { + + /** + * Title_Generation ability instance. + * + * @var Title_Generation + */ + private $ability; + + /** + * Test feature instance. + * + * @var Test_Title_Generation_Feature + */ + private $feature; + + /** + * Set up test case. + * + * @since 0.1.0 + */ + public function setUp(): void { + parent::setUp(); + + $this->feature = new Test_Title_Generation_Feature(); + $this->ability = new Title_Generation( + 'ai/title-generation', + array( + 'label' => $this->feature->get_label(), + 'description' => $this->feature->get_description(), + ) + ); + } + + /** + * Tear down test case. + * + * @since 0.1.0 + */ + public function tearDown(): void { + wp_set_current_user( 0 ); + parent::tearDown(); + } + + /** + * Test that category() returns the correct category. + * + * @since 0.1.0 + */ + public function test_category_returns_correct_category() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'category' ); + $method->setAccessible( true ); + + $result = $method->invoke( $this->ability ); + + $this->assertEquals( 'ai-experiments', $result, 'Category should be ai-experiments' ); + } + + /** + * Test that input_schema() returns the expected schema structure. + * + * @since 0.1.0 + */ + public function test_input_schema_returns_expected_structure() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'input_schema' ); + $method->setAccessible( true ); + + $schema = $method->invoke( $this->ability ); + + $this->assertIsArray( $schema, 'Input schema should be an array' ); + $this->assertEquals( 'object', $schema['type'], 'Schema type should be object' ); + $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' ); + + // Verify content property. + $this->assertEquals( 'string', $schema['properties']['content']['type'], 'Content should be string type' ); + $this->assertEquals( 'sanitize_text_field', $schema['properties']['content']['sanitize_callback'], 'Content should use sanitize_text_field' ); + + // Verify post_id property. + $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' ); + } + + /** + * Test that output_schema() returns the expected schema structure. + * + * @since 0.1.0 + */ + public function test_output_schema_returns_expected_structure() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'output_schema' ); + $method->setAccessible( true ); + + $schema = $method->invoke( $this->ability ); + + $this->assertIsArray( $schema, 'Output schema should be an array' ); + $this->assertEquals( 'object', $schema['type'], 'Schema type should be object' ); + $this->assertArrayHasKey( 'properties', $schema, 'Schema should have properties' ); + $this->assertArrayHasKey( 'titles', $schema['properties'], 'Schema should have titles property' ); + $this->assertEquals( 'array', $schema['properties']['titles']['type'], 'Titles should be array type' ); + $this->assertArrayHasKey( 'items', $schema['properties']['titles'], 'Titles should have items' ); + $this->assertEquals( 'string', $schema['properties']['titles']['items']['type'], 'Title items should be string type' ); + } + + /** + * Test that execute_callback() handles content parameter correctly. + * + * @since 0.1.0 + */ + public function test_execute_callback_with_content() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $input = array( + 'content' => 'This is some test content.', + 'n' => 3, + ); + $result = $method->invoke( $this->ability, $input ); + + $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' ); + } + + /** + * Test that execute_callback() handles post_id parameter correctly. + * + * @since 0.1.0 + */ + public function test_execute_callback_with_post_id() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + // Create a test post. + $post_id = $this->factory->post->create( + array( + 'post_content' => 'This is post content.', + 'post_title' => 'Test Post', + ) + ); + + $input = array( + 'post_id' => $post_id, + 'n' => 2, + ); + $result = $method->invoke( $this->ability, $input ); + + $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' ); + } + + /** + * Test that execute_callback() returns error when post_id points to non-existent post. + * + * @since 0.1.0 + */ + public function test_execute_callback_with_invalid_post_id() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $input = array( + 'post_id' => 99999, // Non-existent post ID. + ); + $result = $method->invoke( $this->ability, $input ); + + $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 execute_callback() returns error when content is missing. + * + * @since 0.1.0 + */ + public function test_execute_callback_without_content() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $input = array(); + $result = $method->invoke( $this->ability, $input ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'content_not_provided', $result->get_error_code(), 'Error code should be content_not_provided' ); + } + + /** + * Test that execute_callback() uses default values. + * + * @since 0.1.0 + */ + public function test_execute_callback_uses_defaults() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $input = array( + 'content' => 'Test content', + ); + $result = $method->invoke( $this->ability, $input ); + + $this->assertIsArray( $result, 'Result should be an array' ); + $this->assertEquals( 1, $result['n'], 'n should default to 1' ); + } + + /** + * Test that execute_callback() prioritizes post_id over content. + * + * @since 0.1.0 + */ + public function test_execute_callback_post_id_overrides_content() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + // Create a test post. + $post_id = $this->factory->post->create( + array( + 'post_content' => 'Post content takes priority.', + 'post_title' => 'Test Post', + ) + ); + + $input = array( + 'content' => 'This content should be ignored.', + 'post_id' => $post_id, + ); + $result = $method->invoke( $this->ability, $input ); + + $this->assertIsArray( $result, 'Result should be an array' ); + $this->assertEquals( 'Post content takes priority.', $result['content'], 'Post content should override provided content' ); + } + + /** + * Test that permission_callback() returns true for user with edit_posts capability. + * + * @since 0.1.0 + */ + public function test_permission_callback_with_edit_posts_capability() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + // Create a user with edit_posts capability. + $user_id = $this->factory->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array() ); + + $this->assertTrue( $result, 'Permission should be granted for user with edit_posts capability' ); + } + + /** + * Test that permission_callback() returns error for user without edit_posts capability. + * + * @since 0.1.0 + */ + public function test_permission_callback_without_edit_posts_capability() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + // Create a user without edit_posts capability. + $user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array() ); + + $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 logged out user. + * + * @since 0.1.0 + */ + public function test_permission_callback_for_logged_out_user() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + // Ensure no user is logged in. + wp_set_current_user( 0 ); + + $result = $method->invoke( $this->ability, array() ); + + $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 meta() returns the expected meta structure. + * + * @since 0.1.0 + */ + public function test_meta_returns_expected_structure() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'meta' ); + $method->setAccessible( true ); + + $meta = $method->invoke( $this->ability ); + + $this->assertIsArray( $meta, 'Meta should be an array' ); + $this->assertArrayHasKey( 'show_in_rest', $meta, 'Meta should have show_in_rest' ); + $this->assertTrue( $meta['show_in_rest'], 'show_in_rest should be true' ); + } +} + diff --git a/tests/Integration/Includes/Abstracts/Abstract_AbilityTest.php b/tests/Integration/Includes/Abstracts/Abstract_AbilityTest.php new file mode 100644 index 0000000..913d993 --- /dev/null +++ b/tests/Integration/Includes/Abstracts/Abstract_AbilityTest.php @@ -0,0 +1,244 @@ + The input schema of the ability. + */ + protected function input_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'test_input' => array( + 'type' => 'string', + ), + ), + ); + } + + /** + * Returns the output schema of the ability. + * + * @since 0.1.0 + * + * @return array The output schema of the ability. + */ + protected function output_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'test_output' => array( + 'type' => 'string', + ), + ), + ); + } + + /** + * Executes the ability with the given input arguments. + * + * @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. + */ + protected function execute_callback( $input ) { + return array( 'result' => 'test' ); + } + + /** + * Checks whether the current user has permission to execute the ability with the given input arguments. + * + * @since 0.1.0 + * + * @param mixed $input The input arguments to the ability. + * @return bool|\WP_Error True if the user has permission, WP_Error otherwise. + */ + protected function permission_callback( $input ) { + return true; + } + + /** + * Returns the meta of the ability. + * + * @since 0.1.0 + * + * @return array The meta of the ability. + */ + protected function meta(): array { + return array( 'test' => 'meta' ); + } +} + +/** + * Test feature for Abstract_Ability tests. + * + * @since 0.1.0 + */ +class Test_Ability_Feature extends Abstract_Feature { + /** + * Loads feature metadata. + * + * @since 0.1.0 + * + * @return array{id: string, label: string, description: string} Feature metadata. + */ + protected function load_feature_metadata(): array { + return array( + 'id' => 'test-ability-feature', + 'label' => 'Test Ability Feature', + 'description' => 'A test feature for ability testing', + ); + } + + /** + * Registers the feature. + * + * @since 0.1.0 + */ + public function register(): void { + // No-op for testing. + } +} + +/** + * Abstract_Ability test case. + * + * @since 0.1.0 + */ +class Abstract_AbilityTest extends WP_UnitTestCase { + + /** + * Test that constructor properly sets up the ability. + * + * @since 0.1.0 + */ + public function test_constructor_sets_up_ability() { + $feature = new Test_Ability_Feature(); + $ability = new Test_Ability( + 'test-ability', + array( + 'label' => $feature->get_label(), + 'description' => $feature->get_description(), + ) + ); + + $this->assertSame( $feature->get_label(), $ability->get_label(), 'Label should be stored in ability' ); + } + + /** + * Test that constructor calls parent constructor with correct properties. + * + * @since 0.1.0 + */ + public function test_constructor_calls_parent_with_properties() { + $feature = new Test_Ability_Feature(); + $ability = new Test_Ability( + 'test-ability', + array( + 'label' => $feature->get_label(), + 'description' => $feature->get_description(), + ) + ); + + // Verify the ability was registered with WordPress Abilities API. + // We can't directly test parent::__construct, but we can verify the ability exists. + $this->assertInstanceOf( Abstract_Ability::class, $ability, 'Ability should be instance of Abstract_Ability' ); + } + + /** + * Test that label() delegates to feature's get_label(). + * + * @since 0.1.0 + */ + public function test_label_delegates_to_feature() { + $feature = new Test_Ability_Feature(); + $ability = new Test_Ability( + 'test-ability', + array( + 'label' => $feature->get_label(), + 'description' => $feature->get_description(), + ) + ); + + // Use reflection to test protected method. + $reflection = new \ReflectionClass( $ability ); + $method = $reflection->getMethod( 'get_label' ); + $method->setAccessible( true ); + + $result = $method->invoke( $ability ); + + $this->assertEquals( $feature->get_label(), $result, 'Label should match feature label' ); + $this->assertEquals( 'Test Ability Feature', $result, 'Label should be correct' ); + } + + /** + * Test that description() delegates to feature's get_description(). + * + * @since 0.1.0 + */ + public function test_description_delegates_to_feature() { + $feature = new Test_Ability_Feature(); + $ability = new Test_Ability( + 'test-ability', + array( + 'label' => $feature->get_label(), + 'description' => $feature->get_description(), + ) + ); + + // Use reflection to test protected method. + $reflection = new \ReflectionClass( $ability ); + $method = $reflection->getMethod( 'get_description' ); + $method->setAccessible( true ); + + $result = $method->invoke( $ability ); + + $this->assertEquals( $feature->get_description(), $result, 'Description should match feature description' ); + $this->assertEquals( 'A test feature for ability testing', $result, 'Description should be correct' ); + } + + /** + * Test that constructor requires label. + * + * @since 0.1.0 + */ + public function test_constructor_requires_label() { + $this->expectException( \InvalidArgumentException::class ); + $this->expectExceptionMessage( 'The ability properties must contain a `label` string.' ); + + // Attempting to construct without a label should fail because. + new Test_Ability( 'test-ability', array() ); + } +} + diff --git a/tests/Integration/Includes/Feature_LoaderTest.php b/tests/Integration/Includes/Feature_LoaderTest.php index 1d36dcd..a103955 100644 --- a/tests/Integration/Includes/Feature_LoaderTest.php +++ b/tests/Integration/Includes/Feature_LoaderTest.php @@ -82,7 +82,7 @@ public function setUp(): void { } /** - * Test register_default_features registers Example_Feature. + * Test register_default_features registers default features. * * @since 0.1.0 */ @@ -94,9 +94,18 @@ public function test_register_default_features() { 'Example feature should be registered' ); + $this->assertTrue( + $this->registry->has_feature( 'title-generation' ), + 'Title generation feature should be registered' + ); + $feature = $this->registry->get_feature( 'example-feature' ); $this->assertNotNull( $feature, 'Example feature should exist' ); $this->assertEquals( 'example-feature', $feature->get_id() ); + + $feature = $this->registry->get_feature( 'title-generation' ); + $this->assertNotNull( $feature, 'Title generation feature should exist' ); + $this->assertEquals( 'title-generation', $feature->get_id() ); } /** diff --git a/tests/Integration/Includes/Features/Title_Generation/Title_GenerationTest.php b/tests/Integration/Includes/Features/Title_Generation/Title_GenerationTest.php new file mode 100644 index 0000000..7e40f39 --- /dev/null +++ b/tests/Integration/Includes/Features/Title_Generation/Title_GenerationTest.php @@ -0,0 +1,59 @@ +register_default_features(); + + $feature = $registry->get_feature( 'title-generation' ); + $this->assertInstanceOf( Title_Generation::class, $feature, 'Title generation feature should be registered in the registry.' ); + } + + /** + * Tear down test case. + * + * @since 0.1.0 + */ + public function tearDown(): void { + wp_set_current_user( 0 ); + parent::tearDown(); + } + + /** + * Test that the feature is registered correctly. + * + * @since 0.1.0 + */ + public function test_feature_registration() { + $feature = new Title_Generation(); + + $this->assertEquals( 'title-generation', $feature->get_id() ); + $this->assertEquals( 'Title Generation', $feature->get_label() ); + $this->assertTrue( $feature->is_enabled() ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 032629b..352b49e 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -7,11 +7,48 @@ define( 'TESTS_REPO_ROOT_DIR', dirname( __DIR__ ) ); +/** + * Check if WordPress core has the Abilities API (e.g., in trunk). + * + * @return bool True if WordPress core includes Abilities API, false otherwise. + */ +function wp_ai_has_core_abilities_api(): bool { + // Check common WordPress core locations for the Abilities API file. + $possible_paths = array( + // wp-env location + '/var/www/html/wp-includes/abilities-api/class-wp-ability.php', + // Relative to tests directory (typical WordPress test setup) + TESTS_REPO_ROOT_DIR . '/../../../../wp-includes/abilities-api/class-wp-ability.php', + // Relative to plugin directory (alternative test setup) + TESTS_REPO_ROOT_DIR . '/../../../../../wp-includes/abilities-api/class-wp-ability.php', + ); + + foreach ( $possible_paths as $path ) { + if ( file_exists( $path ) ) { + return true; + } + } + + return false; +} + +// Load Abilities API classes before autoloader to ensure WP_Ability class is available. +// Only load from vendor if WordPress core doesn't already include it (e.g., when running against trunk). +if ( ! wp_ai_has_core_abilities_api() && file_exists( TESTS_REPO_ROOT_DIR . '/vendor/wordpress/abilities-api/includes/abilities-api/class-wp-ability.php' ) ) { + require_once TESTS_REPO_ROOT_DIR . '/vendor/wordpress/abilities-api/includes/abilities-api/class-wp-ability.php'; +} + // Load Composer dependencies if applicable. if ( file_exists( TESTS_REPO_ROOT_DIR . '/vendor/autoload.php' ) ) { require_once TESTS_REPO_ROOT_DIR . '/vendor/autoload.php'; } +// Load Abilities API bootstrap for functions. +// Only load from vendor if WordPress core doesn't already include it. +if ( ! wp_ai_has_core_abilities_api() && file_exists( TESTS_REPO_ROOT_DIR . '/vendor/wordpress/abilities-api/includes/bootstrap.php' ) ) { + require_once TESTS_REPO_ROOT_DIR . '/vendor/wordpress/abilities-api/includes/bootstrap.php'; +} + // Detect where to load the WordPress tests environment from. if ( false !== getenv( 'WP_TESTS_DIR' ) ) { $_test_root = getenv( 'WP_TESTS_DIR' ); @@ -37,4 +74,4 @@ static function (): void { ); // Start up the WP testing environment. -require $_test_root . '/includes/bootstrap.php'; \ No newline at end of file +require $_test_root . '/includes/bootstrap.php';