Add the WooCommerce features engine (#34616)

Includes adapting the existing Admin features (enable analytics, enable the new navigation) to use the new engine.
This commit is contained in:
Néstor Soriano 2022-09-16 09:17:22 +02:00 committed by GitHub
parent 04b74c1053
commit f65c775da3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1367 additions and 119 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add the WooCommerce features engine

View File

@ -12,6 +12,7 @@ use Automattic\WooCommerce\Internal\AssignDefaultCategory;
use Automattic\WooCommerce\Internal\BatchProcessing\BatchProcessingController;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\DownloadPermissionsAdjuster;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\DataRegenerator;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register as ProductDownloadDirectories;
@ -230,6 +231,7 @@ final class WooCommerce {
$container->get( CustomOrdersTableController::class );
$container->get( OptionSanitizer::class );
$container->get( BatchProcessingController::class );
$container->get( FeaturesController::class );
}
/**

View File

@ -8,6 +8,7 @@ namespace Automattic\WooCommerce\Admin\Features;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Internal\Admin\Loader;
use Automattic\WooCommerce\Internal\Admin\WCAdminAssets;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
/**
* Features Class.
@ -61,8 +62,6 @@ class Features {
$this->register_internal_class_aliases();
// Load feature before WooCommerce update hooks.
add_action( 'init', array( __CLASS__, 'load_features' ), 4 );
add_filter( 'woocommerce_get_sections_advanced', array( __CLASS__, 'add_features_section' ) );
add_filter( 'woocommerce_get_settings_advanced', array( __CLASS__, 'add_features_settings' ), 10, 2 );
add_action( 'admin_enqueue_scripts', array( __CLASS__, 'maybe_load_beta_features_modal' ) );
add_action( 'admin_enqueue_scripts', array( __CLASS__, 'load_scripts' ), 15 );
add_filter( 'admin_body_class', array( __CLASS__, 'add_admin_body_classes' ) );
@ -250,70 +249,26 @@ class Features {
/**
* Adds the Features section to the advanced tab of WooCommerce Settings
*
* @deprecated 7.0 The WooCommerce Admin features are now handled by the WooCommerce features engine (see the FeaturesController class).
*
* @param array $sections Sections.
* @return array
*/
public static function add_features_section( $sections ) {
$features = apply_filters(
'woocommerce_settings_features',
array()
);
if ( empty( $features ) ) {
return $sections;
}
$sections['features'] = __( 'Features', 'woocommerce' );
return $sections;
}
/**
* Adds the Features settings.
*
* @deprecated 7.0 The WooCommerce Admin features are now handled by the WooCommerce features engine (see the FeaturesController class).
*
* @param array $settings Settings.
* @param string $current_section Current section slug.
* @return array
*/
public static function add_features_settings( $settings, $current_section ) {
if ( 'features' !== $current_section ) {
return $settings;
}
$features = apply_filters(
'woocommerce_settings_features',
array()
);
$features_disabled = apply_filters( 'woocommerce_admin_disabled', false );
if ( ! $features_disabled && empty( $features ) ) {
return $settings;
}
$desc = __( 'Start using new features that are being progressively rolled out to improve the store management experience.', 'woocommerce' );
$disabled_desc = __( 'WooCommerce features have been disabled.', 'woocommerce' );
if ( $features_disabled ) {
$GLOBALS['hide_save_button'] = true;
}
return array_merge(
array(
array(
'title' => __( 'Features', 'woocommerce' ),
'type' => 'title',
'desc' => $features_disabled ? $disabled_desc : $desc,
'id' => 'features_options',
),
),
$features_disabled ? array() : $features,
array(
array(
'type' => 'sectionend',
'id' => 'features_options',
),
)
);
return $settings;
}
/**

View File

@ -34,7 +34,6 @@ class Init {
* Hook into WooCommerce.
*/
public function __construct() {
add_filter( 'woocommerce_settings_features', array( $this, 'add_feature_toggle' ) );
add_action( 'update_option_' . self::TOGGLE_OPTION_NAME, array( $this, 'reload_page_on_toggle' ), 10, 2 );
add_action( 'woocommerce_settings_saved', array( $this, 'maybe_reload_page' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'maybe_enqueue_opt_out_scripts' ) );
@ -49,33 +48,12 @@ class Init {
/**
* Add the feature toggle to the features settings.
*
* @deprecated 7.0 The WooCommerce Admin features are now handled by the WooCommerce features engine (see the FeaturesController class).
*
* @param array $features Feature sections.
* @return array
*/
public static function add_feature_toggle( $features ) {
$description = __(
'Adds the new WooCommerce navigation experience to the dashboard',
'woocommerce'
);
$update_text = '';
$needs_update = version_compare( get_bloginfo( 'version' ), '5.6', '<' );
if ( $needs_update && current_user_can( 'update_core' ) && current_user_can( 'update_php' ) ) {
$update_text = sprintf(
/* translators: 1: line break tag, 2: open link to WordPress update link, 3: close link tag. */
__( '%1$s %2$sUpdate WordPress to enable the new navigation%3$s', 'woocommerce' ),
'<br/>',
'<a href="' . self_admin_url( 'update-core.php' ) . '" target="_blank">',
'</a>'
);
}
$features[] = array(
'title' => __( 'Navigation', 'woocommerce' ),
'desc' => $description . $update_text,
'id' => self::TOGGLE_OPTION_NAME,
'type' => 'checkbox',
'class' => $needs_update ? 'disabled' : '',
);
return $features;
}
@ -119,7 +97,7 @@ class Init {
return;
}
if ( $value !== 'yes' ) {
if ( 'yes' !== $value ) {
update_option( 'woocommerce_navigation_show_opt_out', 'yes' );
}

View File

@ -9,6 +9,7 @@ use Automattic\WooCommerce\Internal\DependencyManagement\ExtendedContainer;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\COTMigrationServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\DownloadPermissionsAdjusterServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\AssignDefaultCategoryServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\FeaturesServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\OrdersControllersServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\OrderAdminServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\OrderMetaBoxServiceProvider;
@ -63,6 +64,7 @@ final class Container {
BatchProcessingServiceProvider::class,
OrderMetaBoxServiceProvider::class,
OrderAdminServiceProvider::class,
FeaturesServiceProvider::class,
);
/**

View File

@ -49,7 +49,6 @@ class Analytics {
* Hook into WooCommerce.
*/
public function __construct() {
add_filter( 'woocommerce_settings_features', array( $this, 'add_feature_toggle' ) );
add_action( 'update_option_' . self::TOGGLE_OPTION_NAME, array( $this, 'reload_page_on_toggle' ), 10, 2 );
add_action( 'woocommerce_settings_saved', array( $this, 'maybe_reload_page' ) );
@ -66,24 +65,12 @@ class Analytics {
/**
* Add the feature toggle to the features settings.
*
* @deprecated 7.0 The WooCommerce Admin features are now handled by the WooCommerce features engine (see the FeaturesController class).
*
* @param array $features Feature sections.
* @return array
*/
public static function add_feature_toggle( $features ) {
$description = __(
'Enables WooCommerce Analytics',
'woocommerce'
);
$features[] = array(
'title' => __( 'Analytics', 'woocommerce' ),
'desc' => $description,
'id' => self::TOGGLE_OPTION_NAME,
'type' => 'checkbox',
'default' => 'yes',
'class' => '',
);
return $features;
}

View File

@ -6,6 +6,7 @@
namespace Automattic\WooCommerce\Internal\DataStores\Orders;
use Automattic\WooCommerce\Internal\BatchProcessing\BatchProcessingController;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
defined( 'ABSPATH' ) || exit;
@ -74,18 +75,16 @@ class CustomOrdersTableController {
private $batch_processing_controller;
/**
* Is the feature visible?
* The features controller to use.
*
* @var bool
* @var FeaturesController
*/
private $is_feature_visible;
private $features_controller;
/**
* Class constructor.
*/
public function __construct() {
$this->is_feature_visible = false;
$this->init_hooks();
}
@ -113,12 +112,19 @@ class CustomOrdersTableController {
* @param DataSynchronizer $data_synchronizer The data synchronizer to use.
* @param OrdersTableRefundDataStore $refund_data_store The refund data store to use.
* @param BatchProcessingController $batch_processing_controller The batch processing controller to use.
* @param FeaturesController $features_controller The features controller instance to use.
*/
final public function init( OrdersTableDataStore $data_store, DataSynchronizer $data_synchronizer, OrdersTableRefundDataStore $refund_data_store, BatchProcessingController $batch_processing_controller ) {
final public function init(
OrdersTableDataStore $data_store,
DataSynchronizer $data_synchronizer,
OrdersTableRefundDataStore $refund_data_store,
BatchProcessingController $batch_processing_controller,
FeaturesController $features_controller ) {
$this->data_store = $data_store;
$this->data_synchronizer = $data_synchronizer;
$this->batch_processing_controller = $batch_processing_controller;
$this->refund_data_store = $refund_data_store;
$this->features_controller = $features_controller;
}
/**
@ -127,21 +133,35 @@ class CustomOrdersTableController {
* @return bool True if the feature is visible.
*/
public function is_feature_visible(): bool {
return $this->is_feature_visible;
return $this->features_controller->feature_is_enabled( 'custom_order_tables' );
}
/**
* Makes the feature visible, so that dedicated entries will be added to the debug tools page.
*
* This method shouldn't be used anymore, see the FeaturesController class.
*/
public function show_feature() {
$this->is_feature_visible = true;
$class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . __FUNCTION__;
wc_doing_it_wrong(
$class_and_method,
__( "${class_and_method}: The visibility of the custom orders table feature is now handled by the WooCommerce features engine. See the FeaturesController class, or go to WooCommerce - Settings - Advanced - Features.", 'woocommerce' ),
'7.0'
);
}
/**
* Hides the feature, so that no entries will be added to the debug tools page.
*
* This method shouldn't be used anymore, see the FeaturesController class.
*/
public function hide_feature() {
$this->is_feature_visible = false;
$class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . __FUNCTION__;
wc_doing_it_wrong(
$class_and_method,
__( "${class_and_method}: The visibility of the custom orders table feature is now handled by the WooCommerce features engine. See the FeaturesController class, or go to WooCommerce - Settings - Advanced - Features.", 'woocommerce' ),
'7.0'
);
}
/**

View File

@ -0,0 +1,29 @@
<?php
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Proxies\LegacyProxy;
/**
* Service provider for the features enabling/disabling/compatibility engine.
*/
class FeaturesServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
FeaturesController::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( FeaturesController::class )->addArgument( LegacyProxy::class );
}
}

View File

@ -15,6 +15,7 @@ use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStoreMeta;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
/**
@ -51,6 +52,7 @@ class OrdersDataStoreServiceProvider extends AbstractServiceProvider {
DataSynchronizer::class,
OrdersTableRefundDataStore::class,
BatchProcessingController::class,
FeaturesController::class,
)
);
if ( Constants::is_defined( 'WP_CLI' ) && WP_CLI ) {

View File

@ -0,0 +1,507 @@
<?php
/**
* FeaturesController class file
*/
namespace Automattic\WooCommerce\Internal\Features;
use Automattic\WooCommerce\Admin\Features\Analytics;
use Automattic\WooCommerce\Admin\Features\Navigation\Init;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\ArrayUtil;
defined( 'ABSPATH' ) || exit;
/**
* Class to define the WooCommerce features that can be enabled and disabled by admin users,
* provides also a mechanism for WooCommerce plugins to declare that they are compatible
* (or incompatible) with a given feature.
*/
class FeaturesController {
use AccessiblePrivateMethods;
public const FEATURE_ENABLED_CHANGED_ACTION = 'woocommerce_feature_enabled_changed';
/**
* The existing feature definitions.
*
* @var array[]
*/
private $features;
/**
* The registered compatibility info for WooCommerce plugins, with plugin names as keys.
*
* @var array
*/
private $compatibility_info_by_plugin;
/**
* The registered compatibility info for WooCommerce plugins, with feature ids as keys.
*
* @var array
*/
private $compatibility_info_by_feature;
/**
* The LegacyProxy instance to use.
*
* @var LegacyProxy
*/
private $proxy;
/**
* Creates a new instance of the class.
*/
public function __construct() {
$features = array(
'analytics' => array(
'name' => __( 'Analytics', 'woocommerce' ),
'description' => __( 'Enables WooCommerce Analytics', 'woocommerce' ),
'is_experimental' => false,
),
'new_navigation' => array(
'name' => __( 'Navigation', 'woocommerce' ),
'description' => __( 'Adds the new WooCommerce navigation experience to the dashboard', 'woocommerce' ),
'is_experimental' => false,
),
'custom_order_tables' => array(
'name' => __( 'Custom order tables', 'woocommerce' ),
'description' => __( 'Enable the custom orders tables feature (still in development)', 'woocommerce' ),
'is_experimental' => true,
),
);
$this->init_features( $features );
foreach ( array_keys( $this->features ) as $feature_id ) {
$this->compatibility_info_by_feature[ $feature_id ] = array(
'compatible' => array(),
'incompatible' => array(),
);
}
self::add_filter( 'updated_option', array( $this, 'process_updated_option' ), 999, 3 );
self::add_filter( 'added_option', array( $this, 'process_added_option' ), 999, 3 );
self::add_filter( 'woocommerce_get_sections_advanced', array( $this, 'add_features_section' ), 10, 1 );
self::add_filter( 'woocommerce_get_settings_advanced', array( $this, 'add_feature_settings' ), 10, 2 );
}
/**
* Initialize the class according to the existing features.
*
* @param array $features Information about the existing features.
*/
private function init_features( array $features ) {
$this->compatibility_info_by_plugin = array();
$this->compatibility_info_by_feature = array();
$this->features = $features;
foreach ( array_keys( $this->features ) as $feature_id ) {
$this->compatibility_info_by_feature[ $feature_id ] = array(
'compatible' => array(),
'incompatible' => array(),
);
}
}
/**
* Initialize the class instance.
*
* @internal
*
* @param LegacyProxy $proxy The instance of LegacyProxy to use.
*/
final public function init( LegacyProxy $proxy ) {
$this->proxy = $proxy;
}
/**
* Get all the existing WooCommerce features.
*
* Returns an associative array where keys are unique feature ids
* and values are arrays with these keys:
*
* - name (string)
* - description (string)
* - is_experimental (bool)
* - is_enabled (bool) (only if $include_enabled_info is passed as true)
*
* @param bool $include_experimental Include also experimental/work in progress features in the list.
* @param bool $include_enabled_info True to include the 'is_enabled' field in the returned features info.
* @returns array An array of information about existing features.
*/
public function get_features( bool $include_experimental = false, bool $include_enabled_info = false ): array {
$features = $this->features;
if ( ! $include_experimental ) {
$features = array_filter(
$features,
function( $feature ) {
return ! $feature['is_experimental'];
}
);
}
if ( $include_enabled_info ) {
foreach ( array_keys( $features ) as $feature_id ) {
$is_enabled = $this->feature_is_enabled( $feature_id );
$features[ $feature_id ]['is_enabled'] = $is_enabled;
}
}
return $features;
}
/**
* Check if a given feature is currently enabled.
*
* @param string $feature_id Unique feature id.
* @return bool True if the feature is enabled, false if not or if the feature doesn't exist.
*/
public function feature_is_enabled( string $feature_id ): bool {
return $this->feature_exists( $feature_id ) && 'yes' === get_option( $this->feature_enable_option_name( $feature_id ) );
}
/**
* Change the enabled/disabled status of a feature.
*
* @param string $feature_id Unique feature id.
* @param bool $enable True to enable the feature, false to disable it.
* @return bool True on success, false if feature doesn't exist or the new value is the same as the old value.
*/
public function change_feature_enable( string $feature_id, bool $enable ): bool {
if ( ! $this->feature_exists( $feature_id ) ) {
return false;
}
return update_option( $this->feature_enable_option_name( $feature_id ), $enable ? 'yes' : 'no' );
}
/**
* Declare (in)compatibility with a given feature for a given plugin.
*
* This method MUST be executed from inside a handler for the 'before_woocommerce_init' hook.
*
* The plugin name is expected to be in the form 'directory/file.php' and be one of the keys
* of the array returned by 'get_plugins', but this won't be checked. Plugins are expected to use
* FeaturesUtil::declare_compatibility instead, passing the full plugin file path instead of the plugin name.
*
* @param string $feature_id Unique feature id.
* @param string $plugin_name Plugin name, in the form 'directory/file.php'.
* @param bool $positive_compatibility True if the plugin declares being compatible with the feature, false if it declares being incompatible.
* @return bool True on success, false on error (feature doesn't exist or not inside the required hook).
* @throws \Exception A plugin attempted to declare itself as compatible and incompatible with a given feature at the same time.
*/
public function declare_compatibility( string $feature_id, string $plugin_name, bool $positive_compatibility = true ): bool {
if ( ! $this->proxy->call_function( 'doing_action', 'before_woocommerce_init' ) ) {
$class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . __FUNCTION__;
/* translators: 1: class::method 2: before_woocommerce_init */
$this->proxy->call_function( 'wc_doing_it_wrong', $class_and_method, sprintf( __( '%1$s should be called inside the %2$s action.', 'woocommerce' ), $class_and_method, 'before_woocommerce_init' ), '7.0' );
return false;
}
if ( ! $this->feature_exists( $feature_id ) ) {
return false;
}
// Register compatibility by plugin.
ArrayUtil::ensure_key_is_array( $this->compatibility_info_by_plugin, $plugin_name );
$key = $positive_compatibility ? 'compatible' : 'incompatible';
$opposite_key = $positive_compatibility ? 'incompatible' : 'compatible';
ArrayUtil::ensure_key_is_array( $this->compatibility_info_by_plugin[ $plugin_name ], $key );
ArrayUtil::ensure_key_is_array( $this->compatibility_info_by_plugin[ $plugin_name ], $opposite_key );
if ( in_array( $feature_id, $this->compatibility_info_by_plugin[ $plugin_name ][ $opposite_key ], true ) ) {
throw new \Exception( "Plugin $plugin_name is trying to declare itself as $key with the '$feature_id' feature, but it already declared itself as $opposite_key" );
}
if ( ! in_array( $feature_id, $this->compatibility_info_by_plugin[ $plugin_name ][ $key ], true ) ) {
$this->compatibility_info_by_plugin[ $plugin_name ][ $key ][] = $feature_id;
}
// Register compatibility by feature.
$key = $positive_compatibility ? 'compatible' : 'incompatible';
if ( ! in_array( $plugin_name, $this->compatibility_info_by_feature[ $feature_id ][ $key ], true ) ) {
$this->compatibility_info_by_feature[ $feature_id ][ $key ][] = $plugin_name;
}
return true;
}
/**
* Check whether a feature exists with a given id.
*
* @param string $feature_id The feature id to check.
* @return bool True if the feature exists.
*/
private function feature_exists( string $feature_id ): bool {
return isset( $this->features[ $feature_id ] );
}
/**
* Get the ids of the features that a certain plugin has declared compatibility for.
*
* This method can't be called before the 'woocommerce_init' hook is fired.
*
* @param string $plugin_name Plugin name, in the form 'directory/file.php'.
* @return array An array having a 'compatible' and an 'incompatible' key, each holding an array of feature ids.
*/
public function get_compatible_features_for_plugin( string $plugin_name ) : array {
$this->verify_did_woocommerce_init( __FUNCTION__ );
if ( ! isset( $this->compatibility_info_by_plugin[ $plugin_name ] ) ) {
return array(
'compatible' => array(),
'incompatible' => array(),
);
}
return $this->compatibility_info_by_plugin[ $plugin_name ];
}
/**
* Get the names of the plugins that have been declared compatible or incompatible with a given feature.
*
* @param string $feature_id Feature id.
* @return array An array having a 'compatible' and an 'incompatible' key, each holding an array of plugin names.
*/
public function get_compatible_plugins_for_feature( string $feature_id ) : array {
$this->verify_did_woocommerce_init( __FUNCTION__ );
if ( ! $this->feature_exists( $feature_id ) ) {
return array(
'compatible' => array(),
'incompatible' => array(),
);
}
return $this->compatibility_info_by_feature[ $feature_id ];
}
/**
* Check if the 'woocommerce_init' has run or is running, do a 'wc_doing_it_wrong' if not.
*
* @param string $function Name of the invoking method.
*/
private function verify_did_woocommerce_init( string $function ) {
if ( ! $this->proxy->call_function( 'did_action', 'woocommerce_init' ) &&
! $this->proxy->call_function( 'doing_action', 'woocommerce_init' ) ) {
$class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . $function;
/* translators: 1: class::method 2: plugins_loaded */
$this->proxy->call_function( 'wc_doing_it_wrong', $class_and_method, sprintf( __( '%1$s should not be called before the %2$s action.', 'woocommerce' ), $class_and_method, 'woocommerce_init' ), '7.0' );
}
}
/**
* Get the name of the option that enables/disables a given feature.
* Note that it doesn't check if the feature actually exists.
*
* @param string $feature_id The id of the feature.
* @return string The option that enables or disables the feature.
*/
public function feature_enable_option_name( string $feature_id ): string {
if ( 'analytics' === $feature_id ) {
return Analytics::TOGGLE_OPTION_NAME;
} elseif ( 'new_navigation' === $feature_id ) {
return Init::TOGGLE_OPTION_NAME;
}
return "woocommerce_feature_${feature_id}_enabled";
}
/**
* Handler for the 'added_option' hook.
*
* It fires FEATURE_ENABLED_CHANGED_ACTION when a feature is enabled or disabled.
*
* @param string $option The option that has been created.
* @param mixed $value The value of the option.
*/
private function process_added_option( string $option, $value ) {
$this->process_updated_option( $option, false, $value );
}
/**
* Handler for the 'updated_option' hook.
*
* It fires FEATURE_ENABLED_CHANGED_ACTION when a feature is enabled or disabled.
*
* @param string $option The option that has been modified.
* @param mixed $old_value The old value of the option.
* @param mixed $value The new value of the option.
*/
private function process_updated_option( string $option, $old_value, $value ) {
$matches = array();
$success = preg_match( '/^woocommerce_feature_([a-zA-Z0-9_]+)_enabled$/', $option, $matches );
if ( ! $success ) {
return;
}
if ( $value === $old_value ) {
return;
}
$feature_id = $matches[1];
/**
* Action triggered when a feature is enabled or disabled (the value of the corresponding setting option is changed).
*
* @param string $feature_id The id of the feature.
* @param bool $enabled True if the feature has been enabled, false if it has been disabled.
*
* @since 7.0.0
*/
do_action( self::FEATURE_ENABLED_CHANGED_ACTION, $feature_id, 'yes' === $value );
}
/**
* Handler for the 'woocommerce_get_sections_advanced' hook,
* it adds the "Features" section to the advanced settings page.
*
* @param array $sections The original sections array.
* @return array The updated sections array.
*/
private function add_features_section( $sections ) {
if ( ! isset( $sections['features'] ) ) {
$sections['features'] = __( 'Features', 'woocommerce' );
}
return $sections;
}
/**
* Handler for the 'woocommerce_get_settings_advanced' hook,
* it adds the settings UI for all the existing features.
*
* Note that the settings added via the 'woocommerce_settings_features' hook will be
* displayed in the non-experimental features section.
*
* @param array $settings The existing settings for the corresponding settings section.
* @param string $current_section The section to get the settings for.
* @return array The updated settings array.
*/
private function add_feature_settings( $settings, $current_section ): array {
if ( 'features' !== $current_section ) {
return $settings;
}
// phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment
/**
* Filter allowing WooCommerce Admin to be disabled.
*
* @param bool $disabled False.
*/
$admin_features_disabled = apply_filters( 'woocommerce_admin_disabled', false );
// phpcs:enable WooCommerce.Commenting.CommentHooks.MissingSinceComment
$feature_settings =
array(
array(
'title' => __( 'Features', 'woocommerce' ),
'type' => 'title',
'desc' => __( 'Start using new features that are being progressively rolled out to improve the store management experience.', 'woocommerce' ),
'id' => 'features_options',
),
);
$features = $this->get_features( true );
$feature_ids = array_keys( $features );
$experimental_feature_ids = array_filter(
$feature_ids,
function( $feature_id ) use ( $features ) {
return $features[ $feature_id ]['is_experimental'];
}
);
$mature_feature_ids = array_diff( $feature_ids, $experimental_feature_ids );
$feature_ids = array_merge( $mature_feature_ids, array( 'mature_features_end' ), $experimental_feature_ids );
foreach ( $feature_ids as $id ) {
if ( 'mature_features_end' === $id ) {
// phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment
/**
* Filter allowing to add additional settings to the WooCommerce Advanced - Features settings page.
*
* @param bool $disabled False.
*/
$additional_features = apply_filters( 'woocommerce_settings_features', $features );
// phpcs:enable WooCommerce.Commenting.CommentHooks.MissingSinceComment
$feature_settings = array_merge( $feature_settings, $additional_features );
if ( ! empty( $experimental_feature_ids ) ) {
$feature_settings[] = array(
'type' => 'sectionend',
'id' => 'features_options',
);
$feature_settings[] = array(
'title' => __( 'Experimental features', 'woocommerce' ),
'type' => 'title',
'desc' => __( 'These features are either experimental or incomplete, enable them at your own risk!', 'woocommerce' ),
'id' => 'experimental_features_options',
);
}
continue;
}
$feature_settings[] = $this->get_setting_for_feature( $id, $features[ $id ], $admin_features_disabled );
}
$feature_settings[] = array(
'type' => 'sectionend',
'id' => empty( $experimental_feature_ids ) ? 'features_options' : 'experimental_features_options',
);
return $feature_settings;
}
/**
* Get the parameters to display the setting enable/disable UI for a given feature.
*
* @param string $feature_id The feature id.
* @param array $feature The feature parameters, as returned by get_features.
* @param bool $admin_features_disabled True if admin features have been disabled via 'woocommerce_admin_disabled' filter.
* @return array The parameters to add to the settings array.
*/
private function get_setting_for_feature( string $feature_id, array $feature, bool $admin_features_disabled ): array {
$description = $feature['description'];
$disabled = false;
$desc_tip = '';
if ( ( 'analytics' === $feature_id || 'new_navigation' === $feature_id ) && $admin_features_disabled ) {
$disabled = true;
$desc_tip = __( 'WooCommerce Admin has been disabled', 'woocommerce' );
} elseif ( 'new_navigation' === $feature_id ) {
$needs_update = version_compare( get_bloginfo( 'version' ), '5.6', '<' );
if ( $needs_update && current_user_can( 'update_core' ) && current_user_can( 'update_php' ) ) {
$update_text = sprintf(
// translators: 1: line break tag, 2: open link to WordPress update link, 3: close link tag.
__( '%1$s %2$sUpdate WordPress to enable the new navigation%3$s', 'woocommerce' ),
'<br/>',
'<a href="' . self_admin_url( 'update-core.php' ) . '" target="_blank">',
'</a>'
);
$description .= $update_text;
$disabled = true;
}
}
return array(
'title' => $feature['name'],
'desc' => $description,
'type' => 'checkbox',
'id' => $this->feature_enable_option_name( $feature_id ),
'class' => $disabled ? 'disabled' : '',
'desc_tip' => $desc_tip,
);
}
}

View File

@ -184,5 +184,28 @@ class ArrayUtil {
$array[] = $value;
return true;
}
/**
* Ensure that an associative array has a given key, and if not, set the key to an empty array.
*
* @param array $array The array to check.
* @param string $key The key to check.
* @param bool $throw_if_existing_is_not_array If true, an exception will be thrown if the key already exists in the array but the value is not an array.
* @return bool True if the key has been added to the array, false if not (the key already existed).
* @throws \Exception The key already exists in the array but the value is not an array.
*/
public static function ensure_key_is_array( array &$array, string $key, bool $throw_if_existing_is_not_array = false ): bool {
if ( ! isset( $array[ $key ] ) ) {
$array[ $key ] = array();
return true;
}
if ( $throw_if_existing_is_not_array && ! is_array( $array[ $key ] ) ) {
$type = is_object( $array[ $key ] ) ? get_class( $array[ $key ] ) : gettype( $array[ $key ] );
throw new \Exception( "Array key exists but it's not an array, it's a {$type}" );
}
return false;
}
}

View File

@ -0,0 +1,88 @@
<?php
/**
* FeaturesUtil class file.
*/
namespace Automattic\WooCommerce\Utilities;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
/**
* Class with methods that allow to retrieve information about the existing WooCommerce features,
* also has methods for WooCommerce plugins to declare (in)compatibility with the features.
*/
class FeaturesUtil {
/**
* Get all the existing WooCommerce features.
*
* Returns an associative array where keys are unique feature ids
* and values are arrays with these keys:
*
* - name
* - description
* - is_experimental
* - is_enabled (if $include_enabled_info is passed as true)
*
* @param bool $include_experimental Include also experimental/work in progress features in the list.
* @param bool $include_enabled_info True to include the 'is_enabled' field in the returned features info.
* @returns array An array of information about existing features.
*/
public static function get_features( bool $include_experimental = false, bool $include_enabled_info = false ): array {
return wc_get_container()->get( FeaturesController::class )->get_features( $include_experimental, $include_enabled_info );
}
/**
* Check if a given feature is currently enabled.
*
* @param string $feature_id Unique feature id.
* @return bool True if the feature is enabled, false if not or if the feature doesn't exist.
*/
public static function feature_is_enabled( string $feature_id ): bool {
return wc_get_container()->get( FeaturesController::class )->feature_is_enabled( $feature_id );
}
/**
* Declare (in)compatibility with a given feature for a given plugin.
*
* This method MUST be executed from inside a handler for the 'before_woocommerce_init' hook and
* SHOULD be executed from the main plugin file passing __FILE__ for the $plugin_file argument.
*
* @param string $feature_id Unique feature id.
* @param string $plugin_file The full plugin file path.
* @param bool $positive_compatibility True if the plugin declares being compatible with the feature, false if it declares being incompatible.
* @return bool True on success, false on error (feature doesn't exist or not inside the required hook).
* @throws \Exception A plugin attempted to declare itself as compatible and incompatible with a given feature at the same time.
*/
public static function declare_compatibility( string $feature_id, string $plugin_file, bool $positive_compatibility = true ): bool {
$plugin_name = StringUtil::plugin_name_from_plugin_file( $plugin_file );
if ( ! isset( get_plugins()[ $plugin_name ] ) ) {
throw new \Exception( "FeaturesUtil::declare_compatibility: ${plugin_name} is not a known WordPress plugin." );
}
return wc_get_container()->get( FeaturesController::class )->declare_compatibility( $feature_id, $plugin_name, $positive_compatibility );
}
/**
* Get the ids of the features that a certain plugin has declared compatibility for.
*
* This method can't be called before the 'woocommerce_init' hook is fired.
*
* @param string $plugin_name Plugin name, in the form 'directory/file.php'.
* @return array An array having a 'compatible' and an 'incompatible' key, each holding an array of plugin ids.
*/
public static function get_compatible_features_for_plugin( string $plugin_name ): array {
return wc_get_container()->get( FeaturesController::class )->get_compatible_features_for_plugin( $plugin_name );
}
/**
* Get the names of the plugins that have been declared compatible or incompatible with a given feature.
*
* @param string $feature_id Feature id.
* @return array An array having a 'compatible' and an 'incompatible' key, each holding an array of plugin names.
*/
public static function get_compatible_plugins_for_feature( string $feature_id ): array {
return wc_get_container()->get( FeaturesController::class )->get_compatible_plugins_for_feature( $feature_id );
}
}

View File

@ -73,4 +73,14 @@ final class StringUtil {
return false !== stripos( $string, $contained );
}
}
/**
* Get the name of a plugin in the form 'directory/file.php', as in the keys of the array returned by 'get_plugins'.
*
* @param string $plugin_file_path The path of the main plugin file (can be passed as __FILE__ from the plugin itself).
* @return string The name of the plugin in the form 'directory/file.php'.
*/
public static function plugin_name_from_plugin_file( string $plugin_file_path ): string {
return basename( dirname( $plugin_file_path ) ) . DIRECTORY_SEPARATOR . basename( $plugin_file_path );
}
}

View File

@ -13,6 +13,7 @@ defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use WC_Mock_Payment_Gateway;
use WC_Order;
use WC_Product;
@ -140,9 +141,9 @@ class OrderHelper {
* Helper method to drop custom tables if present.
*/
public static function delete_order_custom_tables() {
$order_table_controller = wc_get_container()
->get( CustomOrdersTableController::class );
$order_table_controller->show_feature();
$features_controller = wc_get_container()->get( Featurescontroller::class );
$features_controller->change_feature_enable( 'custom_order_tables', true );
$synchronizer = wc_get_container()
->get( DataSynchronizer::class );
if ( $synchronizer->check_orders_table_exists() ) {
@ -154,11 +155,10 @@ class OrderHelper {
* Helper method to create custom tables if not present.
*/
public static function create_order_custom_table_if_not_exist() {
$order_table_controller = wc_get_container()
->get( CustomOrdersTableController::class );
$order_table_controller->show_feature();
$synchronizer = wc_get_container()
->get( DataSynchronizer::class );
$features_controller = wc_get_container()->get( Featurescontroller::class );
$features_controller->change_feature_enable( 'custom_order_tables', true );
$synchronizer = wc_get_container()->get( DataSynchronizer::class );
if ( ! $synchronizer->check_orders_table_exists() ) {
$synchronizer->create_database_tables();
}

View File

@ -2,6 +2,7 @@
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
/**
@ -14,11 +15,6 @@ class DataSynchronizerTests extends WC_Unit_Test_Case {
*/
private $sut;
/**
* @var CustomOrdersTableController
*/
private $cot_controller;
/**
* Initializes system under test.
*/
@ -29,9 +25,9 @@ class DataSynchronizerTests extends WC_Unit_Test_Case {
remove_filter( 'query', array( $this, '_drop_temporary_tables' ) );
OrderHelper::delete_order_custom_tables(); // We need this since non-temporary tables won't drop automatically.
OrderHelper::create_order_custom_table_if_not_exist();
$this->sut = wc_get_container()->get( DataSynchronizer::class );
$this->cot_controller = wc_get_container()->get( CustomOrdersTableController::class );
$this->cot_controller->show_feature();
$this->sut = wc_get_container()->get( DataSynchronizer::class );
$features_controller = wc_get_container()->get( Featurescontroller::class );
$features_controller->change_feature_enable( 'custom_order_tables', true );
}
/**

View File

@ -2,6 +2,7 @@
use Automattic\WooCommerce\Database\Migrations\CustomOrderTable\PostsToOrdersMigrationController;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
if ( ! class_exists( 'WC_REST_Orders_Controller_Tests' ) ) {
@ -79,12 +80,14 @@ class OrdersTableDataStoreRestOrdersControllerTests extends \WC_REST_Orders_Cont
* @return void
*/
private function toggle_cot( bool $enabled ): void {
$controller = wc_get_container()->get( CustomOrdersTableController::class )->show_feature();
$features_controller = wc_get_container()->get( Featurescontroller::class );
$features_controller->change_feature_enable( 'custom_order_tables', true );
update_option( CustomOrdersTableController::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, wc_bool_to_string( $enabled ) );
// Confirm things are really correct.
$wc_data_store = WC_Data_Store::load( 'order' );
assert( $enabled === is_a( $wc_data_store->get_current_class_name(), OrdersTableDataStore::class, true ) );
assert( is_a( $wc_data_store->get_current_class_name(), OrdersTableDataStore::class, true ) === $enabled );
}
}

View File

@ -0,0 +1,557 @@
<?php
/**
* FeaturesControllerTest class file.
*/
namespace Automattic\WooCommerce\Tests\Internal\Features;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Admin\Features\Analytics;
use Automattic\WooCommerce\Admin\Features\Navigation\Init;
/**
* Tests for the FeaturesController class.
*/
class FeaturesControllerTest extends \WC_Unit_Test_Case {
/**
* The system under test.
*
* @var FeaturesController
*/
private $sut;
/**
* Runs before each test.
*/
public function setUp(): void {
$this->reset_container_resolutions();
$this->reset_legacy_proxy_mocks();
$features = array(
'mature1' => array(
'name' => 'Mature feature 1',
'description' => 'The mature feature number 1',
'is_experimental' => false,
),
'mature2' => array(
'name' => 'Mature feature 2',
'description' => 'The mature feature number 1',
'is_experimental' => false,
),
'experimental1' => array(
'name' => 'Experimental feature 1',
'description' => 'The experimental feature number 1',
'is_experimental' => true,
),
'experimental2' => array(
'name' => 'Experimental feature 2',
'description' => 'The experimental feature number 2',
'is_experimental' => true,
),
);
$this->sut = $this->get_instance_of( FeaturesController::class );
$init_features_method = new \ReflectionMethod( $this->sut, 'init_features' );
$init_features_method->setAccessible( true );
$init_features_method->invoke( $this->sut, $features );
delete_option( 'woocommerce_feature_mature1_enabled' );
delete_option( 'woocommerce_feature_mature2_enabled' );
delete_option( 'woocommerce_feature_experimental1_enabled' );
delete_option( 'woocommerce_feature_experimental2_enabled' );
remove_all_filters( FeaturesController::FEATURE_ENABLED_CHANGED_ACTION );
}
/**
* @testdox 'get_features' returns existing non-experimental features without enabling information if requested to do so.
*/
public function test_get_features_not_including_experimental_not_including_values() {
$actual = $this->sut->get_features( false, false );
$expected = array(
'mature1' => array(
'name' => 'Mature feature 1',
'description' => 'The mature feature number 1',
'is_experimental' => false,
),
'mature2' => array(
'name' => 'Mature feature 2',
'description' => 'The mature feature number 1',
'is_experimental' => false,
),
);
$this->assertEquals( $expected, $actual );
}
/**
* @testdox 'get_features' returns all existing features without enabling information if requested to do so.
*/
public function test_get_features_including_experimental_not_including_values() {
$actual = $this->sut->get_features( true, false );
$expected = array(
'mature1' => array(
'name' => 'Mature feature 1',
'description' => 'The mature feature number 1',
'is_experimental' => false,
),
'mature2' => array(
'name' => 'Mature feature 2',
'description' => 'The mature feature number 1',
'is_experimental' => false,
),
'experimental1' => array(
'name' => 'Experimental feature 1',
'description' => 'The experimental feature number 1',
'is_experimental' => true,
),
'experimental2' => array(
'name' => 'Experimental feature 2',
'description' => 'The experimental feature number 2',
'is_experimental' => true,
),
);
$this->assertEquals( $expected, $actual );
}
/**
* @testdox 'get_features' returns all existing features with enabling information if requested to do so.
*/
public function test_get_features_including_experimental_and_values() {
update_option( 'woocommerce_feature_mature1_enabled', 'yes' );
update_option( 'woocommerce_feature_mature2_enabled', 'no' );
update_option( 'woocommerce_feature_experimental1_enabled', 'yes' );
// No option for experimental2.
$actual = $this->sut->get_features( true, true );
$expected = array(
'mature1' => array(
'name' => 'Mature feature 1',
'description' => 'The mature feature number 1',
'is_experimental' => false,
'is_enabled' => true,
),
'mature2' => array(
'name' => 'Mature feature 2',
'description' => 'The mature feature number 1',
'is_experimental' => false,
'is_enabled' => false,
),
'experimental1' => array(
'name' => 'Experimental feature 1',
'description' => 'The experimental feature number 1',
'is_experimental' => true,
'is_enabled' => true,
),
'experimental2' => array(
'name' => 'Experimental feature 2',
'description' => 'The experimental feature number 2',
'is_experimental' => true,
'is_enabled' => false,
),
);
$this->assertEquals( $expected, $actual );
}
/**
* @testdox 'feature_is_enabled' returns whether a feature is enabled, and returns false for invalid feature ids.
*
* @testWith ["mature1", true]
* ["mature2", false]
* ["experimental1", false]
* ["NOT_EXISTING", false]
*
* @param string $feature_id Feature id to check.
* @param bool $expected_to_be_enabled Expected result from the method.
*/
public function test_feature_is_enabled( $feature_id, $expected_to_be_enabled ) {
update_option( 'woocommerce_feature_mature1_enabled', 'yes' );
update_option( 'woocommerce_feature_mature2_enabled', 'no' );
// No option for experimental1.
$this->assertEquals( $expected_to_be_enabled, $this->sut->feature_is_enabled( $feature_id ) );
}
/**
* @testdox 'change_feature_enable' does nothing and returns false for an invalid feature id.
*/
public function test_change_feature_enable_for_non_existing_feature() {
$result = $this->sut->change_feature_enable( 'NON_EXISTING', true );
$this->assertFalse( $result );
}
/**
* @testdox 'change_feature_enabled' works as expected with and without previous values for the feature enable options.
*
* @testWith [null, false, true, false, false]
* [null, true, true, false, true]
* ["no", false, false, false, false]
* ["no", true, true, false, true]
* ["yes", false, true, true, false]
* ["yes", true, false, true, true]
*
* @param string|null $previous_value The previous value of the feature enable option.
* @param bool $enable Whether the feature will be enabled or disabled.
* @param bool $expected_result Expected value to be returned by 'change_feature_enable'.
* @param bool $expected_previous_enabled Expected value to be returned by 'feature_is_enabled' before the feature status is changed.
* @param bool $expected_new_enabled Expected value to be returned by 'feature_is_enabled' after the feature status is changed.
*/
public function test_change_feature_enable( $previous_value, $enable, $expected_result, $expected_previous_enabled, $expected_new_enabled ) {
if ( $previous_value ) {
update_option( 'woocommerce_feature_mature1_enabled', $previous_value );
}
$result = $this->sut->feature_is_enabled( 'mature1' );
$this->assertEquals( $expected_previous_enabled, $result );
$result = $this->sut->change_feature_enable( 'mature1', $enable );
$this->assertEquals( $result, $expected_result );
$result = $this->sut->feature_is_enabled( 'mature1' );
$this->assertEquals( $expected_new_enabled, $result );
}
/**
* @testdox 'declare_compatibility' fails when invoked from outside the 'before_woocommerce_init' action.
*/
public function test_declare_compatibility_outside_before_woocommerce_init_hook() {
$function = null;
$message = null;
$version = null;
$this->register_legacy_proxy_function_mocks(
array(
'wc_doing_it_wrong' => function( $f, $m, $v ) use ( &$function, &$message, &$version ) {
$function = $f;
$message = $m;
$version = $v;
},
)
);
$result = $this->sut->declare_compatibility( 'mature1', 'the_plugin' );
$this->assertFalse( $result );
$this->assertEquals( 'FeaturesController::declare_compatibility', $function );
$this->assertEquals( 'FeaturesController::declare_compatibility should be called inside the before_woocommerce_init action.', $message );
$this->assertEquals( '7.0', $version );
}
/**
* @testdox 'declare_compatibility' returns false for invalid feature ids.
*/
public function test_declare_compatibility_for_non_existing_feature() {
$this->simulate_inside_before_woocommerce_init_hook();
$result = $this->sut->declare_compatibility( 'NON_EXISTING', 'the_plugin' );
$this->assertFalse( $result );
}
/**
* @testdox 'declare_compatibility' registers internally the proper per-plugin information.
*/
public function test_declare_compatibility_by_plugin() {
$this->simulate_inside_before_woocommerce_init_hook();
$result = $this->sut->declare_compatibility( 'mature1', 'the_plugin' );
$this->assertTrue( $result );
$result = $this->sut->declare_compatibility( 'experimental1', 'the_plugin' );
$this->assertTrue( $result );
$result = $this->sut->declare_compatibility( 'experimental2', 'the_plugin', false );
$this->assertTrue( $result );
// Duplicate declaration is ok:.
$result = $this->sut->declare_compatibility( 'experimental2', 'the_plugin', false );
$this->assertTrue( $result );
$compatibility_info_prop = new \ReflectionProperty( $this->sut, 'compatibility_info_by_plugin' );
$compatibility_info_prop->setAccessible( true );
$compatibility_info = $compatibility_info_prop->getValue( $this->sut );
$expected = array(
'the_plugin' => array(
'compatible' => array(
'mature1',
'experimental1',
),
'incompatible' => array(
'experimental2',
),
),
);
$this->assertEquals( $expected, $compatibility_info );
}
/**
* @testdox 'declare_compatibility' registers internally the proper per-feature information.
*/
public function test_declare_compatibility_by_feature() {
$this->simulate_inside_before_woocommerce_init_hook();
$result = $this->sut->declare_compatibility( 'mature1', 'the_plugin_1' );
$this->assertTrue( $result );
$result = $this->sut->declare_compatibility( 'mature1', 'the_plugin_2' );
$this->assertTrue( $result );
$result = $this->sut->declare_compatibility( 'mature1', 'the_plugin_3', false );
$this->assertTrue( $result );
$result = $this->sut->declare_compatibility( 'experimental1', 'the_plugin_1', false );
$this->assertTrue( $result );
$result = $this->sut->declare_compatibility( 'experimental2', 'the_plugin_2', true );
$this->assertTrue( $result );
$compatibility_info_prop = new \ReflectionProperty( $this->sut, 'compatibility_info_by_feature' );
$compatibility_info_prop->setAccessible( true );
$compatibility_info = $compatibility_info_prop->getValue( $this->sut );
$expected = array(
'mature1' => array(
'compatible' => array(
'the_plugin_1',
'the_plugin_2',
),
'incompatible' => array(
'the_plugin_3',
),
),
'mature2' => array(
'compatible' => array(),
'incompatible' => array(),
),
'experimental1' => array(
'compatible' => array(),
'incompatible' => array(
'the_plugin_1',
),
),
'experimental2' => array(
'compatible' => array(
'the_plugin_2',
),
'incompatible' => array(),
),
);
$this->assertEquals( $expected, $compatibility_info );
}
/**
* @testdox 'declare_compatibility' throws when a plugin declares itself as both compatible and incompatible with a given feature.
*/
public function test_declare_compatibility_and_incompatibility_for_the_same_plugin() {
$this->simulate_inside_before_woocommerce_init_hook();
$this->ExpectException( \Exception::class );
$this->ExpectExceptionMessage( "Plugin the_plugin is trying to declare itself as incompatible with the 'mature1' feature, but it already declared itself as compatible" );
$this->sut->declare_compatibility( 'mature1', 'the_plugin', true );
$this->sut->declare_compatibility( 'mature1', 'the_plugin', false );
}
/**
* @testdox 'get_compatible_features_for_plugin' fails when invoked before the 'woocommerce_init' hook.
*/
public function test_get_compatible_features_for_plugin_before_woocommerce_init_hook() {
$function = null;
$message = null;
$version = null;
$this->register_legacy_proxy_function_mocks(
array(
'did_action' => function( $action_name ) {
return 'woocommerce_init' === $action_name ? false : did_action( $action_name );
},
'wc_doing_it_wrong' => function( $f, $m, $v ) use ( &$function, &$message, &$version ) {
$function = $f;
$message = $m;
$version = $v;
},
)
);
$this->sut->get_compatible_features_for_plugin( 'the_plugin' );
$this->assertEquals( 'FeaturesController::get_compatible_features_for_plugin', $function );
$this->assertEquals( 'FeaturesController::get_compatible_features_for_plugin should not be called before the woocommerce_init action.', $message );
$this->assertEquals( '7.0', $version );
}
/**
* @testdox 'get_compatible_features_for_plugin' returns empty information for a plugin that has not declared compatibility with any feature.
*/
public function test_get_compatible_features_for_unregistered_plugin() {
$this->simulate_after_woocommerce_init_hook();
$result = $this->sut->get_compatible_features_for_plugin( 'the_plugin' );
$expected = array(
'compatible' => array(),
'incompatible' => array(),
);
$this->assertEquals( $expected, $result );
}
/**
* @testdox 'get_compatible_features_for_plugin' returns proper information for a plugin that has declared compatibility with the passed feature.
*/
public function test_get_compatible_features_for_registered_plugin() {
$this->simulate_inside_before_woocommerce_init_hook();
$this->sut->declare_compatibility( 'mature1', 'the_plugin', true );
$this->sut->declare_compatibility( 'mature2', 'the_plugin', true );
$this->sut->declare_compatibility( 'experimental1', 'the_plugin', false );
$this->sut->declare_compatibility( 'experimental2', 'the_plugin', false );
$this->reset_legacy_proxy_mocks();
$this->simulate_after_woocommerce_init_hook();
$result = $this->sut->get_compatible_features_for_plugin( 'the_plugin' );
$expected = array(
'compatible' => array( 'mature1', 'mature2' ),
'incompatible' => array( 'experimental1', 'experimental2' ),
);
$this->assertEquals( $expected, $result );
}
/**
* @testdox 'get_compatible_plugins_for_feature' fails when invoked before the 'woocommerce_init' hook.
*/
public function test_get_compatible_plugins_for_feature_before_woocommerce_init_hook() {
$function = null;
$message = null;
$version = null;
$this->register_legacy_proxy_function_mocks(
array(
'did_action' => function( $action_name ) {
return 'woocommerce_init' === $action_name ? false : did_action( $action_name );
},
'wc_doing_it_wrong' => function( $f, $m, $v ) use ( &$function, &$message, &$version ) {
$function = $f;
$message = $m;
$version = $v;
},
)
);
$this->sut->get_compatible_plugins_for_feature( 'mature1' );
$this->assertEquals( 'FeaturesController::get_compatible_plugins_for_feature', $function );
$this->assertEquals( 'FeaturesController::get_compatible_plugins_for_feature should not be called before the woocommerce_init action.', $message );
$this->assertEquals( '7.0', $version );
}
/**
* @testdox 'get_compatible_plugins_for_feature' returns empty information for invalid feature ids.
*/
public function test_get_compatible_plugins_for_non_existing_feature() {
$this->simulate_after_woocommerce_init_hook();
$result = $this->sut->get_compatible_plugins_for_feature( 'NON_EXISTING' );
$expected = array(
'compatible' => array(),
'incompatible' => array(),
);
$this->assertEquals( $expected, $result );
}
/**
* @testdox 'get_compatible_plugins_for_feature' returns empty information for features for which no compatibility has been declared.
*/
public function test_get_compatible_plugins_for_existing_feature_without_compatibility_declarations() {
$this->simulate_after_woocommerce_init_hook();
$result = $this->sut->get_compatible_plugins_for_feature( 'mature1' );
$expected = array(
'compatible' => array(),
'incompatible' => array(),
);
$this->assertEquals( $expected, $result );
}
/**
* @testdox 'get_compatible_plugins_for_feature' returns proper information for a feature for which compatibility has been declared.
*/
public function test_get_compatible_plugins_for_feature() {
$this->simulate_inside_before_woocommerce_init_hook();
$this->sut->declare_compatibility( 'mature1', 'the_plugin_1', true );
$this->sut->declare_compatibility( 'mature1', 'the_plugin_2', true );
$this->sut->declare_compatibility( 'mature1', 'the_plugin_3', false );
$this->reset_legacy_proxy_mocks();
$this->simulate_after_woocommerce_init_hook();
$result = $this->sut->get_compatible_plugins_for_feature( 'mature1' );
$expected = array(
'compatible' => array( 'the_plugin_1', 'the_plugin_2' ),
'incompatible' => array( 'the_plugin_3' ),
);
$this->assertEquals( $expected, $result );
}
/**
* @testdox The action defined by FEATURE_ENABLED_CHANGED_ACTION is fired when the enable status of a feature changes.
*
* @testWith [true]
* [false]
*
* @param bool $do_enable Whether to enable or disable the feature.
*/
public function test_feature_enable_changed_hook( $do_enable ) {
$feature_id = null;
$enabled = null;
add_action(
FeaturesController::FEATURE_ENABLED_CHANGED_ACTION,
function( $f, $e ) use ( &$feature_id, &$enabled ) {
$feature_id = $f;
$enabled = $e;
},
10,
2
);
$this->sut->change_feature_enable( 'mature1', $do_enable );
$this->assertEquals( 'mature1', $feature_id );
$this->assertEquals( $do_enable, $enabled );
}
/**
* Simulates that the code is running inside the 'before_woocommerce_init' action.
*/
private function simulate_inside_before_woocommerce_init_hook() {
$this->register_legacy_proxy_function_mocks(
array(
'doing_action' => function( $action_name ) {
return 'before_woocommerce_init' === $action_name || doing_action( $action_name );
},
)
);
}
/**
* Simulates that the code is running after the 'woocommerce_init' action has been fired.
*/
private function simulate_after_woocommerce_init_hook() {
$this->register_legacy_proxy_function_mocks(
array(
'did_action' => function( $action_name ) {
return 'woocommerce_init' === $action_name || did_action( $action_name );
},
)
);
}
}

View File

@ -275,4 +275,79 @@ class ArrayUtilTest extends \WC_Unit_Test_Case {
$this->assertTrue( $result );
$this->assertEquals( array( 1, 2, 3, 4 ), $array );
}
/**
* @testdox `ensure_key_is_array` adds an empty array under the given key if they key doesn't exist already in the array.
*/
public function test_ensure_key_is_array_when_key_does_not_exist() {
$array = array( 'foo' => 1 );
$result = ArrayUtil::ensure_key_is_array( $array, 'bar' );
$this->assertTrue( $result );
$this->assertEquals(
array(
'foo' => 1,
'bar' => array(),
),
$array
);
}
/**
* @testdox `ensure_key_is_array` does nothing if the key already exists in the array and $throw_if_existing_is_not_array is false.
*
* @testWith [[]]
* [1]
* [true]
* ["Foo"]
*
* @param mixed $value The already existing value.
*/
public function test_ensure_key_is_array_when_key_exist_and_not_throwing( $value ) {
$array = array(
'foo' => 1,
'bar' => $value,
);
$result = ArrayUtil::ensure_key_is_array( $array, 'bar' );
$this->assertFalse( $result );
$this->assertEquals(
array(
'foo' => 1,
'bar' => $value,
),
$array
);
}
/**
* @testdox `ensure_key_is_array` does nothing if the key already exists in the array, the value is itself an array, and $throw_if_existing_is_not_array is true.
*/
public function test_ensure_key_is_array_when_key_is_array_and_throwing() {
$array = array(
'foo' => 1,
'bar' => array(),
);
$result = ArrayUtil::ensure_key_is_array( $array, 'bar', true );
$this->assertFalse( $result );
$this->assertEquals(
array(
'foo' => 1,
'bar' => array(),
),
$array
);
}
/**
* @testdox `ensure_key_is_array` throws if the key already exists in the array, the value is not an array, and $throw_if_existing_is_not_array is true.
*/
public function test_ensure_key_is_array_when_key_is_not_array_and_throwing() {
$this->expectException( \Exception::class );
$this->expectExceptionMessage( "Array key exists but it's not an array, it's a integer" );
$array = array(
'foo' => 1,
'bar' => 2,
);
ArrayUtil::ensure_key_is_array( $array, 'bar', true );
}
}

View File

@ -50,7 +50,7 @@ class StringUtilTest extends \WC_Unit_Test_Case {
}
/**
* @return void 'contains' should check whether one string contains another.
* @testdox 'contains' should check whether one string contains another.
*/
public function test_contains() {
$this->assertFalse( StringUtil::contains( 'foobar', 'fizzbuzz' ) );
@ -64,4 +64,14 @@ class StringUtilTest extends \WC_Unit_Test_Case {
$this->assertTrue( StringUtil::contains( 'foobar', 'BA', false ) );
}
/**
* @testdox 'plugin_name_from_plugin_file' returns the plugin name in the form 'directory/file.php' from the plugin file.
*/
public function test_plugin_name_from_plugin_file() {
$file_path = '/home/someone/wordpress/wp-content/plugins/foobar/fizzbuzz.php';
$result = StringUtil::plugin_name_from_plugin_file( $file_path );
$expected = 'foobar/fizzbuzz.php';
$this->assertEquals( $expected, $result );
}
}