diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c967c099..336adcd7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,10 +12,10 @@ All parameters, return values, and properties should use explicit type hints whe The following naming conventions must be followed for consistency and autoloading: -- Interfaces are suffixed with `_Interface` (e.g., `Feature_Interface`). +- Interfaces are suffixed with `_Interface` (e.g., `Experiment_Interface`). - Traits are suffixed with `_Trait` (e.g., `Validation_Trait`). - File names are the same as the class, trait, and interface name for PSR-4 autoloading. -- Classes use WordPress naming conventions with underscores (e.g., `Feature_Loader`). +- Classes use WordPress naming conventions with underscores (e.g., `Experiment_Loader`). - Namespaces follow the pattern `WordPress\AI\{Component}`. ## Documentation standards @@ -43,20 +43,20 @@ All code must be properly documented with PHPDoc blocks following these standard ```php /** - * Class for handling feature registration. + * Class for handling experiment registration. * * @since 0.1.0 */ -class Feature_Registry { +class Experiment_Registry { /** - * Registers a new feature with the plugin. + * Registers a new experiment with the plugin. * * @since 0.1.0 * - * @param Feature $feature The feature instance to register. + * @param Experiment $experiment The experiment instance to register. * @return bool True if registered successfully, false otherwise. */ - public function register_feature( Feature $feature ): bool { + public function register_experiment( Experiment $experiment ): bool { // Implementation } } @@ -137,8 +137,8 @@ echo 'Hello World'; ## Additional resources -For more detailed information on plugin architecture, creating features, and development workflows, see: +For more detailed information on plugin architecture, creating experiments, and development workflows, see: -- [Developer Guide](docs/DEVELOPER_GUIDE.md) - Comprehensive guide to plugin architecture and feature development +- [Developer Guide](docs/DEVELOPER_GUIDE.md) - Comprehensive guide to plugin architecture and experiment development - [Testing Strategy](docs/TESTING.md) - Testing philosophy and guidelines - [WordPress AI Team](https://make.wordpress.org/ai/) - Community and discussion diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index 36f6bf32..3af89464 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -1,12 +1,12 @@ # Developer Guide -Welcome to the WordPress AI Experiments plugin development guide. This document provides everything you need to know to contribute to the plugin or create your own AI-powered features. +Welcome to the WordPress AI Experiments plugin development guide. This document provides everything you need to know to contribute to the plugin or create your own AI-powered experiments. ## Table of Contents - [Getting Started](#getting-started) - [Architecture Overview](#architecture-overview) -- [Creating a New Feature](#creating-a-new-feature) +- [Creating a New Experiment](#creating-a-new-experiment) - [Plugin API](#plugin-api) - [Development Workflow](#development-workflow) - [Additional Resources](#additional-resources) @@ -51,25 +51,25 @@ wp plugin activate ai ## Architecture Overview -The plugin follows a modular, feature-based architecture: +The plugin follows a modular, experiment-based architecture: ``` ai/ ├── ai.php # Plugin bootstrap ├── includes/ # Core plugin code │ ├── bootstrap.php # Plugin initialization -│ ├── Feature_Registry.php # Feature registration system -│ ├── Feature_Loader.php # Feature loading and initialization +│ ├── Experiment_Registry.php # Experiment registration system +│ ├── Experiment_Loader.php # Experiment loading and initialization │ ├── Abstracts/ # Base implementations -│ │ └── Abstract_Feature.php # Base feature class -│ ├── Contracts/ # Feature interfaces -│ │ └── Feature.php # Feature contract +│ │ └── Abstract_Experiment.php # Base experiment class +│ ├── Contracts/ # Experiment interfaces +│ │ └── Experiment.php # Experiment contract │ ├── Exception/ # Custom exceptions -│ │ ├── Invalid_Feature_Exception.php -│ │ └── Invalid_Feature_Metadata_Exception.php -│ └── Features/ # Feature implementations -│ └── Example_Feature/ # Each feature in own directory -│ ├── Example_Feature.php +│ │ ├── Invalid_Experiment_Exception.php +│ │ └── Invalid_Experiment_Metadata_Exception.php +│ └── Experiments/ # Experiment implementations +│ └── Example_Experiment/ # Each experiment in own directory +│ ├── Example_Experiment.php │ └── README.md ├── admin/ # Admin interface (planned) ├── assets/ # CSS, JS, images @@ -83,64 +83,64 @@ ai/ ### Key Design Principles -1. **Encapsulation**: Each feature is self-contained and can be reviewed independently -2. **Modularity**: Features can be added/removed without affecting core functionality -3. **Extensibility**: Third-party developers can register custom features via hooks +1. **Encapsulation**: Each experiment is self-contained and can be reviewed independently +2. **Modularity**: Experiments can be added/removed without affecting core functionality +3. **Extensibility**: Third-party developers can register custom experiments via hooks 4. **Standards Compliance**: All code follows WordPress coding standards --- -## Creating a New Feature +## Creating a New Experiment -Features are the core building blocks of the AI plugin. Each feature represents a distinct AI capability. +Experiments are the core building blocks of the AI plugin. Each experiment represents a distinct AI capability. -### Step 1: Create Feature Directory +### Step 1: Create Experiment Directory -Create a new directory in `includes/Features/` for your feature: +Create a new directory in `includes/Experiments/` for your experiment: ```bash -mkdir -p includes/Features/My_Feature +mkdir -p includes/Experiments/My_Experiment ``` -### Step 2: Create Feature Class +### Step 2: Create Experiment Class -Create your feature class by extending `Abstract_Feature`: +Create your experiment class by extending `Abstract_Experiment`: ```php 'my-feature', - 'label' => __( 'My Feature', 'ai' ), - 'description' => __( 'Description of what my feature does.', 'ai' ), + 'id' => 'my-experiment', + 'label' => __( 'My Experiment', 'ai' ), + 'description' => __( 'Description of what my experiment does.', 'ai' ), ); } /** - * Registers the feature's hooks and functionality. + * Registers the experiment's hooks and functionality. * * @since 0.1.0 */ @@ -151,12 +151,12 @@ class My_Feature extends Abstract_Feature { } /** - * Initializes the feature. + * Initializes the experiment. * * @since 0.1.0 */ public function initialize(): void { - // Feature initialization logic + // Experiment initialization logic } /** @@ -168,63 +168,63 @@ class My_Feature extends Abstract_Feature { * @return string Modified content. */ public function filter_content( string $content ): string { - // Feature logic here + // Experiment logic here return $content; } } ``` -### Step 3: Register the Feature +### Step 3: Register the Experiment -Add your feature class name to the default features list in `Feature_Loader::get_default_features()`: +Add your experiment class name to the default experiments list in `Experiment_Loader::get_default_experiments()`: ```php -private function get_default_features(): array { - $feature_classes = array( - 'WordPress\AI\Features\Example_Feature\Example_Feature', - 'WordPress\AI\Features\My_Feature\My_Feature', // Add your feature +private function get_default_experiments(): array { + $experiment_classes = array( + 'WordPress\AI\Experiments\Example_Experiment\Example_Experiment', + 'WordPress\AI\Experiments\My_Experiment\My_Experiment', // Add your experiment ); // ... rest of the method } ``` -### Step 4: Add Feature Documentation +### Step 4: Add Experiment Documentation -Create a `README.md` in your feature directory: +Create a `README.md` in your experiment directory: ```markdown -# My Feature +# My Experiment -Brief description of the feature. +Brief description of the experiment. ## Functionality -- What the feature does +- What the experiment does - How it works - Any requirements ## Usage -Examples of how to use the feature. +Examples of how to use the experiment. ## Configuration Any settings or filters available. ``` -### Conditional Features +### Conditional Experiments -If your feature has requirements (PHP extensions, other plugins, etc.), implement validation in your constructor: +If your experiment has requirements (PHP extensions, other plugins, etc.), implement validation in your constructor: ```php -use WordPress\AI\Exception\Invalid_Feature_Metadata_Exception; +use WordPress\AI\Exception\Invalid_Experiment_Metadata_Exception; -class My_Feature extends Abstract_Feature { +class My_Experiment extends Abstract_Experiment { public function __construct() { if ( ! extension_loaded( 'gd' ) ) { - throw new Invalid_Feature_Metadata_Exception( - __( 'This feature requires the GD extension.', 'ai' ) + throw new Invalid_Experiment_Metadata_Exception( + __( 'This experiment requires the GD extension.', 'ai' ) ); } @@ -239,62 +239,57 @@ class My_Feature extends Abstract_Feature { The plugin provides a set of hooks and filters to allow third-party developers to extend its functionality. -### Registering a Custom Feature +### Registering a Custom Experiment -Developers can register their own features using the `ai_register_features` action. This is the primary way to add new functionality to the plugin. +Developers can register their own experiments using the `ai_register_experiments` action. This is the primary way to add new functionality to the plugin. ```php -add_action( 'ai_register_features', function( $registry ) { - $registry->register_feature( new My_Custom_Feature() ); +add_action( 'ai_register_experiments', function( $registry ) { + $registry->register_experiment( new My_Custom_Experiment() ); } ); ``` -### Filtering Default Features +### Filtering Default Experiments -Modify the list of default feature classes before they are instantiated: +Modify the list of default experiment classes before they are instantiated: ```php -add_filter( 'ai_default_feature_classes', function( $feature_classes ) { - // Add a custom feature - $feature_classes[] = 'My_Namespace\My_Custom_Feature'; +add_filter( 'ai_default_experiment_classes', function( $experiment_classes ) { + // Add a custom experiment + $experiment_classes[] = 'My_Namespace\My_Custom_Experiment'; - // Remove a default feature - $key = array_search( 'WordPress\AI\Features\Example_Feature\Example_Feature', $feature_classes ); + // Remove a default experiment + $key = array_search( 'WordPress\AI\Experiments\Example_Experiment\Example_Experiment', $experiment_classes ); if ( false !== $key ) { - unset( $feature_classes[ $key ] ); + unset( $experiment_classes[ $key ] ); } - return $feature_classes; + return $experiment_classes; } ); ``` -### Disabling a Feature +### Disabling an Experiment -Features can be disabled using the `ai_feature_enabled` filter: +Experiments can be disabled using the `ai_experiment_{$experiment_id}_enabled` filter: ```php -add_filter( 'ai_feature_enabled', function( $enabled, $feature_id ) { - if ( 'example-feature' === $feature_id ) { - return false; - } - return $enabled; -}, 10, 2 ); +add_filter( 'ai_experiment_example-experiment_enabled', '__return_false' ); ``` -### Disabling All Features +### Disabling All Experiments -Disable all features at once: +Disable all experiments at once: ```php -add_filter( 'ai_features_enabled', '__return_false' ); +add_filter( 'ai_experiments_enabled', '__return_false' ); ``` ### Other Hooks The plugin also includes the following action hooks: -- `ai_register_features`: Fires after default features are registered, receives `$registry` parameter -- `ai_features_initialized`: Fires after all registered features have been initialized +- `ai_register_experiments`: Fires after default experiments are registered, receives `$registry` parameter +- `ai_experiments_initialized`: Fires after all registered experiments have been initialized --- @@ -306,26 +301,26 @@ The plugin also includes the following action hooks: git checkout -b feature/my-feature-name ``` -### 2. Implement Your Feature +### 2. Implement Your Experiment -Follow the steps in [Creating a New Feature](#creating-a-new-feature) above to build your feature. +Follow the steps in [Creating a New Experiment](#creating-a-new-experiment) above to build your experiment. ### 3. Write Tests -Create unit tests in `tests/Unit/` for your feature: +Create unit tests in `tests/Unit/` for your experiment: ```php assertEquals( 'my-feature', $feature->get_id() ); - $this->assertNotEmpty( $feature->get_label() ); +class My_Experiment_Test extends TestCase { + public function test_experiment_metadata() { + $experiment = new My_Experiment(); + $this->assertEquals( 'my-experiment', $experiment->get_id() ); + $this->assertNotEmpty( $experiment->get_label() ); } } ``` @@ -353,7 +348,7 @@ Push your branch and create a pull request. Follow the contribution guidelines i - [Contributing Guidelines](../CONTRIBUTING.md) - Code standards and contribution process - [Testing Strategy](TESTING.md) - Testing philosophy and guidelines -- [Example Feature](../includes/Features/Example_Feature/README.md) - Reference implementation +- [Example Experiment](../includes/Experiments/Example_Experiment/README.md) - Reference implementation - [WordPress Plugin Handbook](https://developer.wordpress.org/plugins/) - [WordPress AI Team](https://make.wordpress.org/ai/) diff --git a/includes/Abstracts/Abstract_Feature.php b/includes/Abstracts/Abstract_Experiment.php similarity index 50% rename from includes/Abstracts/Abstract_Feature.php rename to includes/Abstracts/Abstract_Experiment.php index 5c9da202..95c4805d 100644 --- a/includes/Abstracts/Abstract_Feature.php +++ b/includes/Abstracts/Abstract_Experiment.php @@ -1,6 +1,6 @@ load_feature_metadata(); + $metadata = $this->load_experiment_metadata(); if ( empty( $metadata['id'] ) ) { - throw new Invalid_Feature_Metadata_Exception( - esc_html__( 'Feature id is required in load_feature_metadata().', 'ai' ) + throw new Invalid_Experiment_Metadata_Exception( + esc_html__( 'Experiment id is required in load_experiment_metadata().', 'ai' ) ); } if ( empty( $metadata['label'] ) ) { - throw new Invalid_Feature_Metadata_Exception( - esc_html__( 'Feature label is required in load_feature_metadata().', 'ai' ) + throw new Invalid_Experiment_Metadata_Exception( + esc_html__( 'Experiment label is required in load_experiment_metadata().', 'ai' ) ); } if ( empty( $metadata['description'] ) ) { - throw new Invalid_Feature_Metadata_Exception( - esc_html__( 'Feature description is required in load_feature_metadata().', 'ai' ) + throw new Invalid_Experiment_Metadata_Exception( + esc_html__( 'Experiment description is required in load_experiment_metadata().', 'ai' ) ); } @@ -88,51 +88,51 @@ final public function __construct() { } /** - * Loads feature metadata. + * Loads experiment metadata. * * Must return an array with keys: id, label, description. * * @since 0.1.0 * - * @return array{id: string, label: string, description: string} Feature metadata. + * @return array{id: string, label: string, description: string} Experiment metadata. */ - abstract protected function load_feature_metadata(): array; + abstract protected function load_experiment_metadata(): array; /** - * Gets the feature ID. + * Gets the experiment ID. * * @since 0.1.0 * - * @return string Feature identifier. + * @return string Experiment identifier. */ public function get_id(): string { return $this->id; } /** - * Gets the feature label. + * Gets the experiment label. * * @since 0.1.0 * - * @return string Translated feature label. + * @return string Translated experiment label. */ public function get_label(): string { return $this->label; } /** - * Gets the feature description. + * Gets the experiment description. * * @since 0.1.0 * - * @return string Translated feature description. + * @return string Translated experiment description. */ public function get_description(): string { return $this->description; } /** - * Checks if feature is enabled. + * Checks if experiment is enabled. * * @since 0.1.0 * @@ -142,19 +142,19 @@ final public function is_enabled(): bool { $enabled = $this->enabled; /** - * Filters the enabled status for a specific feature. + * Filters the enabled status for a specific experiment. * - * The dynamic portion of the hook name, `$this->id`, refers to the feature ID. + * The dynamic portion of the hook name, `$this->id`, refers to the experiment ID. * * @since 0.1.0 * - * @param bool $enabled Whether the feature is enabled. + * @param bool $enabled Whether the experiment is enabled. */ - return (bool) apply_filters( "ai_feature_{$this->id}_enabled", $enabled ); + return (bool) apply_filters( "ai_experiment_{$this->id}_enabled", $enabled ); } /** - * Registers the feature. + * Registers the experiment. * * Must be implemented by child classes to set up hooks and functionality. * diff --git a/includes/Contracts/Experiment.php b/includes/Contracts/Experiment.php new file mode 100644 index 00000000..b1ecb691 --- /dev/null +++ b/includes/Contracts/Experiment.php @@ -0,0 +1,71 @@ +registry = $registry; + } + + /** + * Registers default experiments. + * + * This is where built-in experiments are registered. Third-party experiments + * should use the 'ai_register_experiments' action hook. + * + * @since 0.1.0 + * + * @throws \WordPress\AI\Exception\Invalid_Experiment_Exception If an experiment does not implement the Experiment interface. + */ + public function register_default_experiments(): void { + $experiments = $this->get_default_experiments(); + + // Register all experiments with type validation. + foreach ( $experiments as $experiment ) { + // Skip invalid experiment instances. + if ( ! $experiment instanceof Experiment ) { + throw new Invalid_Experiment_Exception( + esc_html__( 'Attempted to register invalid experiment. Must implement Experiment interface.', 'ai' ) + ); + } + + $this->registry->register_experiment( $experiment ); + } + + /** + * Allows registration of custom experiments. + * + * Third-party developers can use this action to register their own experiments. + * + * Example: + * ```php + * add_action( 'ai_register_experiments', function( $registry ) { + * $registry->register_experiment( new My_Custom_Experiment() ); + * } ); + * ``` + * + * @since 0.1.0 + * + * @param \WordPress\AI\Experiment_Registry $registry The experiment registry instance. + */ + do_action( 'ai_register_experiments', $this->registry ); + } + + /** + * Gets default built-in experiments. + * + * @since 0.1.0 + * + * @return array<\WordPress\AI\Contracts\Experiment> Array of default experiment instances. + * @throws \WordPress\AI\Exception\Invalid_Experiment_Exception If an experiment class does not exist (caught internally). + */ + private function get_default_experiments(): array { + $experiment_classes = array( + \WordPress\AI\Experiments\Example_Experiment\Example_Experiment::class, + \WordPress\AI\Experiments\Title_Generation\Title_Generation::class, + ); + + /** + * Filters the list of default experiment classes or instances. + * + * Allows developers to add, remove, or replace default experiments. + * Can accept both class names (strings) and experiment instances. + * + * @since 0.1.0 + * + * @param array $experiment_classes Array of experiment class names or instances. + */ + $items = apply_filters( 'ai_default_experiment_classes', $experiment_classes ); + + $experiments = array(); + foreach ( $items as $item ) { + try { + // Support both class names and pre-instantiated instances. + if ( is_string( $item ) && class_exists( $item ) ) { + /** @var class-string<\WordPress\AI\Contracts\Experiment> $item */ + $experiments[] = new $item(); + } elseif ( $item instanceof Experiment ) { + $experiments[] = $item; + } elseif ( is_string( $item ) ) { + // Class doesn't exist - throw exception. + throw new Invalid_Experiment_Exception( + sprintf( + /* translators: %s: Experiment class name. */ + esc_html__( 'Experiment class "%s" does not exist.', 'ai' ), + esc_html( $item ) + ) + ); + } + } catch ( Invalid_Experiment_Metadata_Exception $e ) { + // Skip experiments with invalid metadata. + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: 1: Experiment class name, 2: Error message. */ + esc_html__( 'Failed to instantiate experiment "%1$s": %2$s', 'ai' ), + is_string( $item ) ? esc_html( $item ) : esc_html( (string) get_class( $item ) ), + esc_html( $e->getMessage() ) + ), + '0.1.0' + ); + } catch ( Throwable $t ) { + // Skip experiments that fail to instantiate. + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: 1: Experiment class name, 2: Error message. */ + esc_html__( 'Experiment instantiation error for "%1$s": %2$s', 'ai' ), + is_string( $item ) ? esc_html( $item ) : esc_html( (string) get_class( $item ) ), + esc_html( $t->getMessage() ) + ), + '0.1.0' + ); + } + } + + return $experiments; + } + + /** + * Initializes all enabled experiments. + * + * Loops through all registered experiments and calls their register() method + * if they are enabled. + * + * @since 0.1.0 + */ + public function initialize_experiments(): void { + if ( $this->initialized ) { + return; + } + + /** + * Filters whether to enable AI experiments. + * + * @since 0.1.0 + * + * @param bool $enabled Whether to enable AI experiments. + */ + $experiments_enabled = apply_filters( 'ai_experiments_enabled', true ); + + if ( ! $experiments_enabled ) { + $this->initialized = true; + return; + } + + foreach ( $this->registry->get_all_experiments() as $experiment ) { + // Skip if experiment is disabled. + if ( ! $experiment->is_enabled() ) { + continue; + } + + // Register the experiment. + $experiment->register(); + } + + /** + * Fires after all experiments have been initialized. + * + * @since 0.1.0 + */ + do_action( 'ai_experiments_initialized' ); + + $this->initialized = true; + } + + /** + * Checks if experiments have been initialized. + * + * @since 0.1.0 + * + * @return bool True if initialized, false otherwise. + */ + public function is_initialized(): bool { + return $this->initialized; + } +} diff --git a/includes/Experiment_Registry.php b/includes/Experiment_Registry.php new file mode 100644 index 00000000..ca58e98b --- /dev/null +++ b/includes/Experiment_Registry.php @@ -0,0 +1,89 @@ +get_id(); + + // Validate experiment ID is not empty. + if ( empty( $id ) ) { + return false; + } + + if ( $this->has_experiment( $id ) ) { + return false; + } + + $this->experiments[ $id ] = $experiment; + return true; + } + + /** + * Gets an experiment by ID. + * + * @since 0.1.0 + * + * @param string $id Experiment identifier. + * @return \WordPress\AI\Contracts\Experiment|null Experiment instance or null if not found. + */ + public function get_experiment( string $id ): ?Experiment { + return $this->experiments[ $id ] ?? null; + } + + /** + * Gets all registered experiments. + * + * @since 0.1.0 + * + * @return \WordPress\AI\Contracts\Experiment[] Array of experiment instances keyed by experiment ID. + */ + public function get_all_experiments(): array { + return $this->experiments; + } + + /** + * Checks if an experiment is registered. + * + * @since 0.1.0 + * + * @param string $id Experiment identifier. + * @return bool True if registered, false otherwise. + */ + public function has_experiment( string $id ): bool { + return isset( $this->experiments[ $id ] ); + } +} diff --git a/includes/Features/Example_Feature/Example_Feature.php b/includes/Experiments/Example_Experiment/Example_Experiment.php similarity index 68% rename from includes/Features/Example_Feature/Example_Feature.php rename to includes/Experiments/Example_Experiment/Example_Experiment.php index a23ff994..578b22fc 100644 --- a/includes/Features/Example_Feature/Example_Feature.php +++ b/includes/Experiments/Example_Experiment/Example_Experiment.php @@ -1,32 +1,32 @@ 'example-feature', - 'label' => __( 'Example Feature', 'ai' ), - 'description' => __( 'Demonstrates the AI feature system with example hooks and functionality.', 'ai' ), + 'id' => 'example-experiment', + 'label' => __( 'Example Experiment', 'ai' ), + 'description' => __( 'Demonstrates the AI experiment system with example hooks and functionality.', 'ai' ), ); } @@ -51,7 +51,7 @@ public function add_footer_content(): void { return; } - echo ''; + echo ''; } /** @@ -95,11 +95,11 @@ public function register_rest_route(): void { */ public function rest_endpoint_callback(): array { return array( - 'feature_id' => $this->get_id(), - 'label' => $this->get_label(), - 'description' => $this->get_description(), - 'enabled' => $this->is_enabled(), - 'message' => __( 'Example feature is active!', 'ai' ), + 'experiment_id' => $this->get_id(), + 'label' => $this->get_label(), + 'description' => $this->get_description(), + 'enabled' => $this->is_enabled(), + 'message' => __( 'Example experiment is active!', 'ai' ), ); } diff --git a/includes/Experiments/Example_Experiment/README.md b/includes/Experiments/Example_Experiment/README.md new file mode 100644 index 00000000..e4d1110b --- /dev/null +++ b/includes/Experiments/Example_Experiment/README.md @@ -0,0 +1,48 @@ +# Example Experiment + +Reference implementation showing how to build experiments for the AI plugin. + +## Summary +- Extends `Abstract_Experiment` +- Adds footer markup for logged-in users +- Modifies the document title while `WP_DEBUG` is true +- Registers a REST endpoint at `/wp-json/ai/v1/example` + +## REST Endpoint +This experiment registers a REST endpoint to demonstrate how to expose experiment data. + +**Endpoint:** `GET /wp-json/ai/v1/example` + +**Permission:** `manage_options` + +**Example Response:** +```json +{ + "experiment_id": "example-experiment", + "label": "Example Experiment", + "description": "Demonstrates the AI experiment system with example hooks and functionality.", + "enabled": true, + "message": "Example experiment is active!" +} +``` + +## Disable The Experiment +Use the experiment-specific filter: + +```php +add_filter( 'ai_experiment_example-experiment_enabled', '__return_false' ); +``` + +Or use the generic filter to disable all experiments: + +```php +add_filter( 'ai_experiments_enabled', '__return_false' ); +``` + +## Create Your Own Experiment +1. Duplicate this folder and rename the namespace/class. +2. Extend `WordPress\AI\Abstracts\Abstract_Experiment`. +3. Set experiment properties (`$id`, `$label`, `$description`) in the constructor. +4. Register hooks in the `register()` method. + +See `Example_Experiment.php` for a complete reference. diff --git a/includes/Features/Title_Generation/Title_Generation.php b/includes/Experiments/Title_Generation/Title_Generation.php similarity index 76% rename from includes/Features/Title_Generation/Title_Generation.php rename to includes/Experiments/Title_Generation/Title_Generation.php index 80da628c..666b0e28 100644 --- a/includes/Features/Title_Generation/Title_Generation.php +++ b/includes/Experiments/Title_Generation/Title_Generation.php @@ -1,32 +1,32 @@ 'title-generation', 'label' => __( 'Title Generation', 'ai' ), diff --git a/includes/Feature_Loader.php b/includes/Feature_Loader.php deleted file mode 100644 index 796a07b1..00000000 --- a/includes/Feature_Loader.php +++ /dev/null @@ -1,229 +0,0 @@ -registry = $registry; - } - - /** - * Registers default features. - * - * This is where built-in features are registered. Third-party features - * should use the 'ai_register_features' action hook. - * - * @since 0.1.0 - * - * @throws \WordPress\AI\Exception\Invalid_Feature_Exception If a feature does not implement the Feature interface. - */ - public function register_default_features(): void { - $features = $this->get_default_features(); - - // Register all features with type validation. - foreach ( $features as $feature ) { - // Skip invalid feature instances. - if ( ! $feature instanceof Feature ) { - throw new Invalid_Feature_Exception( - esc_html__( 'Attempted to register invalid feature. Must implement Feature interface.', 'ai' ) - ); - } - - $this->registry->register_feature( $feature ); - } - - /** - * Allows registration of custom features. - * - * Third-party developers can use this action to register their own features. - * - * Example: - * ```php - * add_action( 'ai_register_features', function( $registry ) { - * $registry->register_feature( new My_Custom_Feature() ); - * } ); - * ``` - * - * @since 0.1.0 - * - * @param \WordPress\AI\Feature_Registry $registry The feature registry instance. - */ - do_action( 'ai_register_features', $this->registry ); - } - - /** - * Gets default built-in features. - * - * @since 0.1.0 - * - * @return array<\WordPress\AI\Contracts\Feature> Array of default feature instances. - * @throws \WordPress\AI\Exception\Invalid_Feature_Exception If a feature class does not exist (caught internally). - */ - private function get_default_features(): array { - $feature_classes = array( - \WordPress\AI\Features\Example_Feature\Example_Feature::class, - \WordPress\AI\Features\Title_Generation\Title_Generation::class, - ); - - /** - * Filters the list of default feature classes or instances. - * - * Allows developers to add, remove, or replace default features. - * Can accept both class names (strings) and feature instances. - * - * @since 0.1.0 - * - * @param array $feature_classes Array of feature class names or instances. - */ - $items = apply_filters( 'ai_default_feature_classes', $feature_classes ); - - $features = array(); - foreach ( $items as $item ) { - try { - // Support both class names and pre-instantiated instances. - if ( is_string( $item ) && class_exists( $item ) ) { - /** @var class-string<\WordPress\AI\Contracts\Feature> $item */ - $features[] = new $item(); - } elseif ( $item instanceof Feature ) { - $features[] = $item; - } elseif ( is_string( $item ) ) { - // Class doesn't exist - throw exception. - throw new Invalid_Feature_Exception( - sprintf( - /* translators: %s: Feature class name. */ - esc_html__( 'Feature class "%s" does not exist.', 'ai' ), - esc_html( $item ) - ) - ); - } - } catch ( Invalid_Feature_Metadata_Exception $e ) { - // Skip features with invalid metadata. - _doing_it_wrong( - __METHOD__, - sprintf( - /* translators: 1: Feature class name, 2: Error message. */ - esc_html__( 'Failed to instantiate feature "%1$s": %2$s', 'ai' ), - is_string( $item ) ? esc_html( $item ) : esc_html( (string) get_class( $item ) ), - esc_html( $e->getMessage() ) - ), - '0.1.0' - ); - } catch ( Throwable $t ) { - // Skip features that fail to instantiate. - _doing_it_wrong( - __METHOD__, - sprintf( - /* translators: 1: Feature class name, 2: Error message. */ - esc_html__( 'Feature instantiation error for "%1$s": %2$s', 'ai' ), - is_string( $item ) ? esc_html( $item ) : esc_html( (string) get_class( $item ) ), - esc_html( $t->getMessage() ) - ), - '0.1.0' - ); - } - } - - return $features; - } - - /** - * Initializes all enabled features. - * - * Loops through all registered features and calls their register() method - * if they are enabled. - * - * @since 0.1.0 - */ - public function initialize_features(): void { - if ( $this->initialized ) { - return; - } - - /** - * Filters whether to enable AI features. - * - * @since 0.1.0 - * - * @param bool $enabled Whether to enable AI features. - */ - $features_enabled = apply_filters( 'ai_features_enabled', true ); - - if ( ! $features_enabled ) { - $this->initialized = true; - return; - } - - foreach ( $this->registry->get_all_features() as $feature ) { - // Skip if feature is disabled. - if ( ! $feature->is_enabled() ) { - continue; - } - - // Register the feature. - $feature->register(); - } - - /** - * Fires after all features have been initialized. - * - * @since 0.1.0 - */ - do_action( 'ai_features_initialized' ); - - $this->initialized = true; - } - - /** - * Checks if features have been initialized. - * - * @since 0.1.0 - * - * @return bool True if initialized, false otherwise. - */ - public function is_initialized(): bool { - return $this->initialized; - } -} diff --git a/includes/Feature_Registry.php b/includes/Feature_Registry.php deleted file mode 100644 index a07a49ee..00000000 --- a/includes/Feature_Registry.php +++ /dev/null @@ -1,89 +0,0 @@ -get_id(); - - // Validate feature ID is not empty. - if ( empty( $id ) ) { - return false; - } - - if ( $this->has_feature( $id ) ) { - return false; - } - - $this->features[ $id ] = $feature; - return true; - } - - /** - * Gets a feature by ID. - * - * @since 0.1.0 - * - * @param string $id Feature identifier. - * @return \WordPress\AI\Contracts\Feature|null Feature instance or null if not found. - */ - public function get_feature( string $id ): ?Feature { - return $this->features[ $id ] ?? null; - } - - /** - * Gets all registered features. - * - * @since 0.1.0 - * - * @return \WordPress\AI\Contracts\Feature[] Array of feature instances keyed by feature ID. - */ - public function get_all_features(): array { - return $this->features; - } - - /** - * Checks if a feature is registered. - * - * @since 0.1.0 - * - * @param string $id Feature identifier. - * @return bool True if registered, false otherwise. - */ - public function has_feature( string $id ): bool { - return isset( $this->features[ $id ] ); - } -} diff --git a/includes/Features/Example_Feature/README.md b/includes/Features/Example_Feature/README.md deleted file mode 100644 index 9fc4fb40..00000000 --- a/includes/Features/Example_Feature/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# Example Feature - -Reference implementation showing how to build features for the AI plugin. - -## Summary -- Extends `Abstract_Feature` -- Adds footer markup for logged-in users -- Modifies the document title while `WP_DEBUG` is true -- Registers a REST endpoint at `/wp-json/ai/v1/example` - -## REST Endpoint -The feature registers a REST endpoint to demonstrate how to expose feature data. - -**Endpoint:** `GET /wp-json/ai/v1/example` - -**Permission:** `manage_options` - -**Example Response:** -```json -{ - "feature_id": "example-feature", - "label": "Example Feature", - "description": "Demonstrates the AI feature system with example hooks and functionality.", - "enabled": true, - "message": "Example feature is active!" -} -``` - -## Disable The Feature -Use the feature-specific filter: - -```php -add_filter( 'ai_feature_example-feature_enabled', '__return_false' ); -``` - -Or use the generic filter to disable all features: - -```php -add_filter( 'ai_features_enabled', '__return_false' ); -``` - -## Create Your Own Feature -1. Duplicate this folder and rename the namespace/class. -2. Extend `WordPress\AI\Abstracts\Abstract_Feature`. -3. Set feature properties (`$id`, `$label`, `$description`) in the constructor. -4. Register hooks in the `register()` method. - -See `Example_Feature.php` for a complete reference. diff --git a/includes/bootstrap.php b/includes/bootstrap.php index 267f6a83..1433907a 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -161,21 +161,21 @@ function load(): void { $loaded = true; - // Hook feature initialization to init. - add_action( 'init', __NAMESPACE__ . '\initialize_features' ); + // Hook experiment initialization to init. + add_action( 'init', __NAMESPACE__ . '\initialize_experiments' ); } /** - * Initializes plugin features. + * Initializes plugin experiments. * * @since 0.1.0 */ -function initialize_features(): void { +function initialize_experiments(): void { try { - $registry = new Feature_Registry(); - $loader = new Feature_Loader( $registry ); - $loader->register_default_features(); - $loader->initialize_features(); + $registry = new Experiment_Registry(); + $loader = new Experiment_Loader( $registry ); + $loader->register_default_experiments(); + $loader->initialize_experiments(); // Initialize the WP AI Client. AI_Client::init(); @@ -192,14 +192,14 @@ static function () { 'ai-experiments', array( 'label' => __( 'AI Experiments', 'ai' ), - 'description' => __( 'Various AI experiment features.', 'ai' ), + 'description' => __( 'Various AI experiments.', 'ai' ), ), ); } ); } catch ( \Throwable $t ) { _doing_it_wrong( - __NAMESPACE__ . '\initialize_features', + __NAMESPACE__ . '\initialize_experiments', sprintf( /* translators: %s: Error message. */ esc_html__( 'AI Plugin initialization failed: %s', 'ai' ), diff --git a/tests/Integration/Includes/Abilities/Title_GenerationTest.php b/tests/Integration/Includes/Abilities/Title_GenerationTest.php index 6066f69f..9f33b255 100644 --- a/tests/Integration/Includes/Abilities/Title_GenerationTest.php +++ b/tests/Integration/Includes/Abilities/Title_GenerationTest.php @@ -8,24 +8,24 @@ namespace WordPress\AI\Tests\Integration\Includes\Abilities; use WordPress\AI\Abilities\Title_Generation\Title_Generation; -use WordPress\AI\Abstracts\Abstract_Feature; +use WordPress\AI\Abstracts\Abstract_Experiment; use WP_Error; use WP_UnitTestCase; /** - * Test feature for Title_Generation Ability tests. + * Test experiment for Title_Generation Ability tests. * * @since 0.1.0 */ -class Test_Title_Generation_Feature extends Abstract_Feature { +class Test_Title_Generation_Experiment extends Abstract_Experiment { /** - * Loads feature metadata. + * Loads experiment metadata. * * @since 0.1.0 * - * @return array{id: string, label: string, description: string} Feature metadata. + * @return array{id: string, label: string, description: string} Experiment metadata. */ - protected function load_feature_metadata(): array { + protected function load_experiment_metadata(): array { return array( 'id' => 'title-generation', 'label' => 'Title Generation', @@ -34,7 +34,7 @@ protected function load_feature_metadata(): array { } /** - * Registers the feature. + * Registers the experiment. * * @since 0.1.0 */ @@ -59,11 +59,11 @@ class Title_GenerationTest extends WP_UnitTestCase { private $ability; /** - * Test feature instance. + * Test experiment instance. * - * @var Test_Title_Generation_Feature + * @var Test_Title_Generation_Experiment */ - private $feature; + private $experiment; /** * Set up test case. @@ -73,12 +73,12 @@ class Title_GenerationTest extends WP_UnitTestCase { public function setUp(): void { parent::setUp(); - $this->feature = new Test_Title_Generation_Feature(); + $this->experiment = new Test_Title_Generation_Experiment(); $this->ability = new Title_Generation( 'ai/title-generation', array( - 'label' => $this->feature->get_label(), - 'description' => $this->feature->get_description(), + 'label' => $this->experiment->get_label(), + 'description' => $this->experiment->get_description(), ) ); } diff --git a/tests/Integration/Includes/Abstracts/Abstract_AbilityTest.php b/tests/Integration/Includes/Abstracts/Abstract_AbilityTest.php index 3b7b0f17..6c3c6bd9 100644 --- a/tests/Integration/Includes/Abstracts/Abstract_AbilityTest.php +++ b/tests/Integration/Includes/Abstracts/Abstract_AbilityTest.php @@ -8,7 +8,7 @@ namespace WordPress\AI\Tests\Integration\Includes\Abstracts; use WordPress\AI\Abstracts\Abstract_Ability; -use WordPress\AI\Abstracts\Abstract_Feature; +use WordPress\AI\Abstracts\Abstract_Experiment; use WP_UnitTestCase; /** @@ -101,28 +101,28 @@ protected function meta(): array { } /** - * Test feature for Abstract_Ability tests. + * Test experiment for Abstract_Ability tests. * * @since 0.1.0 */ -class Test_Ability_Feature extends Abstract_Feature { +class Test_Ability_Experiment extends Abstract_Experiment { /** - * Loads feature metadata. + * Loads experiment metadata. * * @since 0.1.0 * - * @return array{id: string, label: string, description: string} Feature metadata. + * @return array{id: string, label: string, description: string} Experiment metadata. */ - protected function load_feature_metadata(): array { + protected function load_experiment_metadata(): array { return array( - 'id' => 'test-ability-feature', - 'label' => 'Test Ability Feature', - 'description' => 'A test feature for ability testing', + 'id' => 'test-ability-experiment', + 'label' => 'Test Ability Experiment', + 'description' => 'A test experiment for ability testing', ); } /** - * Registers the feature. + * Registers the experiment. * * @since 0.1.0 */ @@ -144,16 +144,16 @@ class Abstract_AbilityTest extends WP_UnitTestCase { * @since 0.1.0 */ public function test_constructor_sets_up_ability() { - $feature = new Test_Ability_Feature(); + $experiment = new Test_Ability_Experiment(); $ability = new Test_Ability( 'test-ability', array( - 'label' => $feature->get_label(), - 'description' => $feature->get_description(), + 'label' => $experiment->get_label(), + 'description' => $experiment->get_description(), ) ); - $this->assertSame( $feature->get_label(), $ability->get_label(), 'Label should be stored in ability' ); + $this->assertSame( $experiment->get_label(), $ability->get_label(), 'Label should be stored in ability' ); } /** @@ -162,12 +162,12 @@ public function test_constructor_sets_up_ability() { * @since 0.1.0 */ public function test_constructor_calls_parent_with_properties() { - $feature = new Test_Ability_Feature(); + $experiment = new Test_Ability_Experiment(); $ability = new Test_Ability( 'test-ability', array( - 'label' => $feature->get_label(), - 'description' => $feature->get_description(), + 'label' => $experiment->get_label(), + 'description' => $experiment->get_description(), ) ); @@ -177,17 +177,17 @@ public function test_constructor_calls_parent_with_properties() { } /** - * Test that label() delegates to feature's get_label(). + * Test that label() delegates to experiment's get_label(). * * @since 0.1.0 */ - public function test_label_delegates_to_feature() { - $feature = new Test_Ability_Feature(); + public function test_label_delegates_to_experiment() { + $experiment = new Test_Ability_Experiment(); $ability = new Test_Ability( 'test-ability', array( - 'label' => $feature->get_label(), - 'description' => $feature->get_description(), + 'label' => $experiment->get_label(), + 'description' => $experiment->get_description(), ) ); @@ -198,22 +198,22 @@ public function test_label_delegates_to_feature() { $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' ); + $this->assertEquals( $experiment->get_label(), $result, 'Label should match experiment label' ); + $this->assertEquals( 'Test Ability Experiment', $result, 'Label should be correct' ); } /** - * Test that description() delegates to feature's get_description(). + * Test that description() delegates to experiment's get_description(). * * @since 0.1.0 */ - public function test_description_delegates_to_feature() { - $feature = new Test_Ability_Feature(); + public function test_description_delegates_to_experiment() { + $experiment = new Test_Ability_Experiment(); $ability = new Test_Ability( 'test-ability', array( - 'label' => $feature->get_label(), - 'description' => $feature->get_description(), + 'label' => $experiment->get_label(), + 'description' => $experiment->get_description(), ) ); @@ -224,8 +224,8 @@ public function test_description_delegates_to_feature() { $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' ); + $this->assertEquals( $experiment->get_description(), $result, 'Description should match experiment description' ); + $this->assertEquals( 'A test experiment for ability testing', $result, 'Description should be correct' ); } /** @@ -247,13 +247,12 @@ public function test_constructor_requires_label() { * @since 0.1.0 */ public function test_get_system_instruction_returns_empty_when_no_file() { - $feature = new Test_Ability_Feature(); + $experiment = new Test_Ability_Experiment(); $ability = new Test_Ability( 'test-ability', array( - 'label' => $feature->get_label(), - 'description' => $feature->get_description(), - 'feature' => $feature, + 'label' => $experiment->get_label(), + 'description' => $experiment->get_description(), ) ); diff --git a/tests/Integration/Includes/Experiment_LoaderTest.php b/tests/Integration/Includes/Experiment_LoaderTest.php new file mode 100644 index 00000000..be0deaba --- /dev/null +++ b/tests/Integration/Includes/Experiment_LoaderTest.php @@ -0,0 +1,270 @@ + 'mock-experiment', + 'label' => 'Mock Experiment', + 'description' => 'A mock experiment for testing', + ); + } + + /** + * Registers the experiment. + * + * @since 0.1.0 + */ + public function register(): void { + $this->register_called = true; + } +} + +/** + * Experiment_Loader test case. + * + * @since 0.1.0 + */ +class Experiment_LoaderTest extends WP_UnitTestCase { + /** + * Experiment registry instance. + * + * @var Experiment_Registry + */ + private $registry; + + /** + * Experiment loader instance. + * + * @var Experiment_Loader + */ + private $loader; + + /** + * Setup test case. + * + * @since 0.1.0 + */ + public function setUp(): void { + parent::setUp(); + $this->registry = new Experiment_Registry(); + $this->loader = new Experiment_Loader( $this->registry ); + } + + /** + * Test register_default_experiments registers default experiments. + * + * @since 0.1.0 + */ + public function test_register_default_experiments() { + $this->loader->register_default_experiments(); + + $this->assertTrue( + $this->registry->has_experiment( 'example-experiment' ), + 'Example experiment should be registered' + ); + + $this->assertTrue( + $this->registry->has_experiment( 'title-generation' ), + 'Title generation experiment should be registered' + ); + + $experiment = $this->registry->get_experiment( 'example-experiment' ); + $this->assertNotNull( $experiment, 'Example experiment should exist' ); + $this->assertEquals( 'example-experiment', $experiment->get_id() ); + + $experiment = $this->registry->get_experiment( 'title-generation' ); + $this->assertNotNull( $experiment, 'Title generation experiment should exist' ); + $this->assertEquals( 'title-generation', $experiment->get_id() ); + } + + /** + * Test ai_register_experiments action hook fires. + * + * @since 0.1.0 + */ + public function test_ai_register_experiments_hook_fires() { + $hook_fired = false; + $passed_registry = null; + + add_action( + 'ai_register_experiments', + function ( $registry ) use ( &$hook_fired, &$passed_registry ) { + $hook_fired = true; + $passed_registry = $registry; + } + ); + + $this->loader->register_default_experiments(); + + $this->assertTrue( $hook_fired, 'ai_register_experiments hook should fire' ); + $this->assertSame( + $this->registry, + $passed_registry, + 'Registry should be passed to hook' + ); + } + + /** + * Test third-party experiments can be registered via hook. + * + * @since 0.1.0 + */ + public function test_third_party_experiment_registration() { + add_action( + 'ai_register_experiments', + function ( $registry ) { + $custom_experiment = new Mock_Experiment(); + $registry->register_experiment( $custom_experiment ); + } + ); + + $this->loader->register_default_experiments(); + + $this->assertTrue( + $this->registry->has_experiment( 'mock-experiment' ), + 'Custom experiment should be registered via hook' + ); + } + + /** + * Test initialize_experiments calls register on enabled experiments. + * + * @since 0.1.0 + */ + public function test_initialize_experiments_calls_register() { + $experiment = new Mock_Experiment(); + $this->registry->register_experiment( $experiment ); + + $this->loader->initialize_experiments(); + + $this->assertTrue( + $experiment->register_called, + 'Experiment register() should be called' + ); + } + + /** + * Test initialize_experiments doesn't initialize twice. + * + * @since 0.1.0 + */ + public function test_initialize_experiments_prevents_double_initialization() { + $experiment = new Mock_Experiment(); + $this->registry->register_experiment( $experiment ); + + $this->loader->initialize_experiments(); + $this->assertTrue( $this->loader->is_initialized(), 'Should be initialized' ); + + // Reset the flag to track second call. + $experiment->register_called = false; + + // Try to initialize again. + $this->loader->initialize_experiments(); + + $this->assertFalse( + $experiment->register_called, + 'Experiment register() should not be called twice' + ); + } + + /** + * Test ai_experiments_initialized action fires. + * + * @since 0.1.0 + */ + public function test_ai_experiments_initialized_hook_fires() { + $hook_fired = false; + + add_action( + 'ai_experiments_initialized', + function () use ( &$hook_fired ) { + $hook_fired = true; + } + ); + + $experiment = new Mock_Experiment(); + $this->registry->register_experiment( $experiment ); + + $this->loader->initialize_experiments(); + + $this->assertTrue( $hook_fired, 'ai_experiments_initialized hook should fire' ); + } + + /** + * Test ai_experiments_initialized fires before is_initialized is true. + * + * @since 0.1.0 + */ + public function test_ai_experiments_initialized_fires_before_initialized_flag() { + $initialized_during_hook = null; + + add_action( + 'ai_experiments_initialized', + function () use ( &$initialized_during_hook ) { + $initialized_during_hook = $this->loader->is_initialized(); + } + ); + + $this->loader->initialize_experiments(); + + $this->assertFalse( + $initialized_during_hook, + 'Loader should not be marked initialized during hook' + ); + $this->assertTrue( + $this->loader->is_initialized(), + 'Loader should be initialized after hook' + ); + } + + /** + * Test disabled experiments are skipped during initialization. + * + * @since 0.1.0 + */ + public function test_disabled_experiments_are_skipped() { + $experiment = new Mock_Experiment(); + $this->registry->register_experiment( $experiment ); + + // Disable the experiment. + add_filter( 'ai_experiment_mock-experiment_enabled', '__return_false' ); + + $this->loader->initialize_experiments(); + + $this->assertFalse( + $experiment->register_called, + 'Disabled experiment register() should not be called' + ); + } +} diff --git a/tests/Integration/Includes/Experiment_RegistryTest.php b/tests/Integration/Includes/Experiment_RegistryTest.php new file mode 100644 index 00000000..783f1916 --- /dev/null +++ b/tests/Integration/Includes/Experiment_RegistryTest.php @@ -0,0 +1,188 @@ + 'test-experiment', + 'label' => 'Test Experiment', + 'description' => 'A test experiment for unit testing', + ); + } + + /** + * Registers the experiment. + * + * @since 0.1.0 + */ + public function register(): void { + // No-op for testing. + } +} + +/** + * Experiment_Registry test case. + * + * @since 0.1.0 + */ +class Experiment_Registry_Test extends WP_UnitTestCase { + /** + * Experiment registry instance. + * + * @var Experiment_Registry + */ + private $registry; + + /** + * Setup test case. + * + * @since 0.1.0 + */ + public function setUp(): void { + parent::setUp(); + $this->registry = new Experiment_Registry(); + } + + /** + * Test registering an experiment. + * + * @since 0.1.0 + */ + public function test_register_experiment() { + $experiment = new Test_Experiment(); + $result = $this->registry->register_experiment( $experiment ); + + $this->assertTrue( $result, 'Experiment should register successfully' ); + $this->assertTrue( $this->registry->has_experiment( 'test-experiment' ), 'Experiment should exist in registry' ); + } + + /** + * Test registering duplicate experiment fails. + * + * @since 0.1.0 + */ + public function test_register_duplicate_experiment_fails() { + $experiment = new Test_Experiment(); + $this->registry->register_experiment( $experiment ); + $result = $this->registry->register_experiment( $experiment ); + + $this->assertFalse( $result, 'Duplicate experiment registration should fail' ); + } + + /** + * Test getting a registered experiment. + * + * @since 0.1.0 + */ + public function test_get_experiment() { + $experiment = new Test_Experiment(); + $this->registry->register_experiment( $experiment ); + $retrieved = $this->registry->get_experiment( 'test-experiment' ); + + $this->assertSame( $experiment, $retrieved, 'Should retrieve the same experiment instance' ); + } + + /** + * Test getting non-existent experiment returns null. + * + * @since 0.1.0 + */ + public function test_get_nonexistent_experiment_returns_null() { + $retrieved = $this->registry->get_experiment( 'nonexistent-experiment' ); + + $this->assertNull( $retrieved, 'Non-existent experiment should return null' ); + } + + /** + * Test getting all experiments. + * + * @since 0.1.0 + */ + public function test_get_all_experiments() { + $experiment1 = new Test_Experiment(); + $this->registry->register_experiment( $experiment1 ); + + $experiments = $this->registry->get_all_experiments(); + + $this->assertIsArray( $experiments, 'get_all_experiments should return an array' ); + $this->assertCount( 1, $experiments, 'Should have one experiment' ); + $this->assertArrayHasKey( 'test-experiment', $experiments, 'Experiments array should contain registered experiment' ); + $this->assertSame( $experiment1, $experiments['test-experiment'], 'Should return same instance' ); + } + + /** + * Test has_experiment returns true for existing experiment. + * + * @since 0.1.0 + */ + public function test_has_experiment_returns_true_for_existing_experiment() { + $experiment = new Test_Experiment(); + $this->registry->register_experiment( $experiment ); + + $this->assertTrue( $this->registry->has_experiment( 'test-experiment' ), 'Should find existing experiment' ); + } + + /** + * Test has_experiment returns false for non-existent experiment. + * + * @since 0.1.0 + */ + public function test_has_experiment_returns_false_for_nonexistent_experiment() { + $this->assertFalse( $this->registry->has_experiment( 'nonexistent-experiment' ), 'Should not find non-existent experiment' ); + } + + /** + * Test experiment initialization. + * + * @since 0.1.0 + */ + public function test_initialize_experiments() { + $experiment = new Test_Experiment(); + $this->registry->register_experiment( $experiment ); + + $loader = new Experiment_Loader( $this->registry ); + $loader->initialize_experiments(); + + $this->assertTrue( $loader->is_initialized(), 'Loader should be marked as initialized' ); + } + + /** + * Test that disabled experiments are not initialized. + * + * @since 0.1.0 + */ + public function test_disabled_experiments_not_initialized() { + add_filter( 'ai_experiment_test-experiment_enabled', '__return_false' ); + + $experiment = new Test_Experiment(); + $this->registry->register_experiment( $experiment ); + + $loader = new Experiment_Loader( $this->registry ); + $loader->initialize_experiments(); + + $this->assertFalse( $experiment->is_enabled(), 'Experiment should be disabled' ); + } +} diff --git a/tests/Integration/Includes/Features/Example_Feature/Example_FeatureTest.php b/tests/Integration/Includes/Experiments/Example_Experiment/Example_ExperimentTest.php similarity index 75% rename from tests/Integration/Includes/Features/Example_Feature/Example_FeatureTest.php rename to tests/Integration/Includes/Experiments/Example_Experiment/Example_ExperimentTest.php index 4d0db0cb..6ce29fd6 100644 --- a/tests/Integration/Includes/Features/Example_Feature/Example_FeatureTest.php +++ b/tests/Integration/Includes/Experiments/Example_Experiment/Example_ExperimentTest.php @@ -1,23 +1,23 @@ register_default_features(); + $registry = new Experiment_Registry(); + $loader = new Experiment_Loader( $registry ); + $loader->register_default_experiments(); - $feature = $registry->get_feature( 'example-feature' ); - $this->assertInstanceOf( Example_Feature::class, $feature, 'Example feature should be registered in the registry.' ); + $experiment = $registry->get_experiment( 'example-experiment' ); + $this->assertInstanceOf( Example_Experiment::class, $experiment, 'Example experiment should be registered in the registry.' ); } /** @@ -45,16 +45,16 @@ public function tearDown(): void { } /** - * Test that the feature is registered correctly. + * Test that the experiment is registered correctly. * * @since 0.1.0 */ - public function test_feature_registration() { - $feature = new Example_Feature(); + public function test_experiment_registration() { + $experiment = new Example_Experiment(); - $this->assertEquals( 'example-feature', $feature->get_id() ); - $this->assertEquals( 'Example Feature', $feature->get_label() ); - $this->assertTrue( $feature->is_enabled() ); + $this->assertEquals( 'example-experiment', $experiment->get_id() ); + $this->assertEquals( 'Example Experiment', $experiment->get_label() ); + $this->assertTrue( $experiment->is_enabled() ); } /** @@ -71,7 +71,7 @@ public function test_add_footer_content_for_logged_in_users() { do_action( 'wp_footer' ); $footer_content = ob_get_clean(); - $this->assertStringContainsString( '', $footer_content ); + $this->assertStringContainsString( '', $footer_content ); } /** @@ -88,7 +88,7 @@ public function test_add_footer_content_for_logged_out_users() { do_action( 'wp_footer' ); $footer_content = ob_get_clean(); - $this->assertStringNotContainsString( '', $footer_content ); + $this->assertStringNotContainsString( '', $footer_content ); } /** @@ -168,9 +168,9 @@ public function test_rest_endpoint_callback() { $data = $response->get_data(); $this->assertEquals( 200, $response->get_status() ); - $this->assertEquals( 'example-feature', $data['feature_id'] ); - $this->assertEquals( 'Example Feature', $data['label'] ); - $this->assertEquals( 'Example feature is active!', $data['message'] ); + $this->assertEquals( 'example-experiment', $data['experiment_id'] ); + $this->assertEquals( 'Example Experiment', $data['label'] ); + $this->assertEquals( 'Example experiment is active!', $data['message'] ); } /** diff --git a/tests/Integration/Includes/Experiments/Title_Generation/Title_GenerationTest.php b/tests/Integration/Includes/Experiments/Title_Generation/Title_GenerationTest.php new file mode 100644 index 00000000..f3f6a9ce --- /dev/null +++ b/tests/Integration/Includes/Experiments/Title_Generation/Title_GenerationTest.php @@ -0,0 +1,59 @@ +register_default_experiments(); + + $experiment = $registry->get_experiment( 'title-generation' ); + $this->assertInstanceOf( Title_Generation::class, $experiment, 'Title generation experiment 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 experiment is registered correctly. + * + * @since 0.1.0 + */ + public function test_experiment_registration() { + $experiment = new Title_Generation(); + + $this->assertEquals( 'title-generation', $experiment->get_id() ); + $this->assertEquals( 'Title Generation', $experiment->get_label() ); + $this->assertTrue( $experiment->is_enabled() ); + } +} diff --git a/tests/Integration/Includes/Feature_LoaderTest.php b/tests/Integration/Includes/Feature_LoaderTest.php deleted file mode 100644 index a103955f..00000000 --- a/tests/Integration/Includes/Feature_LoaderTest.php +++ /dev/null @@ -1,270 +0,0 @@ - 'mock-feature', - 'label' => 'Mock Feature', - 'description' => 'A mock feature for testing', - ); - } - - /** - * Registers the feature. - * - * @since 0.1.0 - */ - public function register(): void { - $this->register_called = true; - } -} - -/** - * Feature_Loader test case. - * - * @since 0.1.0 - */ -class Feature_LoaderTest extends WP_UnitTestCase { - /** - * Feature registry instance. - * - * @var Feature_Registry - */ - private $registry; - - /** - * Feature loader instance. - * - * @var Feature_Loader - */ - private $loader; - - /** - * Setup test case. - * - * @since 0.1.0 - */ - public function setUp(): void { - parent::setUp(); - $this->registry = new Feature_Registry(); - $this->loader = new Feature_Loader( $this->registry ); - } - - /** - * Test register_default_features registers default features. - * - * @since 0.1.0 - */ - public function test_register_default_features() { - $this->loader->register_default_features(); - - $this->assertTrue( - $this->registry->has_feature( 'example-feature' ), - '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() ); - } - - /** - * Test ai_register_features action hook fires. - * - * @since 0.1.0 - */ - public function test_ai_register_features_hook_fires() { - $hook_fired = false; - $passed_registry = null; - - add_action( - 'ai_register_features', - function ( $registry ) use ( &$hook_fired, &$passed_registry ) { - $hook_fired = true; - $passed_registry = $registry; - } - ); - - $this->loader->register_default_features(); - - $this->assertTrue( $hook_fired, 'ai_register_features hook should fire' ); - $this->assertSame( - $this->registry, - $passed_registry, - 'Registry should be passed to hook' - ); - } - - /** - * Test third-party features can be registered via hook. - * - * @since 0.1.0 - */ - public function test_third_party_feature_registration() { - add_action( - 'ai_register_features', - function ( $registry ) { - $custom_feature = new Mock_Feature(); - $registry->register_feature( $custom_feature ); - } - ); - - $this->loader->register_default_features(); - - $this->assertTrue( - $this->registry->has_feature( 'mock-feature' ), - 'Custom feature should be registered via hook' - ); - } - - /** - * Test initialize_features calls register on enabled features. - * - * @since 0.1.0 - */ - public function test_initialize_features_calls_register() { - $feature = new Mock_Feature(); - $this->registry->register_feature( $feature ); - - $this->loader->initialize_features(); - - $this->assertTrue( - $feature->register_called, - 'Feature register() should be called' - ); - } - - /** - * Test initialize_features doesn't initialize twice. - * - * @since 0.1.0 - */ - public function test_initialize_features_prevents_double_initialization() { - $feature = new Mock_Feature(); - $this->registry->register_feature( $feature ); - - $this->loader->initialize_features(); - $this->assertTrue( $this->loader->is_initialized(), 'Should be initialized' ); - - // Reset the flag to track second call. - $feature->register_called = false; - - // Try to initialize again. - $this->loader->initialize_features(); - - $this->assertFalse( - $feature->register_called, - 'Feature register() should not be called twice' - ); - } - - /** - * Test ai_features_initialized action fires. - * - * @since 0.1.0 - */ - public function test_ai_features_initialized_hook_fires() { - $hook_fired = false; - - add_action( - 'ai_features_initialized', - function () use ( &$hook_fired ) { - $hook_fired = true; - } - ); - - $feature = new Mock_Feature(); - $this->registry->register_feature( $feature ); - - $this->loader->initialize_features(); - - $this->assertTrue( $hook_fired, 'ai_features_initialized hook should fire' ); - } - - /** - * Test ai_features_initialized fires before is_initialized is true. - * - * @since 0.1.0 - */ - public function test_ai_features_initialized_fires_before_initialized_flag() { - $initialized_during_hook = null; - - add_action( - 'ai_features_initialized', - function () use ( &$initialized_during_hook ) { - $initialized_during_hook = $this->loader->is_initialized(); - } - ); - - $this->loader->initialize_features(); - - $this->assertFalse( - $initialized_during_hook, - 'Loader should not be marked initialized during hook' - ); - $this->assertTrue( - $this->loader->is_initialized(), - 'Loader should be initialized after hook' - ); - } - - /** - * Test disabled features are skipped during initialization. - * - * @since 0.1.0 - */ - public function test_disabled_features_are_skipped() { - $feature = new Mock_Feature(); - $this->registry->register_feature( $feature ); - - // Disable the feature. - add_filter( 'ai_feature_mock-feature_enabled', '__return_false' ); - - $this->loader->initialize_features(); - - $this->assertFalse( - $feature->register_called, - 'Disabled feature register() should not be called' - ); - } -} diff --git a/tests/Integration/Includes/Feature_RegistryTest.php b/tests/Integration/Includes/Feature_RegistryTest.php deleted file mode 100644 index 0d17c007..00000000 --- a/tests/Integration/Includes/Feature_RegistryTest.php +++ /dev/null @@ -1,188 +0,0 @@ - 'test-feature', - 'label' => 'Test Feature', - 'description' => 'A test feature for unit testing', - ); - } - - /** - * Registers the feature. - * - * @since 0.1.0 - */ - public function register(): void { - // No-op for testing. - } -} - -/** - * Feature_Registry test case. - * - * @since 0.1.0 - */ -class Feature_Registry_Test extends WP_UnitTestCase { - /** - * Feature registry instance. - * - * @var Feature_Registry - */ - private $registry; - - /** - * Setup test case. - * - * @since 0.1.0 - */ - public function setUp(): void { - parent::setUp(); - $this->registry = new Feature_Registry(); - } - - /** - * Test registering a feature. - * - * @since 0.1.0 - */ - public function test_register_feature() { - $feature = new Test_Feature(); - $result = $this->registry->register_feature( $feature ); - - $this->assertTrue( $result, 'Feature should register successfully' ); - $this->assertTrue( $this->registry->has_feature( 'test-feature' ), 'Feature should exist in registry' ); - } - - /** - * Test registering duplicate feature fails. - * - * @since 0.1.0 - */ - public function test_register_duplicate_feature_fails() { - $feature = new Test_Feature(); - $this->registry->register_feature( $feature ); - $result = $this->registry->register_feature( $feature ); - - $this->assertFalse( $result, 'Duplicate feature registration should fail' ); - } - - /** - * Test getting a registered feature. - * - * @since 0.1.0 - */ - public function test_get_feature() { - $feature = new Test_Feature(); - $this->registry->register_feature( $feature ); - $retrieved = $this->registry->get_feature( 'test-feature' ); - - $this->assertSame( $feature, $retrieved, 'Should retrieve the same feature instance' ); - } - - /** - * Test getting non-existent feature returns null. - * - * @since 0.1.0 - */ - public function test_get_nonexistent_feature_returns_null() { - $retrieved = $this->registry->get_feature( 'nonexistent-feature' ); - - $this->assertNull( $retrieved, 'Non-existent feature should return null' ); - } - - /** - * Test getting all features. - * - * @since 0.1.0 - */ - public function test_get_all_features() { - $feature1 = new Test_Feature(); - $this->registry->register_feature( $feature1 ); - - $features = $this->registry->get_all_features(); - - $this->assertIsArray( $features, 'get_all_features should return an array' ); - $this->assertCount( 1, $features, 'Should have one feature' ); - $this->assertArrayHasKey( 'test-feature', $features, 'Features array should contain registered feature' ); - $this->assertSame( $feature1, $features['test-feature'], 'Should return same instance' ); - } - - /** - * Test has_feature returns true for existing feature. - * - * @since 0.1.0 - */ - public function test_has_feature_returns_true_for_existing_feature() { - $feature = new Test_Feature(); - $this->registry->register_feature( $feature ); - - $this->assertTrue( $this->registry->has_feature( 'test-feature' ), 'Should find existing feature' ); - } - - /** - * Test has_feature returns false for non-existent feature. - * - * @since 0.1.0 - */ - public function test_has_feature_returns_false_for_nonexistent_feature() { - $this->assertFalse( $this->registry->has_feature( 'nonexistent-feature' ), 'Should not find non-existent feature' ); - } - - /** - * Test feature initialization. - * - * @since 0.1.0 - */ - public function test_initialize_features() { - $feature = new Test_Feature(); - $this->registry->register_feature( $feature ); - - $loader = new Feature_Loader( $this->registry ); - $loader->initialize_features(); - - $this->assertTrue( $loader->is_initialized(), 'Loader should be marked as initialized' ); - } - - /** - * Test that disabled features are not initialized. - * - * @since 0.1.0 - */ - public function test_disabled_features_not_initialized() { - add_filter( 'ai_feature_test-feature_enabled', '__return_false' ); - - $feature = new Test_Feature(); - $this->registry->register_feature( $feature ); - - $loader = new Feature_Loader( $this->registry ); - $loader->initialize_features(); - - $this->assertFalse( $feature->is_enabled(), 'Feature should be disabled' ); - } -} diff --git a/tests/Integration/Includes/Features/Title_Generation/Title_GenerationTest.php b/tests/Integration/Includes/Features/Title_Generation/Title_GenerationTest.php deleted file mode 100644 index 7e40f396..00000000 --- a/tests/Integration/Includes/Features/Title_Generation/Title_GenerationTest.php +++ /dev/null @@ -1,59 +0,0 @@ -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() ); - } -}