From 5e71cb8ed1c0d01cdaa2fa7c194ec69f0040a60e Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Tue, 7 Jul 2026 21:18:14 +0200 Subject: [PATCH 01/10] fix(framework): harden kernel-package gates and align docblock accuracy Kernel: a declared Feature class that does not implement FeatureInterface now throws FeatureException like a malformed Conditional (same deterministic-developer-error rule, both sides). PluginHeader gates its derived slug through is_valid_identifier, throwing the new InvalidPluginHeaderException. New consumer-contract exceptions complete the lifecycle vocabulary (HookRegistrationException) and replace bare SPL throws (CyclicObjectGraphException). Boot-metrics array shape collapses to one @phpstan-type alias. Docblock accuracy: __toString false-branch, InvalidVersionException scope, convert_to_primitives root-vs-nested asymmetry, Rendering exception formula unified onto {@see}, transaction guarantee owned by the kernel class docblock, PluginBootReport opener per the descriptor grammar, bootstrap guard-string spelling, WHY comment on the TextDomain fallback guess. Bootstrap's @return void stays: PHP 5.6 forbids native : void, so the tag is load-bearing for PHPStan. Assisted-by: Claude Code:claude-fable-5 --- .../bootstrap/src/Environment/functions.php | 4 +- packages/bootstrap/src/Notice/functions.php | 2 +- packages/bootstrap/src/Plugin/functions.php | 9 +++- .../Exceptions/HookRegistrationException.php | 14 +++++ .../Lifecycle/Hookable/Exceptions/index.php | 1 + .../Lifecycle/Hookable/HookableInterface.php | 2 + packages/core/src/PluginKernel.php | 54 ++++++++++--------- .../Exceptions/OutputException.php | 3 +- .../Exceptions/RenderingException.php | 3 +- .../InvalidPluginHeaderException.php | 26 +++++++++ .../src/ValueObjects/Exceptions/index.php | 1 + .../src/ValueObjects/PluginBootReport.php | 2 +- .../core/src/ValueObjects/PluginHeader.php | 15 +++++- .../Support/wp-plugin-stub-functions.php | 21 ++++++++ packages/core/tests/Unit/PluginKernelTest.php | 49 +++++++++++++++++ .../Unit/ValueObjects/PluginHeaderTest.php | 41 ++++++++++++++ .../Exceptions/CyclicObjectGraphException.php | 14 +++++ .../src/Reflection/Exceptions/index.php | 1 + packages/shared/src/Reflection/functions.php | 11 +++- .../src/ValueObject/AbstractValueObject.php | 3 +- .../InvalidValueObjectException.php | 2 +- .../Exceptions/InvalidVersionException.php | 3 +- .../tests/Unit/Reflection/FunctionsTest.php | 11 ++-- 23 files changed, 248 insertions(+), 44 deletions(-) create mode 100644 packages/core/src/Lifecycle/Hookable/Exceptions/HookRegistrationException.php create mode 100644 packages/core/src/Lifecycle/Hookable/Exceptions/index.php create mode 100644 packages/core/src/ValueObjects/Exceptions/InvalidPluginHeaderException.php create mode 100644 packages/core/src/ValueObjects/Exceptions/index.php create mode 100644 packages/core/tests/Support/wp-plugin-stub-functions.php create mode 100644 packages/core/tests/Unit/ValueObjects/PluginHeaderTest.php create mode 100644 packages/shared/src/Reflection/Exceptions/CyclicObjectGraphException.php create mode 100644 packages/shared/src/Reflection/Exceptions/index.php diff --git a/packages/bootstrap/src/Environment/functions.php b/packages/bootstrap/src/Environment/functions.php index fd9804a..05583de 100644 --- a/packages/bootstrap/src/Environment/functions.php +++ b/packages/bootstrap/src/Environment/functions.php @@ -14,7 +14,7 @@ * @return bool */ function is_php_compatible( $min_php ) { - if ( \function_exists( '\is_php_version_compatible' ) ) { + if ( \function_exists( 'is_php_version_compatible' ) ) { return \is_php_version_compatible( $min_php ); } @@ -36,7 +36,7 @@ function is_php_compatible( $min_php ) { * @return bool */ function is_wp_compatible( $min_wp ) { - if ( \function_exists( '\is_wp_version_compatible' ) ) { + if ( \function_exists( 'is_wp_version_compatible' ) ) { return \is_wp_version_compatible( $min_wp ); } diff --git a/packages/bootstrap/src/Notice/functions.php b/packages/bootstrap/src/Notice/functions.php index 2515736..0a000b7 100644 --- a/packages/bootstrap/src/Notice/functions.php +++ b/packages/bootstrap/src/Notice/functions.php @@ -65,7 +65,7 @@ function () use ( $plugin_basename, $error ) { } $message = $intro . ''; - if ( \function_exists( '\wp_admin_notice' ) ) { + if ( \function_exists( 'wp_admin_notice' ) ) { \wp_admin_notice( $message, array( 'type' => 'error' ) ); } else { echo \wp_kses_post( '

' . $message . '

' ); diff --git a/packages/bootstrap/src/Plugin/functions.php b/packages/bootstrap/src/Plugin/functions.php index fff7600..cad9123 100644 --- a/packages/bootstrap/src/Plugin/functions.php +++ b/packages/bootstrap/src/Plugin/functions.php @@ -3,7 +3,9 @@ namespace DeepWebSolutions\Framework\Bootstrap\Plugin; /** - * Returns the consumer plugin's metadata. + * Returns the consumer plugin's metadata. When the main plugin file is not + * readable, returns an empty-valued metadata shape whose TextDomain is guessed + * from the plugin's directory name. * * Pass `$translate=true` ONLY from contexts that fire after `init` (e.g., an * `admin_notices` callback) to avoid WP 6.7+'s "doing it wrong" notice. @@ -25,6 +27,9 @@ function get_plugin_metadata( $plugin_basename, $translate = false ) { $plugin_file = WP_PLUGIN_DIR . '/' . $plugin_basename; if ( ! \is_readable( $plugin_file ) ) { + // A plugin's directory name conventionally matches its text domain, and consumers + // derive the plugin slug from TextDomain — the guess keeps that derivation working + // when the main file cannot be read. $text_domain = ''; $plugin_slug = \dirname( $plugin_basename ); if ( '.' !== $plugin_slug && false === \strpos( $plugin_slug, '/' ) ) { @@ -49,7 +54,7 @@ function get_plugin_metadata( $plugin_basename, $translate = false ) { 'AuthorName' => '', ); } else { - if ( ! \function_exists( '\get_plugin_data' ) ) { + if ( ! \function_exists( 'get_plugin_data' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } diff --git a/packages/core/src/Lifecycle/Hookable/Exceptions/HookRegistrationException.php b/packages/core/src/Lifecycle/Hookable/Exceptions/HookRegistrationException.php new file mode 100644 index 0000000..e99612f --- /dev/null +++ b/packages/core/src/Lifecycle/Hookable/Exceptions/HookRegistrationException.php @@ -0,0 +1,14 @@ +, conditional: class-string}>, pruned_components: list, runnable_components: list, inert_components: list, initialized_components: list, hooked_components: list} */ final class PluginKernel { // region FIELDS AND CONSTANTS @@ -45,7 +52,7 @@ final class PluginKernel { * @since 2.0.0 * @version 2.0.0 * - * @var array{gated_features: list, conditional: class-string}>, pruned_components: list, runnable_components: list, inert_components: list, initialized_components: list, hooked_components: list} + * @var BootMetrics */ protected const EMPTY_BOOT_METRICS = array( 'gated_features' => array(), @@ -85,7 +92,7 @@ final class PluginKernel { * @since 2.0.0 * @version 2.0.0 * - * @var array{gated_features: list, conditional: class-string}>, pruned_components: list, runnable_components: list, inert_components: list, initialized_components: list, hooked_components: list} + * @var BootMetrics */ protected array $boot_metrics = self::EMPTY_BOOT_METRICS; @@ -167,21 +174,15 @@ static function ( bool $network_deactivating = false ) use ( $plugin ): void { * hookable one registers hooks, so a hook callback may safely reach a peer in * another Feature. * - * The whole component phase is a hook-table transaction: WordPress's hook table is - * snapshotted before any Feature or component is constructed, and any failure in the - * phase — resolution, initialization, or hook registration — unwinds the table to its - * window-start state, so no hook registered through the WordPress hook API by any phase - * of a failed boot survives (constructor, initialize(), register_hooks()). Hook-table mutations third-party code - * made synchronously inside the window are unwound with it; $wp_current_filter, - * $wp_actions, and non-hook side effects (options writes, post-type registration, …) - * are not transactional. A failure resolving or running a component fails closed — it - * is logged and the request registers nothing — except a malformed component graph, - * which propagates so the developer error surfaces rather than passing silently. + * The component phase runs inside the hook-table transaction described on the class. + * A failure resolving or running a component fails closed — it is logged and the + * request registers nothing — except a malformed component graph, which propagates so + * the developer error surfaces rather than passing silently. * * @since 2.0.0 * @version 2.0.0 * - * @throws FeatureException When the declared component graph is malformed: a duplicate or cyclic component, or a declared gate that is not a ConditionalInterface. + * @throws FeatureException When the declared component graph is malformed: a duplicate or cyclic component, a declared Feature that is not a FeatureInterface, or a declared gate that is not a ConditionalInterface. */ public function boot(): void { if ( $this->booted ) { @@ -204,6 +205,11 @@ public function boot(): void { $surviving_features = array(); foreach ( $this->plugin->get_feature_classes() as $feature_class ) { + if ( ! \is_a( $feature_class, FeatureInterface::class, true ) ) { + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- framework-internal exception; never reaches an HTML output context unescaped. + throw new FeatureException( 'Feature ' . $feature_class . ' does not implement ' . FeatureInterface::class . '.' ); + } + if ( $this->are_conditionals_met( $feature_class, $container ) ) { /** @var FeatureInterface $feature */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort -- inline @var type assertion, no description applies. $feature = $container->get( $feature_class ); @@ -239,8 +245,8 @@ public function boot(): void { $this->rollback_hook_table( $snapshot ); $this->boot_report = $this->build_boot_report( BootStatus::Failed, $this->format_throwable_summary( $error ) ); - // A duplicate/cyclic component graph or a malformed gate is a deterministic developer error, - // not a runtime fault — it propagates so it surfaces in development rather than failing silently. + // A duplicate/cyclic component graph or a malformed Feature or gate declaration is a deterministic + // developer error, not a runtime fault — it propagates so it surfaces in development rather than failing silently. throw $error; } catch ( \Throwable $error ) { $this->rollback_hook_table( $snapshot ); @@ -561,11 +567,9 @@ protected function snapshot_hook_table(): array { * iterations), so live WP_Hook internals are never manipulated directly; a tag the * unwind empties is recreated by WordPress as a fresh registry object. A changed * priority bucket is rebuilt in snapshot order, so restored callbacks keep their - * original execution order. The restore is scoped to registrations made through the - * WordPress hook API: mutations third-party code made synchronously inside the window - * are unwound with them, while $wp_current_filter, $wp_actions, and non-hook side - * effects stay untouched. A residue left by direct hook-table manipulation cannot be - * removed through the API and is logged instead. + * original execution order. The restore's scope is the transaction guarantee described + * on the class. A residue left by direct hook-table manipulation cannot be removed + * through the API and is logged instead. * * @since 2.0.0 * @version 2.0.0 diff --git a/packages/core/src/Rendering/Outputtable/Exceptions/OutputException.php b/packages/core/src/Rendering/Outputtable/Exceptions/OutputException.php index c50fb70..0af6bba 100644 --- a/packages/core/src/Rendering/Outputtable/Exceptions/OutputException.php +++ b/packages/core/src/Rendering/Outputtable/Exceptions/OutputException.php @@ -5,7 +5,8 @@ use DeepWebSolutions\Framework\Shared\Exception\AbstractRuntimeException; /** - * Thrown when an Outputtable component's output() fails unrecoverably. + * Thrown by {@see \DeepWebSolutions\Framework\Core\Rendering\Outputtable\OutputtableInterface::output()} + * implementations when output fails unrecoverably. * * @since 2.0.0 * @version 2.0.0 diff --git a/packages/core/src/Rendering/Renderable/Exceptions/RenderingException.php b/packages/core/src/Rendering/Renderable/Exceptions/RenderingException.php index 41f1f47..439155b 100644 --- a/packages/core/src/Rendering/Renderable/Exceptions/RenderingException.php +++ b/packages/core/src/Rendering/Renderable/Exceptions/RenderingException.php @@ -5,7 +5,8 @@ use DeepWebSolutions\Framework\Shared\Exception\AbstractRuntimeException; /** - * Thrown when a Renderable component's render() fails unrecoverably. + * Thrown by {@see \DeepWebSolutions\Framework\Core\Rendering\Renderable\RenderableInterface::render()} + * implementations when rendering fails unrecoverably. * * @since 2.0.0 * @version 2.0.0 diff --git a/packages/core/src/ValueObjects/Exceptions/InvalidPluginHeaderException.php b/packages/core/src/ValueObjects/Exceptions/InvalidPluginHeaderException.php new file mode 100644 index 0000000..6ee3164 --- /dev/null +++ b/packages/core/src/ValueObjects/Exceptions/InvalidPluginHeaderException.php @@ -0,0 +1,26 @@ + 'PluginHeader'; + } +} diff --git a/packages/core/src/ValueObjects/Exceptions/index.php b/packages/core/src/ValueObjects/Exceptions/index.php new file mode 100644 index 0000000..f767346 --- /dev/null +++ b/packages/core/src/ValueObjects/Exceptions/index.php @@ -0,0 +1 @@ +requires_php = $data['RequiresPHP'] ?? ''; $this->network = $data['Network'] ?? false; - $directory = \dirname( $basename ); - $this->slug = match ( true ) { + $directory = \dirname( $basename ); + $slug = match ( true ) { '' !== $this->text_domain => $this->text_domain, '.' !== $directory => $directory, default => \pathinfo( $basename, PATHINFO_FILENAME ), }; + + if ( ! is_valid_identifier( $slug ) ) { + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- framework-internal exception; never reaches an HTML output context unescaped. + throw new InvalidPluginHeaderException( "derived slug '$slug' is not a valid identifier. Use a Text Domain (or plugin directory/file name) of a lowercase letter followed by lowercase a-z, 0-9, _, - so derived hook names and REST namespaces stay well-formed" ); + } + + $this->slug = $slug; } // endregion diff --git a/packages/core/tests/Support/wp-plugin-stub-functions.php b/packages/core/tests/Support/wp-plugin-stub-functions.php new file mode 100644 index 0000000..7d60d2f --- /dev/null +++ b/packages/core/tests/Support/wp-plugin-stub-functions.php @@ -0,0 +1,21 @@ +normalized_hook_table(); + + // The second declared "feature" is a plain marker class, so the malformed-Feature guard + // throws after the first feature — and the hook its resolution registered — lands inside + // the transaction window. + $container = new class() implements ContainerInterface { + public function get( string $id ): mixed { + \add_filter( 'feature_resolution_hook', static fn ( mixed $value ): mixed => $value, 10 ); + + return new PluginKernelTestFeatureA( array() ); + } + + public function has( string $id ): bool { + return true; + } + }; + + // @phpstan-ignore argument.type (the malformed feature list is the point of the test) + $plugin = $this->make_plugin( $container, array( PluginKernelTestFeatureA::class, PluginKernelTestComp::class ) ); + $kernel = new PluginKernel( $plugin ); + + $caught = null; + try { + $kernel->boot(); + } catch ( FeatureException $error ) { + $caught = $error; + } + + self::assertInstanceOf( FeatureException::class, $caught ); + self::assertSame( 'Feature ' . PluginKernelTestComp::class . ' does not implement ' . FeatureInterface::class . '.', $caught->getMessage() ); + self::assertSame( $before, $this->normalized_hook_table() ); + self::assertArrayNotHasKey( 'feature_resolution_hook', $this->normalized_hook_table() ); + self::assertSame( BootStatus::Failed, $kernel->boot_report->status ); + } finally { + if ( $had_wp_filter ) { + $GLOBALS['wp_filter'] = $prior_filter; + } else { + unset( $GLOBALS['wp_filter'] ); + } + } + } + public function test_inert_component_after_a_lifecycle_component_is_still_reported(): void { $log = new PluginKernelTestLog(); $container = $this->make_container( diff --git a/packages/core/tests/Unit/ValueObjects/PluginHeaderTest.php b/packages/core/tests/Unit/ValueObjects/PluginHeaderTest.php new file mode 100644 index 0000000..385a627 --- /dev/null +++ b/packages/core/tests/Unit/ValueObjects/PluginHeaderTest.php @@ -0,0 +1,41 @@ +slug ); + self::assertSame( 'my-plugin', $header->text_domain ); + } + + public function test_uppercase_text_domain_derived_slug_throws(): void { + $this->expectException( InvalidPluginHeaderException::class ); + $this->expectExceptionMessage( "derived slug 'MyPlugin' is not a valid identifier" ); + + new PluginHeader( WP_PLUGIN_DIR . '/MyPlugin/my-plugin.php' ); + } + + public function test_dotted_text_domain_derived_slug_throws(): void { + $this->expectException( InvalidPluginHeaderException::class ); + $this->expectExceptionMessage( "derived slug 'my.plugin' is not a valid identifier" ); + + new PluginHeader( WP_PLUGIN_DIR . '/my.plugin/my-plugin.php' ); + } +} diff --git a/packages/shared/src/Reflection/Exceptions/CyclicObjectGraphException.php b/packages/shared/src/Reflection/Exceptions/CyclicObjectGraphException.php new file mode 100644 index 0000000..0f532a2 --- /dev/null +++ b/packages/shared/src/Reflection/Exceptions/CyclicObjectGraphException.php @@ -0,0 +1,14 @@ + */ @@ -68,7 +75,7 @@ function convert_to_primitives( \JsonSerializable $input_object ): array { $object_id = \spl_object_id( $value ); if ( isset( $expanding[ $object_id ] ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- framework-internal exception; never reaches an HTML output context unescaped. - throw new \RuntimeException( 'Cyclic object graph: ' . $value::class . ' is already being converted to primitives.' ); + throw new CyclicObjectGraphException( 'Cyclic object graph: ' . $value::class . ' is already being converted to primitives.' ); } $expanding[ $object_id ] = true; diff --git a/packages/shared/src/ValueObject/AbstractValueObject.php b/packages/shared/src/ValueObject/AbstractValueObject.php index e04dbb7..7a20814 100644 --- a/packages/shared/src/ValueObject/AbstractValueObject.php +++ b/packages/shared/src/ValueObject/AbstractValueObject.php @@ -15,7 +15,8 @@ // region MAGIC METHODS /** - * Returns the value object's JSON form; falls back to the encoder's error message on failure. + * Returns the value object's JSON form; a {@see \JsonException} from the encoder + * yields the exception's message, and a false return yields an empty string. * * @since 2.0.0 * @version 2.0.0 diff --git a/packages/shared/src/ValueObject/Exceptions/InvalidValueObjectException.php b/packages/shared/src/ValueObject/Exceptions/InvalidValueObjectException.php index 28264cb..116b4ce 100644 --- a/packages/shared/src/ValueObject/Exceptions/InvalidValueObjectException.php +++ b/packages/shared/src/ValueObject/Exceptions/InvalidValueObjectException.php @@ -39,7 +39,7 @@ abstract class InvalidValueObjectException extends AbstractInvalidArgumentExcept * @param \Throwable|null $previous Previous exception for chaining. */ public function __construct( string $reason, int $code = 0, ?\Throwable $previous = null ) { - $message = \sprintf( 'Value object of type `%s` is invalid for the following reason: %s', $this->value_object_type, $reason ); + $message = \sprintf( "Value object of type '%s' is invalid for the following reason: %s", $this->value_object_type, $reason ); parent::__construct( $message, $code, $previous ); } } diff --git a/packages/shared/src/Version/Exceptions/InvalidVersionException.php b/packages/shared/src/Version/Exceptions/InvalidVersionException.php index e62d351..948cba4 100644 --- a/packages/shared/src/Version/Exceptions/InvalidVersionException.php +++ b/packages/shared/src/Version/Exceptions/InvalidVersionException.php @@ -5,7 +5,8 @@ use DeepWebSolutions\Framework\Shared\ValueObject\Exceptions\InvalidValueObjectException; /** - * Thrown when a string cannot be parsed into a valid Version value object. + * Thrown when a string cannot be parsed into a valid Version value object, or when + * version parts cannot compose one. * * @since 2.0.0 * @version 2.0.0 diff --git a/packages/shared/tests/Unit/Reflection/FunctionsTest.php b/packages/shared/tests/Unit/Reflection/FunctionsTest.php index 2f6f09a..e3b6fab 100644 --- a/packages/shared/tests/Unit/Reflection/FunctionsTest.php +++ b/packages/shared/tests/Unit/Reflection/FunctionsTest.php @@ -2,7 +2,9 @@ namespace DeepWebSolutions\Framework\Shared\Tests\Unit\Reflection; +use DeepWebSolutions\Framework\Shared\Reflection\Exceptions\CyclicObjectGraphException; use PHPUnit\Framework\Attributes\CoversFunction; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; use function DeepWebSolutions\Framework\Shared\Reflection\convert_to_primitives; @@ -131,6 +133,7 @@ public function jsonSerialize(): array { #[CoversFunction( 'DeepWebSolutions\Framework\Shared\Reflection\convert_to_primitives' )] #[CoversFunction( 'DeepWebSolutions\Framework\Shared\Reflection\get_public_property_names' )] +#[UsesClass( CyclicObjectGraphException::class )] final class FunctionsTest extends TestCase { public function test_get_public_property_names_returns_public_only(): void { $names = get_public_property_names( new FixtureWithMixedVisibility() ); @@ -264,7 +267,7 @@ public function test_convert_to_primitives_throws_on_a_self_referential_object() $fixture = new FixtureSelfReferential(); $fixture->self = $fixture; - $this->expectException( \RuntimeException::class ); + $this->expectException( CyclicObjectGraphException::class ); $this->expectExceptionMessage( FixtureSelfReferential::class ); convert_to_primitives( $fixture ); @@ -273,7 +276,7 @@ public function test_convert_to_primitives_throws_on_a_self_referential_object() public function test_convert_to_primitives_throws_on_a_serializer_returning_itself(): void { $obj = new FixtureWithArray( array( new FixtureRawSelfCycle() ) ); - $this->expectException( \RuntimeException::class ); + $this->expectException( CyclicObjectGraphException::class ); $this->expectExceptionMessage( FixtureRawSelfCycle::class ); convert_to_primitives( $obj ); @@ -306,8 +309,8 @@ public function test_convert_to_primitives_still_reduces_after_a_cycle_was_detec try { convert_to_primitives( $fixture ); - self::fail( 'Expected a cyclic-graph RuntimeException.' ); - } catch ( \RuntimeException ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- the assertion is the follow-up call below. + self::fail( 'Expected a CyclicObjectGraphException.' ); + } catch ( CyclicObjectGraphException ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- the assertion is the follow-up call below. // The in-flight set must be unwound with the exception; a later call starts clean. } From b1ee82c178dcfb396a14331fd3dc150e5a8853ee Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Tue, 7 Jul 2026 21:56:37 +0200 Subject: [PATCH 02/10] refactor(framework)!: rename field surfaces and settle naming rules Renames the five admin field-surface classes *FieldStore -> *FieldSurface (folder Settings/MetaField/Stores/ -> Surfaces/): an industry survey (ACF, MetaBox, CMB2, Carbon Fields, Fieldmanager, Pods, WC core) is unanimous that the Store family names passive persistence, which this codebase already uses it for (KeyValueStore family, NoticeStore, the object-meta repositories beneath these classes). A surface mounts a field group onto one WP admin surface: registration, render, save, CRUD, cleanup. Two prefix rules settle the remaining drift: the Settings prefix marks the descriptor family and types named for it, so the generic SettingsOptionsProviderInterface becomes OptionsProviderInterface; the WooCommerce token spells out everywhere while WP abbreviates, so WCSettingsBuilder becomes WooCommerceSettingsBuilder and DescriptorBackedWCSettingsPage becomes DescriptorBackedWooCommerceSettingsPage (test fixtures included). Frozen namespace roots are untouched; deptrac collectors and PSR-4 mappings needed no changes. ~240 references across 35 files. Assisted-by: Claude Code:claude-fable-5 --- infection.integration.json | 2 +- .../PostMetaFieldSurface.php} | 8 +- .../TermFieldSurface.php} | 8 +- .../UserProfileFieldSurface.php} | 8 +- .../MetaField/{Stores => Surfaces}/index.php | 0 ...rface.php => OptionsProviderInterface.php} | 2 +- .../Schema/Options/OptionsResolver.php | 6 +- .../Schema/ValueObjects/SettingsField.php | 32 +++---- .../PostMetaFieldSurfaceTest.php} | 30 +++---- .../TermFieldSurfaceTest.php} | 36 ++++---- .../UserProfileFieldSurfaceTest.php} | 30 +++---- .../MetaField/{Stores => Surfaces}/index.php | 0 .../PostMetaFieldSurfaceTest.php} | 12 +-- .../TermFieldSurfaceTest.php} | 12 +-- .../UserProfileFieldSurfaceTest.php} | 12 +-- .../MetaField/{Stores => Surfaces}/index.php | 0 .../Settings/Unit/OptionsResolverTest.php | 6 +- .../tests/Settings/Unit/SettingsFieldTest.php | 4 +- ...scriptorBackedWooCommerceSettingsPage.php} | 4 +- .../Backend/WooCommerceSettingsBackend.php | 6 +- ...der.php => WooCommerceSettingsBuilder.php} | 2 +- ...erFieldStore.php => OrderFieldSurface.php} | 8 +- ...dStore.php => ProductDataFieldSurface.php} | 6 +- ...ptorBackedWooCommerceSettingsPageTest.php} | 70 ++++++++-------- ...age.php => BarWooCommerceSettingsPage.php} | 4 +- ...age.php => FooWooCommerceSettingsPage.php} | 4 +- ...hp => LazyBindWooCommerceSettingsPage.php} | 4 +- ...php => UnboundWooCommerceSettingsPage.php} | 4 +- ...toreTest.php => OrderFieldSurfaceTest.php} | 54 ++++++------ ...st.php => ProductDataFieldSurfaceTest.php} | 84 +++++++++---------- .../WooCommerceSettingsBackendTest.php | 56 ++++++------- ...toreTest.php => OrderFieldSurfaceTest.php} | 10 +-- .../Unit/WooCommerceSettingsBackendTest.php | 4 +- ...php => WooCommerceSettingsBuilderTest.php} | 64 +++++++------- tests/Fixtures/consumer-smoke/smoke.php | 4 +- 35 files changed, 298 insertions(+), 298 deletions(-) rename packages/infrastructure/src/Settings/MetaField/{Stores/PostMetaFieldStore.php => Surfaces/PostMetaFieldSurface.php} (97%) rename packages/infrastructure/src/Settings/MetaField/{Stores/TermFieldStore.php => Surfaces/TermFieldSurface.php} (97%) rename packages/infrastructure/src/Settings/MetaField/{Stores/UserProfileFieldStore.php => Surfaces/UserProfileFieldSurface.php} (97%) rename packages/infrastructure/src/Settings/MetaField/{Stores => Surfaces}/index.php (100%) rename packages/infrastructure/src/Settings/Schema/Options/{SettingsOptionsProviderInterface.php => OptionsProviderInterface.php} (93%) rename packages/infrastructure/tests/Settings/Integration/MetaField/{Stores/PostMetaFieldStoreTest.php => Surfaces/PostMetaFieldSurfaceTest.php} (91%) rename packages/infrastructure/tests/Settings/Integration/MetaField/{Stores/TermFieldStoreTest.php => Surfaces/TermFieldSurfaceTest.php} (91%) rename packages/infrastructure/tests/Settings/Integration/MetaField/{Stores/UserProfileFieldStoreTest.php => Surfaces/UserProfileFieldSurfaceTest.php} (90%) rename packages/infrastructure/tests/Settings/Integration/MetaField/{Stores => Surfaces}/index.php (100%) rename packages/infrastructure/tests/Settings/Unit/MetaField/{Stores/PostMetaFieldStoreTest.php => Surfaces/PostMetaFieldSurfaceTest.php} (95%) rename packages/infrastructure/tests/Settings/Unit/MetaField/{Stores/TermFieldStoreTest.php => Surfaces/TermFieldSurfaceTest.php} (94%) rename packages/infrastructure/tests/Settings/Unit/MetaField/{Stores/UserProfileFieldStoreTest.php => Surfaces/UserProfileFieldSurfaceTest.php} (93%) rename packages/infrastructure/tests/Settings/Unit/MetaField/{Stores => Surfaces}/index.php (100%) rename packages/woocommerce/src/Backend/{DescriptorBackedWCSettingsPage.php => DescriptorBackedWooCommerceSettingsPage.php} (98%) rename packages/woocommerce/src/Backend/{WCSettingsBuilder.php => WooCommerceSettingsBuilder.php} (99%) rename packages/woocommerce/src/OrderData/{OrderFieldStore.php => OrderFieldSurface.php} (97%) rename packages/woocommerce/src/ProductData/{ProductDataFieldStore.php => ProductDataFieldSurface.php} (98%) rename packages/woocommerce/tests/Integration/{DescriptorBackedWCSettingsPageTest.php => DescriptorBackedWooCommerceSettingsPageTest.php} (73%) rename packages/woocommerce/tests/Integration/Fixtures/{BarWCSettingsPage.php => BarWooCommerceSettingsPage.php} (70%) rename packages/woocommerce/tests/Integration/Fixtures/{FooWCSettingsPage.php => FooWooCommerceSettingsPage.php} (70%) rename packages/woocommerce/tests/Integration/Fixtures/{LazyBindWCSettingsPage.php => LazyBindWooCommerceSettingsPage.php} (70%) rename packages/woocommerce/tests/Integration/Fixtures/{UnboundWCSettingsPage.php => UnboundWooCommerceSettingsPage.php} (70%) rename packages/woocommerce/tests/Integration/OrderData/{OrderFieldStoreTest.php => OrderFieldSurfaceTest.php} (93%) rename packages/woocommerce/tests/Integration/{ProductDataFieldStoreTest.php => ProductDataFieldSurfaceTest.php} (93%) rename packages/woocommerce/tests/Unit/OrderData/{OrderFieldStoreTest.php => OrderFieldSurfaceTest.php} (95%) rename packages/woocommerce/tests/Unit/{WCSettingsBuilderTest.php => WooCommerceSettingsBuilderTest.php} (78%) diff --git a/infection.integration.json b/infection.integration.json index f433735..85677e9 100644 --- a/infection.integration.json +++ b/infection.integration.json @@ -14,7 +14,7 @@ "excludes": [ "tests", "changelog", - "Backend/DescriptorBackedWCSettingsPage.php" + "Backend/DescriptorBackedWooCommerceSettingsPage.php" ] }, "phpUnit": { diff --git a/packages/infrastructure/src/Settings/MetaField/Stores/PostMetaFieldStore.php b/packages/infrastructure/src/Settings/MetaField/Surfaces/PostMetaFieldSurface.php similarity index 97% rename from packages/infrastructure/src/Settings/MetaField/Stores/PostMetaFieldStore.php rename to packages/infrastructure/src/Settings/MetaField/Surfaces/PostMetaFieldSurface.php index 1db1bd0..cbe65db 100644 --- a/packages/infrastructure/src/Settings/MetaField/Stores/PostMetaFieldStore.php +++ b/packages/infrastructure/src/Settings/MetaField/Surfaces/PostMetaFieldSurface.php @@ -1,6 +1,6 @@ |\Closure|SettingsOptionsProviderInterface $options Options source to resolve. + * @param array|\Closure|OptionsProviderInterface $options Options source to resolve. * * @throws InvalidSettingsOptionsException If a closure source resolves to a non-array. * * @return array */ - public function resolve( array|\Closure|SettingsOptionsProviderInterface $options ): array { - if ( $options instanceof SettingsOptionsProviderInterface ) { + public function resolve( array|\Closure|OptionsProviderInterface $options ): array { + if ( $options instanceof OptionsProviderInterface ) { return $options->get_options(); } diff --git a/packages/infrastructure/src/Settings/Schema/ValueObjects/SettingsField.php b/packages/infrastructure/src/Settings/Schema/ValueObjects/SettingsField.php index ec3e7aa..c9de8a3 100644 --- a/packages/infrastructure/src/Settings/Schema/ValueObjects/SettingsField.php +++ b/packages/infrastructure/src/Settings/Schema/ValueObjects/SettingsField.php @@ -3,7 +3,7 @@ namespace DeepWebSolutions\Framework\Settings\Schema\ValueObjects; use DeepWebSolutions\Framework\Settings\Schema\Exceptions\InvalidSettingsFieldException; -use DeepWebSolutions\Framework\Settings\Schema\Options\SettingsOptionsProviderInterface; +use DeepWebSolutions\Framework\Settings\Schema\Options\OptionsProviderInterface; use function DeepWebSolutions\Framework\Shared\Identifier\is_valid_identifier; @@ -56,20 +56,20 @@ * @since 2.0.0 * @version 2.0.0 * - * @param string $id Page-unique field identifier; a lowercase token matching the field-id charset. - * @param string $type Field-type token resolved against the framework taxonomy when rendered or processed: a `Field\FieldType` value such as `FieldType::Text->value`, or a registered `CustomFieldType` token. - * @param string $label Human-readable field label. - * @param mixed $default_value Default value used when nothing is stored. - * @param ?callable $sanitize Sanitizer for the submitted value; stored as a Closure. Signature `(mixed $value): mixed` — returns the transformed value. - * @param ?callable $validate Validator for the sanitized value; stored as a Closure. Signature `(mixed $value): bool` — the return counts only for truthiness, so a returned (truthy) string never fails validation. - * @param ?string $capability Primitive capability required to edit the field; null inherits the section/page capability. Object-scoped checks belong to the hosting WordPress surface and the field sanitize/validate seam. - * @param bool $show_in_rest Whether the field is exposed via REST where the backend supports it. - * @param bool $autoload Whether the field's stored value should autoload on every request; defaults to off. - * @param ?int $position Sort position within the section; null keeps declaration order. - * @param array|\Closure|SettingsOptionsProviderInterface $options Option set for choice-typed fields: a literal array, a Closure (not a bare callable, so an array is always the option set), or a provider; labels are stringified at render. - * @param array $attributes Extra HTML attributes passed through to the rendered control. - * @param ?string $meta_key Object-field storage key; may be underscore-prefixed, and null for option settings. - * @param ?string $description Help text rendered beneath the control; null renders none. + * @param string $id Page-unique field identifier; a lowercase token matching the field-id charset. + * @param string $type Field-type token resolved against the framework taxonomy when rendered or processed: a `Field\FieldType` value such as `FieldType::Text->value`, or a registered `CustomFieldType` token. + * @param string $label Human-readable field label. + * @param mixed $default_value Default value used when nothing is stored. + * @param ?callable $sanitize Sanitizer for the submitted value; stored as a Closure. Signature `(mixed $value): mixed` — returns the transformed value. + * @param ?callable $validate Validator for the sanitized value; stored as a Closure. Signature `(mixed $value): bool` — the return counts only for truthiness, so a returned (truthy) string never fails validation. + * @param ?string $capability Primitive capability required to edit the field; null inherits the section/page capability. Object-scoped checks belong to the hosting WordPress surface and the field sanitize/validate seam. + * @param bool $show_in_rest Whether the field is exposed via REST where the backend supports it. + * @param bool $autoload Whether the field's stored value should autoload on every request; defaults to off. + * @param ?int $position Sort position within the section; null keeps declaration order. + * @param array|\Closure|OptionsProviderInterface $options Option set for choice-typed fields: a literal array, a Closure (not a bare callable, so an array is always the option set), or a provider; labels are stringified at render. + * @param array $attributes Extra HTML attributes passed through to the rendered control. + * @param ?string $meta_key Object-field storage key; may be underscore-prefixed, and null for option settings. + * @param ?string $description Help text rendered beneath the control; null renders none. * * @throws InvalidSettingsFieldException If $id does not match the field-id charset. */ @@ -84,7 +84,7 @@ public function __construct( public bool $show_in_rest = false, public bool $autoload = false, public ?int $position = null, - public array|\Closure|SettingsOptionsProviderInterface $options = array(), + public array|\Closure|OptionsProviderInterface $options = array(), public array $attributes = array(), public ?string $meta_key = null, public ?string $description = null, diff --git a/packages/infrastructure/tests/Settings/Integration/MetaField/Stores/PostMetaFieldStoreTest.php b/packages/infrastructure/tests/Settings/Integration/MetaField/Surfaces/PostMetaFieldSurfaceTest.php similarity index 91% rename from packages/infrastructure/tests/Settings/Integration/MetaField/Stores/PostMetaFieldStoreTest.php rename to packages/infrastructure/tests/Settings/Integration/MetaField/Surfaces/PostMetaFieldSurfaceTest.php index 8391f98..0eba43e 100644 --- a/packages/infrastructure/tests/Settings/Integration/MetaField/Stores/PostMetaFieldStoreTest.php +++ b/packages/infrastructure/tests/Settings/Integration/MetaField/Surfaces/PostMetaFieldSurfaceTest.php @@ -1,9 +1,9 @@ register( $this->group(), $this->placement() ); + ( new PostMetaFieldSurface() )->register( $this->group(), $this->placement() ); \do_action( 'add_meta_boxes_post', \get_post( $this->post_id ) ); self::assertArrayHasKey( self::GROUP_ID, $this->boxes_on( 'post' ) ); @@ -92,7 +92,7 @@ public function test_register_adds_the_meta_box_on_the_screen(): void { public function test_the_registered_box_renders_a_nonce_and_its_control(): void { $group = $this->group(); - ( new PostMetaFieldStore() )->register( $group, $this->placement() ); + ( new PostMetaFieldSurface() )->register( $group, $this->placement() ); \do_action( 'add_meta_boxes_post', \get_post( $this->post_id ) ); $html = $this->render_box(); @@ -102,7 +102,7 @@ public function test_the_registered_box_renders_a_nonce_and_its_control(): void } public function test_the_box_row_binds_the_label_to_the_control_id(): void { - ( new PostMetaFieldStore() )->register( $this->group(), $this->placement() ); + ( new PostMetaFieldSurface() )->register( $this->group(), $this->placement() ); \do_action( 'add_meta_boxes_post', \get_post( $this->post_id ) ); $html = $this->render_box(); @@ -112,7 +112,7 @@ public function test_the_box_row_binds_the_label_to_the_control_id(): void { } public function test_saving_persists_with_capability_and_a_valid_nonce(): void { - $store = new PostMetaFieldStore(); + $store = new PostMetaFieldSurface(); $group = $this->group(); $store->register( $group, $this->placement() ); @@ -126,7 +126,7 @@ public function test_saving_persists_with_capability_and_a_valid_nonce(): void { } public function test_crud_addresses_the_same_meta_key_the_form_save_writes(): void { - $store = new PostMetaFieldStore(); + $store = new PostMetaFieldSurface(); $group = $this->group(); $store->register( $group, $this->placement() ); @@ -148,7 +148,7 @@ public function test_crud_addresses_the_same_meta_key_the_form_save_writes(): vo } public function test_saving_applies_the_builtin_default_sanitizer(): void { - $store = new PostMetaFieldStore(); + $store = new PostMetaFieldSurface(); $group = $this->group(); $raw = 'x'; $store->register( $group, $this->placement() ); @@ -163,7 +163,7 @@ public function test_saving_applies_the_builtin_default_sanitizer(): void { } public function test_saving_preserves_an_existing_value_when_a_present_submission_is_invalid(): void { - $store = new PostMetaFieldStore(); + $store = new PostMetaFieldSurface(); $group = $this->group_with( new SettingsField( id: 'color', type: 'select', label: 'Color', options: array( 'red' => 'Red' ) ), ); @@ -180,7 +180,7 @@ public function test_saving_preserves_an_existing_value_when_a_present_submissio } public function test_saving_is_skipped_without_a_valid_nonce(): void { - ( new PostMetaFieldStore() )->register( $this->group(), $this->placement() ); + ( new PostMetaFieldSurface() )->register( $this->group(), $this->placement() ); $_POST = array( self::GROUP_ID => array( 'note' => 'hi' ) ); \do_action( 'save_post_post', $this->post_id ); @@ -200,7 +200,7 @@ public function test_saving_is_skipped_for_a_user_without_the_edit_capability(): \wp_set_current_user( $subscriber ); $group = $this->group(); - ( new PostMetaFieldStore() )->register( $group, $this->placement() ); + ( new PostMetaFieldSurface() )->register( $group, $this->placement() ); $_POST = array( $this->nonce_name( $group ) => $this->nonce( $group ), @@ -215,7 +215,7 @@ public function test_saving_is_skipped_for_a_user_without_the_edit_capability(): } public function test_a_configured_box_capability_overrides_the_default(): void { - $store = new PostMetaFieldStore(); + $store = new PostMetaFieldSurface(); $placement = new MetaBoxPlacement( screen: 'post', context: 'side', priority: 'default', capability: 'dws_nonexistent_cap' ); $group = $this->group(); $store->register( $group, $placement ); @@ -232,7 +232,7 @@ public function test_a_configured_box_capability_overrides_the_default(): void { public function test_a_configured_capability_hides_the_box_on_render(): void { $placement = new MetaBoxPlacement( screen: 'post', context: 'side', priority: 'default', capability: 'dws_nonexistent_cap' ); - ( new PostMetaFieldStore() )->register( $this->group(), $placement ); + ( new PostMetaFieldSurface() )->register( $this->group(), $placement ); \do_action( 'add_meta_boxes_post', \get_post( $this->post_id ) ); // The administrator reaches the edit screen but lacks the configured capability, so the box is not added. diff --git a/packages/infrastructure/tests/Settings/Integration/MetaField/Stores/TermFieldStoreTest.php b/packages/infrastructure/tests/Settings/Integration/MetaField/Surfaces/TermFieldSurfaceTest.php similarity index 91% rename from packages/infrastructure/tests/Settings/Integration/MetaField/Stores/TermFieldStoreTest.php rename to packages/infrastructure/tests/Settings/Integration/MetaField/Surfaces/TermFieldSurfaceTest.php index ccf9440..88200b4 100644 --- a/packages/infrastructure/tests/Settings/Integration/MetaField/Stores/TermFieldStoreTest.php +++ b/packages/infrastructure/tests/Settings/Integration/MetaField/Surfaces/TermFieldSurfaceTest.php @@ -1,9 +1,9 @@ register( $this->term_group() ); + ( new TermFieldSurface() )->register( $this->term_group() ); \ob_start(); \do_action( 'category_edit_form_fields', \get_term( $this->term_id, 'category' ) ); @@ -87,7 +87,7 @@ public function test_editing_a_term_renders_the_nonce_and_control(): void { } public function test_adding_a_term_renders_the_nonce_and_control_in_add_form_markup(): void { - ( new TermFieldStore() )->register( $this->term_group() ); + ( new TermFieldSurface() )->register( $this->term_group() ); \ob_start(); \do_action( 'category_add_form_fields', 'category' ); @@ -100,7 +100,7 @@ public function test_adding_a_term_renders_the_nonce_and_control_in_add_form_mar } public function test_the_edit_row_binds_the_label_to_the_control_id(): void { - ( new TermFieldStore() )->register( $this->term_group() ); + ( new TermFieldSurface() )->register( $this->term_group() ); \ob_start(); \do_action( 'category_edit_form_fields', \get_term( $this->term_id, 'category' ) ); @@ -111,7 +111,7 @@ public function test_the_edit_row_binds_the_label_to_the_control_id(): void { } public function test_the_add_row_binds_the_label_to_the_control_id(): void { - ( new TermFieldStore() )->register( $this->term_group() ); + ( new TermFieldSurface() )->register( $this->term_group() ); \ob_start(); \do_action( 'category_add_form_fields', 'category' ); @@ -122,7 +122,7 @@ public function test_the_add_row_binds_the_label_to_the_control_id(): void { } public function test_the_hooks_are_registered_for_the_descriptor_taxonomy(): void { - ( new TermFieldStore() )->register( $this->term_group() ); + ( new TermFieldSurface() )->register( $this->term_group() ); self::assertNotFalse( \has_action( 'category_edit_form_fields' ) ); self::assertNotFalse( \has_action( 'edited_category' ) ); @@ -131,7 +131,7 @@ public function test_the_hooks_are_registered_for_the_descriptor_taxonomy(): voi } public function test_saving_persists_with_capability_and_a_valid_nonce(): void { - ( new TermFieldStore() )->register( $this->term_group() ); + ( new TermFieldSurface() )->register( $this->term_group() ); $_POST = array( self::NONCE_NAME => $this->nonce(), @@ -143,7 +143,7 @@ public function test_saving_persists_with_capability_and_a_valid_nonce(): void { } public function test_crud_addresses_the_same_meta_key_the_form_save_writes(): void { - $store = new TermFieldStore(); + $store = new TermFieldSurface(); $term_group = $this->term_group(); $group = $term_group->group; $store->register( $term_group ); @@ -166,7 +166,7 @@ public function test_crud_addresses_the_same_meta_key_the_form_save_writes(): vo } public function test_saving_a_created_term_persists_with_capability_and_a_valid_add_nonce(): void { - ( new TermFieldStore() )->register( $this->term_group() ); + ( new TermFieldSurface() )->register( $this->term_group() ); $_POST = array( self::NONCE_NAME => $this->nonce_for( 0 ), @@ -179,7 +179,7 @@ public function test_saving_a_created_term_persists_with_capability_and_a_valid_ public function test_saving_applies_the_builtin_default_sanitizer(): void { $raw = 'x'; - ( new TermFieldStore() )->register( $this->term_group() ); + ( new TermFieldSurface() )->register( $this->term_group() ); $_POST = array( self::NONCE_NAME => $this->nonce(), @@ -191,7 +191,7 @@ public function test_saving_applies_the_builtin_default_sanitizer(): void { } public function test_saving_preserves_an_existing_value_when_a_present_submission_is_invalid(): void { - ( new TermFieldStore() )->register( + ( new TermFieldSurface() )->register( $this->term_group_with( new SettingsField( id: 'color', type: 'select', label: 'Color', options: array( 'red' => 'Red' ) ), ), @@ -208,7 +208,7 @@ public function test_saving_preserves_an_existing_value_when_a_present_submissio } public function test_saving_is_skipped_without_a_valid_nonce(): void { - ( new TermFieldStore() )->register( $this->term_group() ); + ( new TermFieldSurface() )->register( $this->term_group() ); $_POST = array( self::GROUP_ID => array( 'color' => 'blue' ) ); \do_action( 'edited_category', $this->term_id ); @@ -227,7 +227,7 @@ public function test_saving_is_skipped_for_a_user_without_the_term_capability(): \assert( \is_int( $subscriber ) ); \wp_set_current_user( $subscriber ); - ( new TermFieldStore() )->register( $this->term_group() ); + ( new TermFieldSurface() )->register( $this->term_group() ); $_POST = array( self::NONCE_NAME => $this->nonce(), @@ -272,7 +272,7 @@ public function test_the_add_form_gates_on_edit_terms_not_manage_terms(): void { title: 'Split Cap Meta', fields_provider: static fn ( int $object_id ): array => array( $field ), ); - ( new TermFieldStore() )->register( new TermFieldGroup( group: $group, taxonomy: 'dws_split_cap_tax' ) ); + ( new TermFieldSurface() )->register( new TermFieldGroup( group: $group, taxonomy: 'dws_split_cap_tax' ) ); \ob_start(); \do_action( 'dws_split_cap_tax_add_form_fields', 'dws_split_cap_tax' ); @@ -304,7 +304,7 @@ public function test_rendering_is_skipped_for_a_user_without_the_term_capability \assert( \is_int( $subscriber ) ); \wp_set_current_user( $subscriber ); - ( new TermFieldStore() )->register( $this->term_group() ); + ( new TermFieldSurface() )->register( $this->term_group() ); \ob_start(); \do_action( 'category_edit_form_fields', \get_term( $this->term_id, 'category' ) ); diff --git a/packages/infrastructure/tests/Settings/Integration/MetaField/Stores/UserProfileFieldStoreTest.php b/packages/infrastructure/tests/Settings/Integration/MetaField/Surfaces/UserProfileFieldSurfaceTest.php similarity index 90% rename from packages/infrastructure/tests/Settings/Integration/MetaField/Stores/UserProfileFieldStoreTest.php rename to packages/infrastructure/tests/Settings/Integration/MetaField/Surfaces/UserProfileFieldSurfaceTest.php index 3a50dec..0c2e1c1 100644 --- a/packages/infrastructure/tests/Settings/Integration/MetaField/Stores/UserProfileFieldStoreTest.php +++ b/packages/infrastructure/tests/Settings/Integration/MetaField/Surfaces/UserProfileFieldSurfaceTest.php @@ -1,9 +1,9 @@ register( $this->profile() ); + ( new UserProfileFieldSurface() )->register( $this->profile() ); \ob_start(); \do_action( 'edit_user_profile', \get_userdata( $this->user_id ) ); @@ -101,7 +101,7 @@ public function test_editing_a_profile_renders_the_nonce_control_and_form_table( } public function test_the_profile_row_binds_the_label_to_the_control_id(): void { - ( new UserProfileFieldStore() )->register( $this->profile() ); + ( new UserProfileFieldSurface() )->register( $this->profile() ); \ob_start(); \do_action( 'edit_user_profile', \get_userdata( $this->user_id ) ); @@ -112,7 +112,7 @@ public function test_the_profile_row_binds_the_label_to_the_control_id(): void { } public function test_saving_persists_with_capability_and_a_valid_nonce(): void { - ( new UserProfileFieldStore() )->register( $this->profile() ); + ( new UserProfileFieldSurface() )->register( $this->profile() ); $_POST = array( self::NONCE_NAME => $this->nonce(), @@ -124,7 +124,7 @@ public function test_saving_persists_with_capability_and_a_valid_nonce(): void { } public function test_crud_addresses_the_same_meta_key_the_form_save_writes(): void { - $store = new UserProfileFieldStore(); + $store = new UserProfileFieldSurface(); $profile = $this->text_profile(); $group = $profile->group; $store->register( $profile ); @@ -148,7 +148,7 @@ public function test_crud_addresses_the_same_meta_key_the_form_save_writes(): vo public function test_saving_applies_the_builtin_default_sanitizer(): void { $raw = 'x'; - ( new UserProfileFieldStore() )->register( $this->text_profile() ); + ( new UserProfileFieldSurface() )->register( $this->text_profile() ); $_POST = array( self::NONCE_NAME => $this->nonce(), @@ -160,7 +160,7 @@ public function test_saving_applies_the_builtin_default_sanitizer(): void { } public function test_saving_preserves_an_existing_value_when_a_present_submission_is_invalid(): void { - ( new UserProfileFieldStore() )->register( + ( new UserProfileFieldSurface() )->register( $this->profile_with( new SettingsField( id: 'pref', type: 'select', label: 'Preference', options: array( 'red' => 'Red' ) ), ), @@ -177,7 +177,7 @@ public function test_saving_preserves_an_existing_value_when_a_present_submissio } public function test_saving_is_skipped_without_a_valid_nonce(): void { - ( new UserProfileFieldStore() )->register( $this->profile() ); + ( new UserProfileFieldSurface() )->register( $this->profile() ); $_POST = array( self::GROUP_ID => array( 'pref' => '1' ) ); \do_action( 'edit_user_profile_update', $this->user_id ); @@ -186,7 +186,7 @@ public function test_saving_is_skipped_without_a_valid_nonce(): void { } public function test_the_own_profile_surface_is_registered_by_default(): void { - ( new UserProfileFieldStore() )->register( $this->profile() ); + ( new UserProfileFieldSurface() )->register( $this->profile() ); self::assertNotFalse( \has_action( 'show_user_profile' ) ); self::assertNotFalse( \has_action( 'personal_options_update' ) ); @@ -194,7 +194,7 @@ public function test_the_own_profile_surface_is_registered_by_default(): void { public function test_the_own_profile_surface_is_omitted_when_restricted_to_admins(): void { $profile = new UserProfileFieldGroup( group: $this->group(), on_own_profile: false ); - ( new UserProfileFieldStore() )->register( $profile ); + ( new UserProfileFieldSurface() )->register( $profile ); self::assertFalse( \has_action( 'show_user_profile' ) ); self::assertFalse( \has_action( 'personal_options_update' ) ); @@ -213,7 +213,7 @@ public function test_saving_is_skipped_for_a_user_who_cannot_edit_the_target(): \assert( \is_int( $other ) ); \wp_set_current_user( $other ); - ( new UserProfileFieldStore() )->register( $this->profile() ); + ( new UserProfileFieldSurface() )->register( $this->profile() ); $_POST = array( self::NONCE_NAME => $this->nonce(), @@ -237,7 +237,7 @@ public function test_rendering_is_skipped_for_a_user_who_cannot_edit_the_target( \assert( \is_int( $other ) ); \wp_set_current_user( $other ); - ( new UserProfileFieldStore() )->register( $this->profile() ); + ( new UserProfileFieldSurface() )->register( $this->profile() ); \ob_start(); \do_action( 'edit_user_profile', \get_userdata( $this->user_id ) ); diff --git a/packages/infrastructure/tests/Settings/Integration/MetaField/Stores/index.php b/packages/infrastructure/tests/Settings/Integration/MetaField/Surfaces/index.php similarity index 100% rename from packages/infrastructure/tests/Settings/Integration/MetaField/Stores/index.php rename to packages/infrastructure/tests/Settings/Integration/MetaField/Surfaces/index.php diff --git a/packages/infrastructure/tests/Settings/Unit/MetaField/Stores/PostMetaFieldStoreTest.php b/packages/infrastructure/tests/Settings/Unit/MetaField/Surfaces/PostMetaFieldSurfaceTest.php similarity index 95% rename from packages/infrastructure/tests/Settings/Unit/MetaField/Stores/PostMetaFieldStoreTest.php rename to packages/infrastructure/tests/Settings/Unit/MetaField/Surfaces/PostMetaFieldSurfaceTest.php index d22a383..5f610ce 100644 --- a/packages/infrastructure/tests/Settings/Unit/MetaField/Stores/PostMetaFieldStoreTest.php +++ b/packages/infrastructure/tests/Settings/Unit/MetaField/Surfaces/PostMetaFieldSurfaceTest.php @@ -1,9 +1,9 @@ repository = new InMemoryObjectMetaRepository(); - $this->store = new PostMetaFieldStore( repository: $this->repository ); + $this->store = new PostMetaFieldSurface( repository: $this->repository ); } public function test_a_value_round_trips_under_the_field_id_when_no_meta_key_override_is_set(): void { diff --git a/packages/infrastructure/tests/Settings/Unit/MetaField/Stores/TermFieldStoreTest.php b/packages/infrastructure/tests/Settings/Unit/MetaField/Surfaces/TermFieldSurfaceTest.php similarity index 94% rename from packages/infrastructure/tests/Settings/Unit/MetaField/Stores/TermFieldStoreTest.php rename to packages/infrastructure/tests/Settings/Unit/MetaField/Surfaces/TermFieldSurfaceTest.php index 0270f30..ccf3c2e 100644 --- a/packages/infrastructure/tests/Settings/Unit/MetaField/Stores/TermFieldStoreTest.php +++ b/packages/infrastructure/tests/Settings/Unit/MetaField/Surfaces/TermFieldSurfaceTest.php @@ -1,9 +1,9 @@ repository = new InMemoryObjectMetaRepository(); - $this->store = new TermFieldStore( repository: $this->repository ); + $this->store = new TermFieldSurface( repository: $this->repository ); } public function test_a_value_round_trips_under_the_resolved_storage_key(): void { diff --git a/packages/infrastructure/tests/Settings/Unit/MetaField/Stores/UserProfileFieldStoreTest.php b/packages/infrastructure/tests/Settings/Unit/MetaField/Surfaces/UserProfileFieldSurfaceTest.php similarity index 93% rename from packages/infrastructure/tests/Settings/Unit/MetaField/Stores/UserProfileFieldStoreTest.php rename to packages/infrastructure/tests/Settings/Unit/MetaField/Surfaces/UserProfileFieldSurfaceTest.php index 1ad567b..5afbc73 100644 --- a/packages/infrastructure/tests/Settings/Unit/MetaField/Stores/UserProfileFieldStoreTest.php +++ b/packages/infrastructure/tests/Settings/Unit/MetaField/Surfaces/UserProfileFieldSurfaceTest.php @@ -1,9 +1,9 @@ repository = new InMemoryObjectMetaRepository(); - $this->store = new UserProfileFieldStore( repository: $this->repository ); + $this->store = new UserProfileFieldSurface( repository: $this->repository ); } public function test_a_value_round_trips_under_the_resolved_storage_key(): void { diff --git a/packages/infrastructure/tests/Settings/Unit/MetaField/Stores/index.php b/packages/infrastructure/tests/Settings/Unit/MetaField/Surfaces/index.php similarity index 100% rename from packages/infrastructure/tests/Settings/Unit/MetaField/Stores/index.php rename to packages/infrastructure/tests/Settings/Unit/MetaField/Surfaces/index.php diff --git a/packages/infrastructure/tests/Settings/Unit/OptionsResolverTest.php b/packages/infrastructure/tests/Settings/Unit/OptionsResolverTest.php index 6bf60f7..36bfe3c 100644 --- a/packages/infrastructure/tests/Settings/Unit/OptionsResolverTest.php +++ b/packages/infrastructure/tests/Settings/Unit/OptionsResolverTest.php @@ -4,7 +4,7 @@ use DeepWebSolutions\Framework\Settings\Schema\Exceptions\InvalidSettingsOptionsException; use DeepWebSolutions\Framework\Settings\Schema\Options\OptionsResolver; -use DeepWebSolutions\Framework\Settings\Schema\Options\SettingsOptionsProviderInterface; +use DeepWebSolutions\Framework\Settings\Schema\Options\OptionsProviderInterface; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -34,7 +34,7 @@ public function test_resolves_a_closure_source(): void { } public function test_resolves_a_provider_source(): void { - $provider = new class() implements SettingsOptionsProviderInterface { + $provider = new class() implements OptionsProviderInterface { public function get_options(): array { return array( 'a' => 'A' ); } @@ -50,7 +50,7 @@ public function test_all_three_sources_yield_the_same_resolved_set(): void { 'x' => 'X', 'y' => 'Y', ); - $provider = new class() implements SettingsOptionsProviderInterface { + $provider = new class() implements OptionsProviderInterface { public function get_options(): array { return array( 'x' => 'X', diff --git a/packages/infrastructure/tests/Settings/Unit/SettingsFieldTest.php b/packages/infrastructure/tests/Settings/Unit/SettingsFieldTest.php index c4f0486..9eee833 100644 --- a/packages/infrastructure/tests/Settings/Unit/SettingsFieldTest.php +++ b/packages/infrastructure/tests/Settings/Unit/SettingsFieldTest.php @@ -3,7 +3,7 @@ namespace DeepWebSolutions\Framework\Settings\Tests\Unit; use DeepWebSolutions\Framework\Settings\Schema\Exceptions\InvalidSettingsFieldException; -use DeepWebSolutions\Framework\Settings\Schema\Options\SettingsOptionsProviderInterface; +use DeepWebSolutions\Framework\Settings\Schema\Options\OptionsProviderInterface; use DeepWebSolutions\Framework\Settings\Schema\ValueObjects\SettingsField; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; @@ -109,7 +109,7 @@ public function test_options_accepts_a_closure(): void { } public function test_options_accepts_a_provider(): void { - $provider = new class() implements SettingsOptionsProviderInterface { + $provider = new class() implements OptionsProviderInterface { public function get_options(): array { return array( 'k' => 'V' ); } diff --git a/packages/woocommerce/src/Backend/DescriptorBackedWCSettingsPage.php b/packages/woocommerce/src/Backend/DescriptorBackedWooCommerceSettingsPage.php similarity index 98% rename from packages/woocommerce/src/Backend/DescriptorBackedWCSettingsPage.php rename to packages/woocommerce/src/Backend/DescriptorBackedWooCommerceSettingsPage.php index ffe0b28..bd4fda5 100644 --- a/packages/woocommerce/src/Backend/DescriptorBackedWCSettingsPage.php +++ b/packages/woocommerce/src/Backend/DescriptorBackedWooCommerceSettingsPage.php @@ -21,7 +21,7 @@ * @since 2.0.0 * @version 2.0.0 */ -abstract class DescriptorBackedWCSettingsPage extends \WC_Settings_Page { +abstract class DescriptorBackedWooCommerceSettingsPage extends \WC_Settings_Page { // region FIELDS AND CONSTANTS /** @@ -83,7 +83,7 @@ protected function get_settings_for_section_core( $section_id ): array { return array(); } - return ( new WCSettingsBuilder() )->build_section( $page, $section ); + return ( new WooCommerceSettingsBuilder() )->build_section( $page, $section ); } /** diff --git a/packages/woocommerce/src/Backend/WooCommerceSettingsBackend.php b/packages/woocommerce/src/Backend/WooCommerceSettingsBackend.php index 83d060f..739ae81 100644 --- a/packages/woocommerce/src/Backend/WooCommerceSettingsBackend.php +++ b/packages/woocommerce/src/Backend/WooCommerceSettingsBackend.php @@ -22,7 +22,7 @@ * checkbox is the string 'no'), never the boolean false WordPress cannot keep distinct from an absent * option. Each field's descriptor sanitizer, and its per-field capability gate, are bridged onto * WooCommerce's per-option sanitize filter. The tab is realized through a consumer-declared - * DescriptorBackedWCSettingsPage subclass, bound here so WooCommerce can rebuild it by class name across + * DescriptorBackedWooCommerceSettingsPage subclass, bound here so WooCommerce can rebuild it by class name across * requests. * * @since 2.0.0 @@ -71,7 +71,7 @@ final class WooCommerceSettingsBackend implements SettingsBackendInterface { * @since 2.0.0 * @version 2.0.0 * - * @param class-string $page_class Consumer subclass that renders the page as a WooCommerce tab. + * @param class-string $page_class Consumer subclass that renders the page as a WooCommerce tab. */ public function __construct( protected string $page_class, @@ -104,7 +104,7 @@ public function register_page( SettingsPage $page ): void { \add_filter( 'woocommerce_get_settings_pages', function ( array $pages ) use ( $page ): array { - DescriptorBackedWCSettingsPage::bind( $this->page_class, $page ); + DescriptorBackedWooCommerceSettingsPage::bind( $this->page_class, $page ); $pages[] = new $this->page_class(); return $pages; diff --git a/packages/woocommerce/src/Backend/WCSettingsBuilder.php b/packages/woocommerce/src/Backend/WooCommerceSettingsBuilder.php similarity index 99% rename from packages/woocommerce/src/Backend/WCSettingsBuilder.php rename to packages/woocommerce/src/Backend/WooCommerceSettingsBuilder.php index a43af7d..dbd7cf2 100644 --- a/packages/woocommerce/src/Backend/WCSettingsBuilder.php +++ b/packages/woocommerce/src/Backend/WooCommerceSettingsBuilder.php @@ -23,7 +23,7 @@ * @since 2.0.0 * @version 2.0.0 */ -final readonly class WCSettingsBuilder { +final readonly class WooCommerceSettingsBuilder { // region MAGIC METHODS /** diff --git a/packages/woocommerce/src/OrderData/OrderFieldStore.php b/packages/woocommerce/src/OrderData/OrderFieldSurface.php similarity index 97% rename from packages/woocommerce/src/OrderData/OrderFieldStore.php rename to packages/woocommerce/src/OrderData/OrderFieldSurface.php index ff62d92..5e9515a 100644 --- a/packages/woocommerce/src/OrderData/OrderFieldStore.php +++ b/packages/woocommerce/src/OrderData/OrderFieldSurface.php @@ -17,7 +17,7 @@ use function DeepWebSolutions\Framework\Settings\Schema\field_label_html; /** - * Registers a field group as a WooCommerce-order meta box and stores its fields as order meta. + * Surface that mounts a field group onto the WooCommerce order edit screen as a meta box and stores its fields as order meta. * * Resolves the order edit screen at registration — the legacy post screen or, under HPOS, the orders * page (and the admin.php variant WooCommerce uses for a user who cannot see the WooCommerce menu) — so @@ -26,7 +26,7 @@ * configured box capability, defaulting to WooCommerce's own order-edit check: the order's edit * capability, or manage_woocommerce. * - * Beyond registration, the store exposes field-addressed CRUD over the same storage keys and value + * Beyond registration, the surface exposes field-addressed CRUD over the same storage keys and value * semantics the form path applies — get/set/has/delete by group and field id — plus meta_keys() for the * consumer's uninstall cleanup. Object fields are revoke-based, so reads never fall back to the field's * declared default. @@ -37,7 +37,7 @@ * @since 2.0.0 * @version 2.0.0 */ -final class OrderFieldStore { +final class OrderFieldSurface { // region FIELDS AND CONSTANTS /** @@ -303,7 +303,7 @@ public function save_boxes( int $object_id ): void { protected function resolve_screens( string $screen ): array { if ( self::ORDER_SCREEN !== $screen ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- framework-internal exception; never reaches an HTML output context unescaped. - throw new UnsupportedOrderScreenException( "OrderFieldStore registers meta boxes on the WooCommerce order screen ('shop_order') only; got '$screen'." ); + throw new UnsupportedOrderScreenException( "OrderFieldSurface registers meta boxes on the WooCommerce order screen ('shop_order') only; got '$screen'." ); } if ( ! OrderUtil::custom_orders_table_usage_is_enabled() ) { return array( self::ORDER_SCREEN ); diff --git a/packages/woocommerce/src/ProductData/ProductDataFieldStore.php b/packages/woocommerce/src/ProductData/ProductDataFieldSurface.php similarity index 98% rename from packages/woocommerce/src/ProductData/ProductDataFieldStore.php rename to packages/woocommerce/src/ProductData/ProductDataFieldSurface.php index 21bd255..6feebe8 100644 --- a/packages/woocommerce/src/ProductData/ProductDataFieldStore.php +++ b/packages/woocommerce/src/ProductData/ProductDataFieldSurface.php @@ -17,9 +17,9 @@ use function DeepWebSolutions\Framework\WooCommerce\to_yes_no; /** - * Registers a WooCommerce product-data settings tab and persists its fields as product meta. + * Surface that mounts a WooCommerce product-data settings tab and persists its fields as product meta. * - * One store drives one tab. register_tab() wires WooCommerce's three product hooks — add the tab, render + * One surface drives one tab. register_tab() wires WooCommerce's three product hooks — add the tab, render * its panel, save it — plus the two default-metadata filters that make a product predating a field render * its descriptor default instead of a blank. Rendering uses native woocommerce_wp_* controls; saving is * framework-owned (WooCommerce verifies the product-edit nonce and capability before its save hook fires). @@ -28,7 +28,7 @@ * @since 2.0.0 * @version 2.0.0 */ -final class ProductDataFieldStore { +final class ProductDataFieldSurface { // region FIELDS AND CONSTANTS /** diff --git a/packages/woocommerce/tests/Integration/DescriptorBackedWCSettingsPageTest.php b/packages/woocommerce/tests/Integration/DescriptorBackedWooCommerceSettingsPageTest.php similarity index 73% rename from packages/woocommerce/tests/Integration/DescriptorBackedWCSettingsPageTest.php rename to packages/woocommerce/tests/Integration/DescriptorBackedWooCommerceSettingsPageTest.php index e098aff..9ba6b54 100644 --- a/packages/woocommerce/tests/Integration/DescriptorBackedWCSettingsPageTest.php +++ b/packages/woocommerce/tests/Integration/DescriptorBackedWooCommerceSettingsPageTest.php @@ -7,16 +7,16 @@ use DeepWebSolutions\Framework\Settings\Schema\ValueObjects\SettingsField; use DeepWebSolutions\Framework\Settings\Schema\ValueObjects\SettingsPage; use DeepWebSolutions\Framework\Settings\Schema\ValueObjects\SettingsSection; -use DeepWebSolutions\Framework\WooCommerce\Backend\DescriptorBackedWCSettingsPage; +use DeepWebSolutions\Framework\WooCommerce\Backend\DescriptorBackedWooCommerceSettingsPage; use DeepWebSolutions\Framework\WooCommerce\Backend\Exceptions\UnboundSettingsPageException; -use DeepWebSolutions\Framework\WooCommerce\Tests\Integration\Fixtures\BarWCSettingsPage; -use DeepWebSolutions\Framework\WooCommerce\Tests\Integration\Fixtures\FooWCSettingsPage; -use DeepWebSolutions\Framework\WooCommerce\Tests\Integration\Fixtures\UnboundWCSettingsPage; +use DeepWebSolutions\Framework\WooCommerce\Tests\Integration\Fixtures\BarWooCommerceSettingsPage; +use DeepWebSolutions\Framework\WooCommerce\Tests\Integration\Fixtures\FooWooCommerceSettingsPage; +use DeepWebSolutions\Framework\WooCommerce\Tests\Integration\Fixtures\UnboundWooCommerceSettingsPage; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -#[CoversClass( DescriptorBackedWCSettingsPage::class )] -final class DescriptorBackedWCSettingsPageTest extends TestCase { +#[CoversClass( DescriptorBackedWooCommerceSettingsPage::class )] +final class DescriptorBackedWooCommerceSettingsPageTest extends TestCase { protected function setUp(): void { parent::setUp(); @@ -29,45 +29,45 @@ protected function setUp(): void { protected function tearDown(): void { // The static descriptor map persists across the process; clear it so a class bound in one test // cannot leak into another (e.g. silently satisfying the unbound-instantiation guard test). - $descriptors = new \ReflectionProperty( DescriptorBackedWCSettingsPage::class, 'descriptors' ); + $descriptors = new \ReflectionProperty( DescriptorBackedWooCommerceSettingsPage::class, 'descriptors' ); $descriptors->setValue( null, array() ); parent::tearDown(); } public function test_recovers_its_descriptor_id_and_label_from_the_static_map(): void { - DescriptorBackedWCSettingsPage::bind( FooWCSettingsPage::class, $this->page( 'dws-foo', 'dws_foo', 'Foo' ) ); + DescriptorBackedWooCommerceSettingsPage::bind( FooWooCommerceSettingsPage::class, $this->page( 'dws-foo', 'dws_foo', 'Foo' ) ); - $page = new FooWCSettingsPage(); + $page = new FooWooCommerceSettingsPage(); self::assertSame( 'dws_foo', $page->get_id() ); self::assertSame( 'Foo', $page->get_label() ); } public function test_falls_back_to_the_slug_when_the_descriptor_has_no_location(): void { - DescriptorBackedWCSettingsPage::bind( - FooWCSettingsPage::class, + DescriptorBackedWooCommerceSettingsPage::bind( + FooWooCommerceSettingsPage::class, new SettingsPage( slug: 'dws-foo', page_title: 'Foo', menu_title: 'Foo', capability: 'manage_woocommerce' ), ); - self::assertSame( 'dws-foo', ( new FooWCSettingsPage() )->get_id() ); + self::assertSame( 'dws-foo', ( new FooWooCommerceSettingsPage() )->get_id() ); } public function test_the_tab_id_is_sanitized_for_woocommerce_routing(): void { - DescriptorBackedWCSettingsPage::bind( - FooWCSettingsPage::class, + DescriptorBackedWooCommerceSettingsPage::bind( + FooWooCommerceSettingsPage::class, new SettingsPage( slug: 'dws-foo', page_title: 'Foo', menu_title: 'Foo', capability: 'manage_woocommerce', location: 'DWS Foo' ), ); // WooCommerce routes the settings screen by sanitize_title($_GET['tab']) but the page registers its // output/save hooks against $this->id, so a non-slug-stable id neither renders nor saves. - self::assertSame( 'dws-foo', ( new FooWCSettingsPage() )->get_id() ); + self::assertSame( 'dws-foo', ( new FooWooCommerceSettingsPage() )->get_id() ); } public function test_builds_its_section_settings_from_the_descriptor(): void { - DescriptorBackedWCSettingsPage::bind( FooWCSettingsPage::class, $this->page( 'dws-foo', 'dws_foo', 'Foo' ) ); + DescriptorBackedWooCommerceSettingsPage::bind( FooWooCommerceSettingsPage::class, $this->page( 'dws-foo', 'dws_foo', 'Foo' ) ); - $settings = ( new FooWCSettingsPage() )->get_settings_for_section( '' ); + $settings = ( new FooWooCommerceSettingsPage() )->get_settings_for_section( '' ); $ids = \array_column( $settings, 'id' ); self::assertContains( 'dws-foo_general', $ids ); @@ -75,10 +75,10 @@ public function test_builds_its_section_settings_from_the_descriptor(): void { } public function test_maps_the_first_section_to_the_default_and_later_sections_to_their_slug(): void { - DescriptorBackedWCSettingsPage::bind( FooWCSettingsPage::class, $this->multi_section_page() ); + DescriptorBackedWooCommerceSettingsPage::bind( FooWooCommerceSettingsPage::class, $this->multi_section_page() ); // get_sections() (public) returns the filtered get_own_sections() map. - $sections = ( new FooWCSettingsPage() )->get_sections(); + $sections = ( new FooWooCommerceSettingsPage() )->get_sections(); self::assertSame( 'General', $sections[''] ?? null ); self::assertSame( 'Advanced', $sections['advanced'] ?? null ); @@ -86,9 +86,9 @@ public function test_maps_the_first_section_to_the_default_and_later_sections_to } public function test_routes_a_later_section_to_its_own_fields(): void { - DescriptorBackedWCSettingsPage::bind( FooWCSettingsPage::class, $this->multi_section_page() ); + DescriptorBackedWooCommerceSettingsPage::bind( FooWooCommerceSettingsPage::class, $this->multi_section_page() ); - $settings = ( new FooWCSettingsPage() )->get_settings_for_section( 'advanced' ); + $settings = ( new FooWooCommerceSettingsPage() )->get_settings_for_section( 'advanced' ); $ids = \array_column( $settings, 'id' ); self::assertContains( 'dws-foo_advanced', $ids ); @@ -97,9 +97,9 @@ public function test_routes_a_later_section_to_its_own_fields(): void { } public function test_routes_a_later_section_by_woocommerces_sanitized_section_id(): void { - DescriptorBackedWCSettingsPage::bind( FooWCSettingsPage::class, $this->unstable_section_page() ); + DescriptorBackedWooCommerceSettingsPage::bind( FooWooCommerceSettingsPage::class, $this->unstable_section_page() ); - $page = new FooWCSettingsPage(); + $page = new FooWooCommerceSettingsPage(); $sections = $page->get_sections(); $settings = $page->get_settings_for_section( 'advanced' ); $ids = \array_column( $settings, 'id' ); @@ -110,19 +110,19 @@ public function test_routes_a_later_section_by_woocommerces_sanitized_section_id } public function test_two_distinct_subclasses_recover_their_own_descriptors(): void { - DescriptorBackedWCSettingsPage::bind( FooWCSettingsPage::class, $this->page( 'dws-foo', 'dws_foo', 'Foo' ) ); - DescriptorBackedWCSettingsPage::bind( BarWCSettingsPage::class, $this->page( 'dws-bar', 'dws_bar', 'Bar' ) ); + DescriptorBackedWooCommerceSettingsPage::bind( FooWooCommerceSettingsPage::class, $this->page( 'dws-foo', 'dws_foo', 'Foo' ) ); + DescriptorBackedWooCommerceSettingsPage::bind( BarWooCommerceSettingsPage::class, $this->page( 'dws-bar', 'dws_bar', 'Bar' ) ); - self::assertSame( 'dws_foo', ( new FooWCSettingsPage() )->get_id() ); - self::assertSame( 'dws_bar', ( new BarWCSettingsPage() )->get_id() ); + self::assertSame( 'dws_foo', ( new FooWooCommerceSettingsPage() )->get_id() ); + self::assertSame( 'dws_bar', ( new BarWooCommerceSettingsPage() )->get_id() ); } public function test_a_rebuilt_instance_recovers_the_same_descriptor(): void { - DescriptorBackedWCSettingsPage::bind( FooWCSettingsPage::class, $this->page( 'dws-foo', 'dws_foo', 'Foo' ) ); + DescriptorBackedWooCommerceSettingsPage::bind( FooWooCommerceSettingsPage::class, $this->page( 'dws-foo', 'dws_foo', 'Foo' ) ); // WooCommerce rebuilds the page object on each request; a fresh instance must still resolve its descriptor. - $first = new FooWCSettingsPage(); - $second = new FooWCSettingsPage(); + $first = new FooWooCommerceSettingsPage(); + $second = new FooWooCommerceSettingsPage(); self::assertSame( $first->get_id(), $second->get_id() ); self::assertSame( 'Foo', $second->get_label() ); @@ -131,13 +131,13 @@ public function test_a_rebuilt_instance_recovers_the_same_descriptor(): void { public function test_instantiating_an_unbound_page_class_throws(): void { $this->expectException( UnboundSettingsPageException::class ); - new UnboundWCSettingsPage(); + new UnboundWooCommerceSettingsPage(); } public function test_the_first_section_matches_only_the_default_token(): void { - DescriptorBackedWCSettingsPage::bind( FooWCSettingsPage::class, $this->multi_section_page() ); + DescriptorBackedWooCommerceSettingsPage::bind( FooWooCommerceSettingsPage::class, $this->multi_section_page() ); - $page = new FooWCSettingsPage(); + $page = new FooWooCommerceSettingsPage(); // The first section's fields answer to WooCommerce's default token, not to the // section's own sanitized id — otherwise it could shadow a later section. @@ -174,7 +174,7 @@ public function test_binding_two_sections_colliding_on_the_sanitized_id_throws() $this->expectException( DuplicateSettingsSectionException::class ); $this->expectExceptionMessage( 'advanced' ); - DescriptorBackedWCSettingsPage::bind( FooWCSettingsPage::class, $page ); + DescriptorBackedWooCommerceSettingsPage::bind( FooWooCommerceSettingsPage::class, $page ); } public function test_binding_a_section_whose_id_sanitizes_to_the_default_token_throws(): void { @@ -186,7 +186,7 @@ public function test_binding_a_section_whose_id_sanitizes_to_the_default_token_t try { $this->expectException( InvalidSettingsSectionException::class ); - DescriptorBackedWCSettingsPage::bind( FooWCSettingsPage::class, $this->multi_section_page() ); + DescriptorBackedWooCommerceSettingsPage::bind( FooWooCommerceSettingsPage::class, $this->multi_section_page() ); } finally { \remove_filter( 'sanitize_title', $force_empty ); } diff --git a/packages/woocommerce/tests/Integration/Fixtures/BarWCSettingsPage.php b/packages/woocommerce/tests/Integration/Fixtures/BarWooCommerceSettingsPage.php similarity index 70% rename from packages/woocommerce/tests/Integration/Fixtures/BarWCSettingsPage.php rename to packages/woocommerce/tests/Integration/Fixtures/BarWooCommerceSettingsPage.php index 105d8c8..c0c29cf 100644 --- a/packages/woocommerce/tests/Integration/Fixtures/BarWCSettingsPage.php +++ b/packages/woocommerce/tests/Integration/Fixtures/BarWooCommerceSettingsPage.php @@ -2,9 +2,9 @@ namespace DeepWebSolutions\Framework\WooCommerce\Tests\Integration\Fixtures; -use DeepWebSolutions\Framework\WooCommerce\Backend\DescriptorBackedWCSettingsPage; +use DeepWebSolutions\Framework\WooCommerce\Backend\DescriptorBackedWooCommerceSettingsPage; /** * A second distinct settings-page subclass standing in for another plugin's coexisting page. */ -final class BarWCSettingsPage extends DescriptorBackedWCSettingsPage {} +final class BarWooCommerceSettingsPage extends DescriptorBackedWooCommerceSettingsPage {} diff --git a/packages/woocommerce/tests/Integration/Fixtures/FooWCSettingsPage.php b/packages/woocommerce/tests/Integration/Fixtures/FooWooCommerceSettingsPage.php similarity index 70% rename from packages/woocommerce/tests/Integration/Fixtures/FooWCSettingsPage.php rename to packages/woocommerce/tests/Integration/Fixtures/FooWooCommerceSettingsPage.php index f827ab2..f751f7b 100644 --- a/packages/woocommerce/tests/Integration/Fixtures/FooWCSettingsPage.php +++ b/packages/woocommerce/tests/Integration/Fixtures/FooWooCommerceSettingsPage.php @@ -2,9 +2,9 @@ namespace DeepWebSolutions\Framework\WooCommerce\Tests\Integration\Fixtures; -use DeepWebSolutions\Framework\WooCommerce\Backend\DescriptorBackedWCSettingsPage; +use DeepWebSolutions\Framework\WooCommerce\Backend\DescriptorBackedWooCommerceSettingsPage; /** * A distinct, REST-safe settings-page subclass standing in for one consumer plugin's page. */ -final class FooWCSettingsPage extends DescriptorBackedWCSettingsPage {} +final class FooWooCommerceSettingsPage extends DescriptorBackedWooCommerceSettingsPage {} diff --git a/packages/woocommerce/tests/Integration/Fixtures/LazyBindWCSettingsPage.php b/packages/woocommerce/tests/Integration/Fixtures/LazyBindWooCommerceSettingsPage.php similarity index 70% rename from packages/woocommerce/tests/Integration/Fixtures/LazyBindWCSettingsPage.php rename to packages/woocommerce/tests/Integration/Fixtures/LazyBindWooCommerceSettingsPage.php index 5f322db..79e7b82 100644 --- a/packages/woocommerce/tests/Integration/Fixtures/LazyBindWCSettingsPage.php +++ b/packages/woocommerce/tests/Integration/Fixtures/LazyBindWooCommerceSettingsPage.php @@ -2,9 +2,9 @@ namespace DeepWebSolutions\Framework\WooCommerce\Tests\Integration\Fixtures; -use DeepWebSolutions\Framework\WooCommerce\Backend\DescriptorBackedWCSettingsPage; +use DeepWebSolutions\Framework\WooCommerce\Backend\DescriptorBackedWooCommerceSettingsPage; /** * A subclass bound by no other test, used to prove register_page() defers binding until the filter fires. */ -final class LazyBindWCSettingsPage extends DescriptorBackedWCSettingsPage {} +final class LazyBindWooCommerceSettingsPage extends DescriptorBackedWooCommerceSettingsPage {} diff --git a/packages/woocommerce/tests/Integration/Fixtures/UnboundWCSettingsPage.php b/packages/woocommerce/tests/Integration/Fixtures/UnboundWooCommerceSettingsPage.php similarity index 70% rename from packages/woocommerce/tests/Integration/Fixtures/UnboundWCSettingsPage.php rename to packages/woocommerce/tests/Integration/Fixtures/UnboundWooCommerceSettingsPage.php index aa8899d..24bc86f 100644 --- a/packages/woocommerce/tests/Integration/Fixtures/UnboundWCSettingsPage.php +++ b/packages/woocommerce/tests/Integration/Fixtures/UnboundWooCommerceSettingsPage.php @@ -2,9 +2,9 @@ namespace DeepWebSolutions\Framework\WooCommerce\Tests\Integration\Fixtures; -use DeepWebSolutions\Framework\WooCommerce\Backend\DescriptorBackedWCSettingsPage; +use DeepWebSolutions\Framework\WooCommerce\Backend\DescriptorBackedWooCommerceSettingsPage; /** * A subclass deliberately never bound to a descriptor, to exercise the unbound-instantiation guard. */ -final class UnboundWCSettingsPage extends DescriptorBackedWCSettingsPage {} +final class UnboundWooCommerceSettingsPage extends DescriptorBackedWooCommerceSettingsPage {} diff --git a/packages/woocommerce/tests/Integration/OrderData/OrderFieldStoreTest.php b/packages/woocommerce/tests/Integration/OrderData/OrderFieldSurfaceTest.php similarity index 93% rename from packages/woocommerce/tests/Integration/OrderData/OrderFieldStoreTest.php rename to packages/woocommerce/tests/Integration/OrderData/OrderFieldSurfaceTest.php index 3237a13..0cf1f90 100644 --- a/packages/woocommerce/tests/Integration/OrderData/OrderFieldStoreTest.php +++ b/packages/woocommerce/tests/Integration/OrderData/OrderFieldSurfaceTest.php @@ -14,13 +14,13 @@ use DeepWebSolutions\Framework\Settings\Schema\ValueObjects\CustomFieldType; use DeepWebSolutions\Framework\Settings\Schema\ValueObjects\SettingsField; use DeepWebSolutions\Framework\WooCommerce\OrderData\Exceptions\UnsupportedOrderScreenException; -use DeepWebSolutions\Framework\WooCommerce\OrderData\OrderFieldStore; +use DeepWebSolutions\Framework\WooCommerce\OrderData\OrderFieldSurface; use DeepWebSolutions\Framework\WooCommerce\OrderData\OrderMetaRepository; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; -#[CoversClass( OrderFieldStore::class )] +#[CoversClass( OrderFieldSurface::class )] #[UsesClass( ObjectFieldForm::class )] #[UsesClass( OrderMetaRepository::class )] #[UsesClass( FieldGroup::class )] @@ -31,7 +31,7 @@ #[UsesClass( OptionsResolver::class )] #[UsesClass( FieldType::class )] #[UsesClass( CustomFieldType::class )] -final class OrderFieldStoreTest extends TestCase { +final class OrderFieldSurfaceTest extends TestCase { private const GROUP_ID = 'dws_unlock'; private const ISOLATED_HOOKS = array( @@ -104,7 +104,7 @@ public function test_register_adds_the_box_on_the_resolved_order_screen(): void $screen = $this->order_screen(); \set_current_screen( $screen ); - ( new OrderFieldStore() )->register( $this->group(), $this->placement() ); + ( new OrderFieldSurface() )->register( $this->group(), $this->placement() ); \do_action( "add_meta_boxes_$screen", \wc_get_order( $this->order_id ) ); self::assertArrayHasKey( self::GROUP_ID, $this->boxes_on( $screen ) ); @@ -119,7 +119,7 @@ public function test_register_adds_the_box_on_the_resolved_order_screen(): void public function test_register_targets_the_legacy_post_screen_when_hpos_is_disabled(): void { \add_filter( 'option_woocommerce_custom_orders_table_enabled', static fn (): string => 'no' ); - ( new OrderFieldStore() )->register( $this->group(), $this->placement() ); + ( new OrderFieldSurface() )->register( $this->group(), $this->placement() ); self::assertNotFalse( \has_action( 'add_meta_boxes_shop_order' ) ); self::assertFalse( \has_action( 'add_meta_boxes_woocommerce_page_wc-orders' ) ); @@ -129,7 +129,7 @@ public function test_register_targets_the_legacy_post_screen_when_hpos_is_disabl public function test_register_targets_both_hpos_screens_when_hpos_is_enabled(): void { \add_filter( 'option_woocommerce_custom_orders_table_enabled', static fn (): string => 'yes' ); - ( new OrderFieldStore() )->register( $this->group(), $this->placement() ); + ( new OrderFieldSurface() )->register( $this->group(), $this->placement() ); self::assertNotFalse( \has_action( 'add_meta_boxes_woocommerce_page_wc-orders' ) ); self::assertNotFalse( \has_action( 'add_meta_boxes_admin_page_wc-orders' ) ); @@ -140,7 +140,7 @@ public function test_the_registered_box_renders_a_nonce_and_its_field_control(): $screen = $this->order_screen(); \set_current_screen( $screen ); - ( new OrderFieldStore() )->register( $this->group(), $this->placement() ); + ( new OrderFieldSurface() )->register( $this->group(), $this->placement() ); \do_action( "add_meta_boxes_$screen", \wc_get_order( $this->order_id ) ); $html = $this->render_box( $screen ); @@ -153,7 +153,7 @@ public function test_the_box_row_binds_the_label_to_the_control_id(): void { $screen = $this->order_screen(); \set_current_screen( $screen ); - ( new OrderFieldStore() )->register( $this->group(), $this->placement() ); + ( new OrderFieldSurface() )->register( $this->group(), $this->placement() ); \do_action( "add_meta_boxes_$screen", \wc_get_order( $this->order_id ) ); $html = $this->render_box( $screen ); @@ -176,7 +176,7 @@ public function test_a_bespoke_render_and_save_group_emits_the_nonce_and_saves() $saved_for = $object_id; }, ); - $store = new OrderFieldStore(); + $store = new OrderFieldSurface(); $store->register( $group, $this->placement() ); \do_action( "add_meta_boxes_$screen", \wc_get_order( $this->order_id ) ); @@ -191,7 +191,7 @@ public function test_a_bespoke_render_and_save_group_emits_the_nonce_and_saves() public function test_a_truthy_submission_is_stored_and_a_falsy_one_deletes_the_meta(): void { $repo = new OrderMetaRepository(); - $store = new OrderFieldStore(); + $store = new OrderFieldSurface(); $store->register( $this->group(), $this->placement() ); $_POST = array( @@ -207,7 +207,7 @@ public function test_a_truthy_submission_is_stored_and_a_falsy_one_deletes_the_m } public function test_crud_addresses_the_same_meta_key_the_form_save_writes(): void { - $store = new OrderFieldStore(); + $store = new OrderFieldSurface(); $group = $this->group_with( new SettingsField( id: 'note', type: 'text', label: 'Note' ) ); $store->register( $group, $this->placement() ); @@ -232,7 +232,7 @@ public function test_crud_addresses_the_same_meta_key_the_form_save_writes(): vo public function test_a_zero_value_is_stored_not_revoked(): void { $repo = new OrderMetaRepository(); - $store = new OrderFieldStore(); + $store = new OrderFieldSurface(); $store->register( $this->group_with( new SettingsField( id: 'note', type: 'text', label: 'Note' ) ), $this->placement() ); $_POST = array( @@ -247,7 +247,7 @@ public function test_a_zero_value_is_stored_not_revoked(): void { public function test_save_applies_the_builtin_default_sanitizer(): void { $repo = new OrderMetaRepository(); - $store = new OrderFieldStore(); + $store = new OrderFieldSurface(); $raw = 'x'; $store->register( $this->group_with( new SettingsField( id: 'note', type: 'text', label: 'Note' ) ), $this->placement() ); @@ -262,7 +262,7 @@ public function test_save_applies_the_builtin_default_sanitizer(): void { public function test_save_preserves_an_existing_value_when_a_present_submission_is_invalid(): void { $repo = new OrderMetaRepository(); - $store = new OrderFieldStore(); + $store = new OrderFieldSurface(); $store->register( $this->group_with( new SettingsField( id: 'status', type: 'select', label: 'Status', options: array( 'locked' => 'Locked' ) ), @@ -282,7 +282,7 @@ public function test_save_preserves_an_existing_value_when_a_present_submission_ public function test_save_is_skipped_without_a_valid_nonce(): void { $repo = new OrderMetaRepository(); - $store = new OrderFieldStore(); + $store = new OrderFieldSurface(); $store->register( $this->group(), $this->placement() ); $_POST = array( self::GROUP_ID => array( 'unlocked' => '1' ) ); @@ -297,7 +297,7 @@ public function test_register_also_registers_on_the_restricted_hpos_screen(): vo } \set_current_screen( 'admin_page_wc-orders' ); - ( new OrderFieldStore() )->register( $this->group(), $this->placement() ); + ( new OrderFieldSurface() )->register( $this->group(), $this->placement() ); \do_action( 'add_meta_boxes_admin_page_wc-orders', \wc_get_order( $this->order_id ) ); self::assertArrayHasKey( self::GROUP_ID, $this->boxes_on( 'admin_page_wc-orders' ) ); @@ -305,7 +305,7 @@ public function test_register_also_registers_on_the_restricted_hpos_screen(): vo public function test_a_field_meta_key_overrides_the_id_for_storage(): void { $repo = new OrderMetaRepository(); - $store = new OrderFieldStore(); + $store = new OrderFieldSurface(); $store->register( $this->group_with( new SettingsField( id: 'unlocked', type: 'checkbox', label: 'Unlocked', meta_key: '_lpm_unlocked' ) ), $this->placement(), @@ -333,7 +333,7 @@ public function test_save_is_skipped_for_a_user_without_the_order_capability(): \wp_set_current_user( $subscriber ); $repo = new OrderMetaRepository(); - $store = new OrderFieldStore(); + $store = new OrderFieldSurface(); $store->register( $this->group(), $this->placement() ); $_POST = array( @@ -349,7 +349,7 @@ public function test_save_is_skipped_for_a_user_without_the_order_capability(): public function test_a_configured_box_capability_overrides_the_default(): void { $repo = new OrderMetaRepository(); $placement = new MetaBoxPlacement( screen: 'shop_order', context: 'side', priority: 'default', capability: 'dws_nonexistent_cap' ); - $store = new OrderFieldStore(); + $store = new OrderFieldSurface(); $store->register( $this->group(), $placement ); // The administrator passes the default order-edit gate but lacks the configured capability, so the save is refused. @@ -376,7 +376,7 @@ public function test_the_box_is_not_added_for_a_user_who_cannot_edit_the_order() \assert( \is_int( $subscriber ) ); \wp_set_current_user( $subscriber ); - ( new OrderFieldStore() )->register( $this->group(), $this->placement() ); + ( new OrderFieldSurface() )->register( $this->group(), $this->placement() ); \do_action( "add_meta_boxes_$screen", \wc_get_order( $this->order_id ) ); self::assertArrayNotHasKey( self::GROUP_ID, $this->boxes_on( $screen ) ); @@ -386,7 +386,7 @@ public function test_the_box_is_not_added_for_a_user_who_cannot_edit_the_order() public function test_a_multi_field_save_persists_the_order_once(): void { $repo = new OrderMetaRepository(); - $store = new OrderFieldStore(); + $store = new OrderFieldSurface(); $store->register( new FieldGroup( id: self::GROUP_ID, @@ -422,7 +422,7 @@ static function () use ( &$saves ): void { } public function test_a_no_op_save_does_not_persist_the_order(): void { - $store = new OrderFieldStore(); + $store = new OrderFieldSurface(); $store->register( $this->group(), $this->placement() ); $saves = 0; @@ -441,7 +441,7 @@ static function () use ( &$saves ): void { } public function test_a_duplicate_field_id_in_a_group_is_rejected(): void { - $store = new OrderFieldStore(); + $store = new OrderFieldSurface(); $store->register( new FieldGroup( id: self::GROUP_ID, @@ -468,7 +468,7 @@ public function test_register_rejects_a_non_order_screen(): void { $this->expectException( UnsupportedOrderScreenException::class ); - ( new OrderFieldStore() )->register( $this->group(), $placement ); + ( new OrderFieldSurface() )->register( $this->group(), $placement ); } public function test_a_group_title_is_escaped_before_registration(): void { @@ -480,7 +480,7 @@ public function test_a_group_title_is_escaped_before_registration(): void { title: '', fields_provider: static fn ( int $object_id ): array => array(), ); - ( new OrderFieldStore() )->register( $group, $this->placement() ); + ( new OrderFieldSurface() )->register( $group, $this->placement() ); \do_action( "add_meta_boxes_$screen", \wc_get_order( $this->order_id ) ); $title = (string) ( ( (array) ( $this->boxes_on( $screen )[ self::GROUP_ID ] ?? array() ) )['title'] ?? '' ); @@ -504,7 +504,7 @@ public function test_a_custom_field_type_renders_and_saves_through_the_order_sur ); $group = $this->group_with( new SettingsField( id: 'home_page', type: 'single_select_page', label: 'Home Page' ) ); $repo = new OrderMetaRepository(); - $store = new OrderFieldStore( + $store = new OrderFieldSurface( renderer: new FieldRenderer( custom_types: $custom_types ), processor: new FieldProcessor( custom_types: $custom_types ), ); @@ -529,7 +529,7 @@ public function test_clearing_a_field_with_a_default_revokes_it_without_restorin \set_current_screen( $screen ); $repo = new OrderMetaRepository(); - $store = new OrderFieldStore(); + $store = new OrderFieldSurface(); $store->register( $this->group_with( new SettingsField( id: 'note', type: 'text', label: 'Note', default_value: 'preset' ) ), $this->placement(), diff --git a/packages/woocommerce/tests/Integration/ProductDataFieldStoreTest.php b/packages/woocommerce/tests/Integration/ProductDataFieldSurfaceTest.php similarity index 93% rename from packages/woocommerce/tests/Integration/ProductDataFieldStoreTest.php rename to packages/woocommerce/tests/Integration/ProductDataFieldSurfaceTest.php index e29abc1..5f81a44 100644 --- a/packages/woocommerce/tests/Integration/ProductDataFieldStoreTest.php +++ b/packages/woocommerce/tests/Integration/ProductDataFieldSurfaceTest.php @@ -7,13 +7,13 @@ use DeepWebSolutions\Framework\Settings\Schema\ValueObjects\SettingsField; use DeepWebSolutions\Framework\Settings\Schema\ValueObjects\SettingsSection; use DeepWebSolutions\Framework\WooCommerce\ProductData\Exceptions\InvalidProductDataTabException; -use DeepWebSolutions\Framework\WooCommerce\ProductData\ProductDataFieldStore; +use DeepWebSolutions\Framework\WooCommerce\ProductData\ProductDataFieldSurface; use DeepWebSolutions\Framework\WooCommerce\ProductData\ProductDataTab; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -#[CoversClass( ProductDataFieldStore::class )] -final class ProductDataFieldStoreTest extends TestCase { +#[CoversClass( ProductDataFieldSurface::class )] +final class ProductDataFieldSurfaceTest extends TestCase { private const ISOLATED_HOOKS = array( 'woocommerce_product_data_tabs', 'woocommerce_product_data_panels', @@ -82,7 +82,7 @@ protected function tearDown(): void { public function test_registers_the_tab_for_a_supported_product(): void { $this->set_current_product( $this->product_id ); - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $store->register_tab( $this->tab() ); $tabs = \apply_filters( 'woocommerce_product_data_tabs', array() ); @@ -96,7 +96,7 @@ public function test_registers_the_tab_for_a_supported_product(): void { public function test_does_not_register_the_tab_for_an_unsupported_product(): void { $this->set_current_product( $this->product_id ); - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $store->register_tab( $this->tab( supports: static fn ( int $product_id ): bool => false ) ); $tabs = \apply_filters( 'woocommerce_product_data_tabs', array() ); @@ -106,7 +106,7 @@ public function test_does_not_register_the_tab_for_an_unsupported_product(): voi public function test_dynamic_classes_closure_contributes_product_type_classes(): void { $this->set_current_product( $this->product_id ); - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $store->register_tab( $this->tab( classes: static fn ( int $product_id ): array => array( 'show_if_simple' ) ) ); $tabs = \apply_filters( 'woocommerce_product_data_tabs', array() ); @@ -121,7 +121,7 @@ public function test_dynamic_classes_closure_contributes_product_type_classes(): public function test_renders_the_panel_with_each_field_control(): void { $this->set_current_product( $this->product_id ); - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $store->register_tab( $this->tab() ); \ob_start(); @@ -135,7 +135,7 @@ public function test_renders_the_panel_with_each_field_control(): void { public function test_does_not_render_the_panel_for_an_unsupported_product(): void { $this->set_current_product( $this->product_id ); - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $store->register_tab( $this->tab( supports: static fn ( int $product_id ): bool => false ) ); \ob_start(); @@ -150,7 +150,7 @@ public function test_does_not_render_the_panel_for_an_unsupported_product(): voi // region SAVE public function test_save_persists_submitted_values(): void { - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $store->register_tab( $this->tab() ); $_POST = array( '_dws-wrwc_general_warranty-type' => 'addon' ); @@ -160,7 +160,7 @@ public function test_save_persists_submitted_values(): void { } public function test_save_applies_the_field_sanitizer(): void { - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $store->register_tab( $this->tab_with( new SettingsField( id: 'code', type: 'text', label: 'Code', sanitize: static fn ( mixed $v ): string => \strtoupper( (string) $v ) ), @@ -174,7 +174,7 @@ public function test_save_applies_the_field_sanitizer(): void { } public function test_save_applies_the_builtin_default_sanitizer(): void { - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $raw = 'x'; $store->register_tab( $this->tab_with( @@ -189,7 +189,7 @@ public function test_save_applies_the_builtin_default_sanitizer(): void { } public function test_save_preserves_an_existing_value_when_a_present_submission_is_invalid(): void { - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $store->register_tab( $this->tab_with( new SettingsField( @@ -212,7 +212,7 @@ public function test_save_preserves_an_existing_value_when_a_present_submission_ } public function test_save_is_skipped_for_an_unsupported_product(): void { - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $store->register_tab( $this->tab( supports: static fn ( int $product_id ): bool => false ) ); $_POST = array( '_dws-wrwc_general_warranty-type' => 'addon' ); @@ -222,7 +222,7 @@ public function test_save_is_skipped_for_an_unsupported_product(): void { } public function test_save_persists_a_multiselect_selection(): void { - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $store->register_tab( $this->tab_with( new SettingsField( @@ -245,7 +245,7 @@ public function test_save_persists_a_multiselect_selection(): void { } public function test_save_preserves_a_checkbox_when_validation_rejects_the_submission(): void { - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $store->register_tab( $this->tab_with( // A validator that rejects 'yes' makes the checked submission invalid. @@ -263,7 +263,7 @@ public function test_save_preserves_a_checkbox_when_validation_rejects_the_submi } public function test_save_runs_sanitize_and_validate_on_a_custom_field(): void { - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $store->register_tab( $this->tab_with( new SettingsField( @@ -287,7 +287,7 @@ public function test_save_runs_sanitize_and_validate_on_a_custom_field(): void { } public function test_a_custom_field_without_a_renderer_is_rejected_at_registration(): void { - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $this->expectException( InvalidProductDataTabException::class ); @@ -299,7 +299,7 @@ public function test_a_custom_field_without_a_renderer_is_rejected_at_registrati } public function test_a_custom_field_without_a_sanitize_is_rejected_at_registration(): void { - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $this->expectException( InvalidProductDataTabException::class ); @@ -312,7 +312,7 @@ public function test_a_custom_field_without_a_sanitize_is_rejected_at_registrati } public function test_an_absent_custom_field_stores_the_sanitized_empty_not_a_null_or_default(): void { - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $store->register_tab( $this->tab_with( new SettingsField( @@ -336,7 +336,7 @@ public function test_an_absent_custom_field_stores_the_sanitized_empty_not_a_nul public function test_a_non_scalar_custom_field_submission_is_coerced_before_sanitize(): void { $seen = null; - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $store->register_tab( $this->tab_with( new SettingsField( @@ -361,7 +361,7 @@ public function test_a_non_scalar_custom_field_submission_is_coerced_before_sani } public function test_the_before_save_hook_strips_an_injected_default(): void { - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $store->register_tab( $this->tab() ); // A fresh read injects the field defaults into the product's meta. @@ -378,7 +378,7 @@ public function test_the_before_save_hook_strips_an_injected_default(): void { } public function test_the_before_save_hook_keeps_a_set_value(): void { - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $store->register_tab( $this->tab() ); \clean_post_cache( $this->product_id ); @@ -397,14 +397,14 @@ public function test_the_before_save_hook_keeps_a_set_value(): void { // region DEFAULT INJECTION public function test_a_new_product_reads_the_default_through_get_post_meta(): void { - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $store->register_tab( $this->tab() ); self::assertSame( 'global', \get_post_meta( $this->product_id, '_dws-wrwc_general_warranty-type', true ) ); } public function test_a_new_product_reads_one_list_default_row_through_non_single_get_post_meta(): void { - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $store->register_tab( $this->tab_with( new SettingsField( @@ -425,7 +425,7 @@ public function test_a_new_product_reads_one_list_default_row_through_non_single } public function test_a_new_product_reads_the_default_through_the_wc_product(): void { - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $store->register_tab( $this->tab() ); \clean_post_cache( $this->product_id ); @@ -437,7 +437,7 @@ public function test_a_new_product_reads_the_default_through_the_wc_product(): v public function test_a_pre_existing_product_renders_the_default_without_a_stored_row(): void { // The product was created and saved in setUp before the tab existed — the regression-prone case. - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $store->register_tab( $this->tab() ); // Both read paths return the descriptor default… @@ -452,14 +452,14 @@ public function test_a_pre_existing_product_renders_the_default_without_a_stored } public function test_default_injection_is_scoped_to_supported_products(): void { - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $store->register_tab( $this->tab( supports: static fn ( int $product_id ): bool => false ) ); self::assertSame( '', \get_post_meta( $this->product_id, '_dws-wrwc_general_warranty-type', true ) ); } public function test_after_save_the_real_value_replaces_the_default(): void { - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $store->register_tab( $this->tab() ); $_POST = array( '_dws-wrwc_general_warranty-type' => 'addon' ); @@ -470,7 +470,7 @@ public function test_after_save_the_real_value_replaces_the_default(): void { } public function test_default_injection_does_not_leak_into_non_products(): void { - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); // A permissive gate must still be floored by product-existence: the global default filters must not // inject a product field's default into an unrelated post that happens to read the same meta key. $store->register_tab( $this->tab( supports: static fn ( int $product_id ): bool => true ) ); @@ -491,7 +491,7 @@ public function test_default_injection_does_not_leak_into_non_products(): void { public function test_bulk_default_injection_skips_the_consumer_gate_when_no_owned_key_is_missing(): void { $gate_calls = 0; - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $store->register_tab( $this->tab( supports: static function ( int $product_id ) use ( &$gate_calls ): bool { @@ -519,7 +519,7 @@ public function test_bulk_default_injection_skips_the_consumer_gate_when_no_owne public function test_a_field_the_user_cannot_edit_is_not_rendered(): void { $this->set_current_product( $this->product_id ); - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $store->register_tab( $this->tab_with( new SettingsField( id: 'secret', type: 'text', label: 'Secret', capability: 'dws_protected_cap' ), @@ -534,7 +534,7 @@ public function test_a_field_the_user_cannot_edit_is_not_rendered(): void { } public function test_a_field_the_user_cannot_edit_is_not_saved(): void { - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $store->register_tab( $this->tab_with( new SettingsField( id: 'secret', type: 'text', label: 'Secret', capability: 'dws_protected_cap' ), @@ -548,7 +548,7 @@ public function test_a_field_the_user_cannot_edit_is_not_saved(): void { } public function test_save_does_not_freeze_an_injected_default_for_a_field_the_user_cannot_edit(): void { - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $store->register_tab( $this->tab_with( new SettingsField( id: 'secret', type: 'text', label: 'Secret', default_value: 'fallback', capability: 'dws_protected_cap' ), @@ -572,7 +572,7 @@ public function test_save_does_not_freeze_an_injected_default_for_a_field_the_us public function test_a_custom_field_type_renders_via_its_renderer_and_saves_via_sanitize(): void { $this->set_current_product( $this->product_id ); - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $store->register_tab( new ProductDataTab( slug: 'dws_warranty', @@ -615,7 +615,7 @@ public function test_a_custom_field_type_renders_via_its_renderer_and_saves_via_ // region CRUD + UNINSTALL SURFACE public function test_crud_round_trips_by_section_and_field(): void { - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $store->register_tab( $this->tab() ); self::assertFalse( $store->has( $this->product_id, 'general', 'code' ) ); @@ -630,7 +630,7 @@ public function test_crud_round_trips_by_section_and_field(): void { } public function test_set_normalizes_a_checkbox_value_to_yes_no(): void { - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $store->register_tab( $this->tab_with( new SettingsField( id: 'flag', type: 'checkbox', label: 'Flag' ) ) ); // A boolean written through CRUD must persist as WooCommerce's 'yes', matching the form-save path. @@ -640,7 +640,7 @@ public function test_set_normalizes_a_checkbox_value_to_yes_no(): void { } public function test_set_persists_a_value_equal_to_the_default(): void { - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $store->register_tab( $this->tab() ); // Setting a field to a value that equals its default must persist a real row — matching the form save's @@ -652,7 +652,7 @@ public function test_set_persists_a_value_equal_to_the_default(): void { } public function test_get_returns_the_descriptor_default_for_an_unstored_supported_field(): void { - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $store->register_tab( $this->tab() ); // get() reads the descriptor default while nothing is stored, agreeing with the injected read paths and @@ -662,14 +662,14 @@ public function test_get_returns_the_descriptor_default_for_an_unstored_supporte } public function test_get_returns_the_caller_fallback_for_an_unsupported_product(): void { - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $store->register_tab( $this->tab( supports: static fn ( int $product_id ): bool => false ) ); self::assertSame( 'na', $store->get( $this->product_id, 'general', 'warranty-type', 'na' ) ); } public function test_meta_key_derivation_and_override(): void { - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $store->register_tab( new ProductDataTab( slug: 'dws_warranty', @@ -697,7 +697,7 @@ public function test_meta_key_derivation_and_override(): void { } public function test_a_duplicate_meta_key_is_rejected(): void { - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $this->expectException( DuplicateSettingsFieldException::class ); @@ -715,7 +715,7 @@ public function test_a_duplicate_meta_key_is_rejected(): void { } public function test_crud_on_an_unregistered_field_throws(): void { - $store = new ProductDataFieldStore(); + $store = new ProductDataFieldSurface(); $store->register_tab( $this->tab() ); $this->expectException( InvalidSettingsFieldException::class ); diff --git a/packages/woocommerce/tests/Integration/WooCommerceSettingsBackendTest.php b/packages/woocommerce/tests/Integration/WooCommerceSettingsBackendTest.php index 6832eb6..91412d9 100644 --- a/packages/woocommerce/tests/Integration/WooCommerceSettingsBackendTest.php +++ b/packages/woocommerce/tests/Integration/WooCommerceSettingsBackendTest.php @@ -11,9 +11,9 @@ use DeepWebSolutions\Framework\WooCommerce\Backend\WooCommerceSettingsBackend; use DeepWebSolutions\Framework\WooCommerce\Backend\Exceptions\UnsupportedSettingsPageCapabilityException; use DeepWebSolutions\Framework\WooCommerce\Backend\Exceptions\UnboundSettingsPageException; -use DeepWebSolutions\Framework\WooCommerce\Tests\Integration\Fixtures\BarWCSettingsPage; -use DeepWebSolutions\Framework\WooCommerce\Tests\Integration\Fixtures\FooWCSettingsPage; -use DeepWebSolutions\Framework\WooCommerce\Tests\Integration\Fixtures\LazyBindWCSettingsPage; +use DeepWebSolutions\Framework\WooCommerce\Tests\Integration\Fixtures\BarWooCommerceSettingsPage; +use DeepWebSolutions\Framework\WooCommerce\Tests\Integration\Fixtures\FooWooCommerceSettingsPage; +use DeepWebSolutions\Framework\WooCommerce\Tests\Integration\Fixtures\LazyBindWooCommerceSettingsPage; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -46,7 +46,7 @@ protected function tearDown(): void { } public function test_register_page_adds_the_page_as_a_woocommerce_settings_tab(): void { - $backend = new WooCommerceSettingsBackend( FooWCSettingsPage::class ); + $backend = new WooCommerceSettingsBackend( FooWooCommerceSettingsPage::class ); $backend->register_page( $this->single_field_page( 'store_name', 'text', 'Store Name' ) ); $ids = $this->tab_ids( \apply_filters( 'woocommerce_get_settings_pages', array() ) ); @@ -55,10 +55,10 @@ public function test_register_page_adds_the_page_as_a_woocommerce_settings_tab() } public function test_two_backends_register_coexisting_pages_without_collision(): void { - ( new WooCommerceSettingsBackend( FooWCSettingsPage::class ) )->register_page( + ( new WooCommerceSettingsBackend( FooWooCommerceSettingsPage::class ) )->register_page( $this->page( 'dws-foo', 'dws_foo', 'Foo', array( new SettingsField( id: 'a', type: 'text', label: 'A' ) ) ), ); - ( new WooCommerceSettingsBackend( BarWCSettingsPage::class ) )->register_page( + ( new WooCommerceSettingsBackend( BarWooCommerceSettingsPage::class ) )->register_page( $this->page( 'dws-bar', 'dws_bar', 'Bar', array( new SettingsField( id: 'b', type: 'text', label: 'B' ) ) ), ); @@ -69,7 +69,7 @@ public function test_two_backends_register_coexisting_pages_without_collision(): } public function test_a_field_value_round_trips_through_a_prefixed_option(): void { - $backend = new WooCommerceSettingsBackend( FooWCSettingsPage::class ); + $backend = new WooCommerceSettingsBackend( FooWooCommerceSettingsPage::class ); $backend->register_page( $this->single_field_page( 'store_name', 'text', 'Store Name' ) ); $backend->set( 'store_name', 'Acme' ); @@ -92,7 +92,7 @@ public function test_option_keys_names_exactly_the_rows_the_backend_persists_int ), ); - $backend = new WooCommerceSettingsBackend( FooWCSettingsPage::class ); + $backend = new WooCommerceSettingsBackend( FooWooCommerceSettingsPage::class ); $backend->register_page( $page ); $backend->set( 'store_name', 'Acme' ); @@ -112,7 +112,7 @@ public function test_option_keys_names_exactly_the_rows_the_backend_persists_int } public function test_a_stored_value_is_distinct_from_an_absent_one(): void { - $backend = new WooCommerceSettingsBackend( FooWCSettingsPage::class ); + $backend = new WooCommerceSettingsBackend( FooWooCommerceSettingsPage::class ); $backend->register_page( $this->page( 'dws-foo', @@ -136,7 +136,7 @@ public function test_a_stored_value_is_distinct_from_an_absent_one(): void { } public function test_the_sanitize_bridge_runs_the_descriptor_sanitizer(): void { - $backend = new WooCommerceSettingsBackend( FooWCSettingsPage::class ); + $backend = new WooCommerceSettingsBackend( FooWooCommerceSettingsPage::class ); $backend->register_page( $this->page( 'dws-foo', @@ -154,7 +154,7 @@ public function test_the_sanitize_bridge_runs_the_descriptor_sanitizer(): void { } public function test_register_page_defers_binding_until_woocommerce_builds_pages(): void { - $backend = new WooCommerceSettingsBackend( LazyBindWCSettingsPage::class ); + $backend = new WooCommerceSettingsBackend( LazyBindWooCommerceSettingsPage::class ); $backend->register_page( $this->page( 'dws-lazy', 'dws_lazy', 'Lazy', array( new SettingsField( id: 'a', type: 'text', label: 'A' ) ) ), ); @@ -164,11 +164,11 @@ public function test_register_page_defers_binding_until_woocommerce_builds_pages // therefore still unbound here, and instantiating it throws. $this->expectException( UnboundSettingsPageException::class ); - new LazyBindWCSettingsPage(); + new LazyBindWooCommerceSettingsPage(); } public function test_a_field_the_user_cannot_edit_is_not_rendered(): void { - $backend = new WooCommerceSettingsBackend( FooWCSettingsPage::class ); + $backend = new WooCommerceSettingsBackend( FooWooCommerceSettingsPage::class ); $backend->register_page( $this->page( 'dws-foo', @@ -182,18 +182,18 @@ public function test_a_field_the_user_cannot_edit_is_not_rendered(): void { ); \apply_filters( 'woocommerce_get_settings_pages', array() ); - $ids = \array_column( ( new FooWCSettingsPage() )->get_settings_for_section( '' ), 'id' ); + $ids = \array_column( ( new FooWooCommerceSettingsPage() )->get_settings_for_section( '' ), 'id' ); self::assertContains( 'dws-foo_open', $ids ); self::assertNotContains( 'dws-foo_secret', $ids ); } public function test_each_descriptor_section_registers_as_a_native_woocommerce_section(): void { - $backend = new WooCommerceSettingsBackend( FooWCSettingsPage::class ); + $backend = new WooCommerceSettingsBackend( FooWooCommerceSettingsPage::class ); $backend->register_page( $this->two_section_page() ); \apply_filters( 'woocommerce_get_settings_pages', array() ); - $page = new FooWCSettingsPage(); + $page = new FooWooCommerceSettingsPage(); self::assertSame( array( @@ -205,11 +205,11 @@ public function test_each_descriptor_section_registers_as_a_native_woocommerce_s } public function test_each_native_woocommerce_section_renders_only_its_own_fields(): void { - $backend = new WooCommerceSettingsBackend( FooWCSettingsPage::class ); + $backend = new WooCommerceSettingsBackend( FooWooCommerceSettingsPage::class ); $backend->register_page( $this->two_section_page() ); \apply_filters( 'woocommerce_get_settings_pages', array() ); - $page = new FooWCSettingsPage(); + $page = new FooWooCommerceSettingsPage(); $default_ids = \array_column( $page->get_settings_for_section( '' ), 'id' ); $advanced_ids = \array_column( $page->get_settings_for_section( 'advanced' ), 'id' ); @@ -225,16 +225,16 @@ public function test_each_native_woocommerce_section_renders_only_its_own_fields } public function test_a_single_descriptor_section_registers_only_the_default_woocommerce_section(): void { - $backend = new WooCommerceSettingsBackend( FooWCSettingsPage::class ); + $backend = new WooCommerceSettingsBackend( FooWooCommerceSettingsPage::class ); $backend->register_page( $this->single_field_page( 'store_name', 'text', 'Store Name' ) ); \apply_filters( 'woocommerce_get_settings_pages', array() ); - self::assertSame( array( '' => 'General' ), ( new FooWCSettingsPage() )->get_sections() ); + self::assertSame( array( '' => 'General' ), ( new FooWooCommerceSettingsPage() )->get_sections() ); } public function test_a_save_of_a_field_the_user_cannot_edit_is_rejected(): void { - $backend = new WooCommerceSettingsBackend( FooWCSettingsPage::class ); + $backend = new WooCommerceSettingsBackend( FooWooCommerceSettingsPage::class ); $backend->register_page( $this->page( 'dws-foo', @@ -253,7 +253,7 @@ public function test_a_save_of_a_field_the_user_cannot_edit_is_rejected(): void } public function test_register_page_rejects_a_non_woocommerce_settings_capability(): void { - $backend = new WooCommerceSettingsBackend( FooWCSettingsPage::class ); + $backend = new WooCommerceSettingsBackend( FooWooCommerceSettingsPage::class ); $this->expectException( UnsupportedSettingsPageCapabilityException::class ); @@ -270,7 +270,7 @@ public function test_register_page_rejects_a_non_woocommerce_settings_capability } public function test_set_honors_the_field_autoload_policy(): void { - $backend = new WooCommerceSettingsBackend( FooWCSettingsPage::class ); + $backend = new WooCommerceSettingsBackend( FooWooCommerceSettingsPage::class ); $backend->register_page( $this->page( 'dws-foo', @@ -293,7 +293,7 @@ public function test_set_honors_the_field_autoload_policy(): void { } public function test_a_no_cap_save_of_an_unstored_field_leaves_it_absent(): void { - $backend = new WooCommerceSettingsBackend( FooWCSettingsPage::class ); + $backend = new WooCommerceSettingsBackend( FooWooCommerceSettingsPage::class ); $backend->register_page( $this->page( 'dws-foo', @@ -312,7 +312,7 @@ public function test_a_no_cap_save_of_an_unstored_field_leaves_it_absent(): void } public function test_accessing_an_unregistered_field_throws(): void { - $backend = new WooCommerceSettingsBackend( FooWCSettingsPage::class ); + $backend = new WooCommerceSettingsBackend( FooWooCommerceSettingsPage::class ); $backend->register_page( $this->single_field_page( 'store_name', 'text', 'Store Name' ) ); $this->expectException( InvalidSettingsFieldException::class ); @@ -321,7 +321,7 @@ public function test_accessing_an_unregistered_field_throws(): void { } public function test_a_duplicate_field_id_across_sections_throws(): void { - $backend = new WooCommerceSettingsBackend( FooWCSettingsPage::class ); + $backend = new WooCommerceSettingsBackend( FooWooCommerceSettingsPage::class ); $this->expectException( DuplicateSettingsFieldException::class ); @@ -341,7 +341,7 @@ public function test_a_duplicate_field_id_across_sections_throws(): void { } public function test_a_duplicate_section_id_on_a_page_throws(): void { - $backend = new WooCommerceSettingsBackend( FooWCSettingsPage::class ); + $backend = new WooCommerceSettingsBackend( FooWooCommerceSettingsPage::class ); $this->expectException( DuplicateSettingsSectionException::class ); @@ -361,7 +361,7 @@ public function test_a_duplicate_section_id_on_a_page_throws(): void { } public function test_a_section_id_colliding_with_a_field_id_throws(): void { - $backend = new WooCommerceSettingsBackend( FooWCSettingsPage::class ); + $backend = new WooCommerceSettingsBackend( FooWooCommerceSettingsPage::class ); $this->expectException( DuplicateSettingsFieldException::class ); diff --git a/packages/woocommerce/tests/Unit/OrderData/OrderFieldStoreTest.php b/packages/woocommerce/tests/Unit/OrderData/OrderFieldSurfaceTest.php similarity index 95% rename from packages/woocommerce/tests/Unit/OrderData/OrderFieldStoreTest.php rename to packages/woocommerce/tests/Unit/OrderData/OrderFieldSurfaceTest.php index 8fa2f37..3bc911f 100644 --- a/packages/woocommerce/tests/Unit/OrderData/OrderFieldStoreTest.php +++ b/packages/woocommerce/tests/Unit/OrderData/OrderFieldSurfaceTest.php @@ -10,14 +10,14 @@ use DeepWebSolutions\Framework\Settings\Schema\Options\OptionsResolver; use DeepWebSolutions\Framework\Settings\Schema\ValueObjects\SettingsField; use DeepWebSolutions\Framework\Storage\ObjectMeta\ObjectMetaRepositoryInterface; -use DeepWebSolutions\Framework\WooCommerce\OrderData\OrderFieldStore; +use DeepWebSolutions\Framework\WooCommerce\OrderData\OrderFieldSurface; use DeepWebSolutions\Framework\WooCommerce\Tests\Fixtures\InMemoryObjectMetaRepository; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\Attributes\UsesFunction; use PHPUnit\Framework\TestCase; -#[CoversClass( OrderFieldStore::class )] +#[CoversClass( OrderFieldSurface::class )] #[UsesClass( ObjectFieldForm::class )] #[UsesClass( FieldGroup::class )] #[UsesClass( SettingsField::class )] @@ -28,15 +28,15 @@ #[UsesFunction( 'DeepWebSolutions\Framework\Settings\Schema\is_checkbox_checked' )] #[UsesFunction( 'DeepWebSolutions\Framework\Settings\Schema\normalize_checkbox_value' )] #[UsesFunction( 'DeepWebSolutions\Framework\Settings\Schema\wordpress_field_type_sanitizers' )] -final class OrderFieldStoreTest extends TestCase { +final class OrderFieldSurfaceTest extends TestCase { private ObjectMetaRepositoryInterface $repository; - private OrderFieldStore $store; + private OrderFieldSurface $store; protected function setUp(): void { parent::setUp(); $this->repository = new InMemoryObjectMetaRepository(); - $this->store = new OrderFieldStore( repository: $this->repository ); + $this->store = new OrderFieldSurface( repository: $this->repository ); } public function test_a_value_round_trips_under_the_resolved_storage_key(): void { diff --git a/packages/woocommerce/tests/Unit/WooCommerceSettingsBackendTest.php b/packages/woocommerce/tests/Unit/WooCommerceSettingsBackendTest.php index 9d37d00..f167fa4 100644 --- a/packages/woocommerce/tests/Unit/WooCommerceSettingsBackendTest.php +++ b/packages/woocommerce/tests/Unit/WooCommerceSettingsBackendTest.php @@ -5,7 +5,7 @@ use DeepWebSolutions\Framework\Settings\Schema\ValueObjects\SettingsField; use DeepWebSolutions\Framework\Settings\Schema\ValueObjects\SettingsPage; use DeepWebSolutions\Framework\Settings\Schema\ValueObjects\SettingsSection; -use DeepWebSolutions\Framework\WooCommerce\Backend\DescriptorBackedWCSettingsPage; +use DeepWebSolutions\Framework\WooCommerce\Backend\DescriptorBackedWooCommerceSettingsPage; use DeepWebSolutions\Framework\WooCommerce\Backend\WooCommerceSettingsBackend; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; @@ -58,6 +58,6 @@ public function test_option_keys_is_empty_for_a_page_without_fields(): void { private function backend(): WooCommerceSettingsBackend { // The page subclass is never loaded or instantiated by option_keys(), so the abstract base's own // class-string satisfies the constructor without WooCommerce present. - return new WooCommerceSettingsBackend( DescriptorBackedWCSettingsPage::class ); + return new WooCommerceSettingsBackend( DescriptorBackedWooCommerceSettingsPage::class ); } } diff --git a/packages/woocommerce/tests/Unit/WCSettingsBuilderTest.php b/packages/woocommerce/tests/Unit/WooCommerceSettingsBuilderTest.php similarity index 78% rename from packages/woocommerce/tests/Unit/WCSettingsBuilderTest.php rename to packages/woocommerce/tests/Unit/WooCommerceSettingsBuilderTest.php index 11921e2..e2ed3f8 100644 --- a/packages/woocommerce/tests/Unit/WCSettingsBuilderTest.php +++ b/packages/woocommerce/tests/Unit/WooCommerceSettingsBuilderTest.php @@ -6,13 +6,13 @@ use DeepWebSolutions\Framework\Settings\Schema\ValueObjects\SettingsField; use DeepWebSolutions\Framework\Settings\Schema\ValueObjects\SettingsPage; use DeepWebSolutions\Framework\Settings\Schema\ValueObjects\SettingsSection; -use DeepWebSolutions\Framework\WooCommerce\Backend\WCSettingsBuilder; +use DeepWebSolutions\Framework\WooCommerce\Backend\WooCommerceSettingsBuilder; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\Attributes\UsesFunction; use PHPUnit\Framework\TestCase; -#[CoversClass( WCSettingsBuilder::class )] +#[CoversClass( WooCommerceSettingsBuilder::class )] #[UsesClass( OptionsResolver::class )] #[UsesClass( SettingsField::class )] #[UsesClass( SettingsPage::class )] @@ -22,9 +22,9 @@ #[UsesFunction( 'DeepWebSolutions\Framework\Shared\Identifier\is_valid_identifier' )] #[UsesFunction( 'DeepWebSolutions\Framework\Settings\Schema\stringify_for_output' )] #[UsesFunction( 'DeepWebSolutions\Framework\WooCommerce\to_yes_no' )] -final class WCSettingsBuilderTest extends TestCase { +final class WooCommerceSettingsBuilderTest extends TestCase { public function test_emits_a_title_fields_sectionend_sequence_per_section(): void { - $built = ( new WCSettingsBuilder() )->build( $this->page() ); + $built = ( new WooCommerceSettingsBuilder() )->build( $this->page() ); $shape = \array_map( static fn ( array $row ): array => array( $row['type'], $row['id'] ?? null ), @@ -46,7 +46,7 @@ public function test_emits_a_title_fields_sectionend_sequence_per_section(): voi } public function test_a_section_title_row_carries_the_section_title(): void { - $built = ( new WCSettingsBuilder() )->build( $this->page() ); + $built = ( new WooCommerceSettingsBuilder() )->build( $this->page() ); self::assertSame( 'General', $built[0]['title'] ); self::assertSame( 'Advanced', $built[4]['title'] ); @@ -54,7 +54,7 @@ public function test_a_section_title_row_carries_the_section_title(): void { public function test_build_section_emits_only_that_sections_title_fields_and_sectionend(): void { $page = $this->page(); - $built = ( new WCSettingsBuilder() )->build_section( $page, $page->sections[1] ); + $built = ( new WooCommerceSettingsBuilder() )->build_section( $page, $page->sections[1] ); $shape = \array_map( static fn ( array $row ): array => array( $row['type'], $row['id'] ?? null ), @@ -72,7 +72,7 @@ public function test_build_section_emits_only_that_sections_title_fields_and_sec } public function test_a_field_row_carries_type_label_and_default(): void { - $row = $this->row_by_id( ( new WCSettingsBuilder() )->build( $this->page() ), 'dws-shop_store_name' ); + $row = $this->row_by_id( ( new WooCommerceSettingsBuilder() )->build( $this->page() ), 'dws-shop_store_name' ); self::assertSame( 'text', $row['type'] ); self::assertSame( 'Store Name', $row['title'] ); @@ -80,7 +80,7 @@ public function test_a_field_row_carries_type_label_and_default(): void { } public function test_a_null_default_is_omitted(): void { - $row = $this->row_by_id( ( new WCSettingsBuilder() )->build( $this->page() ), 'dws-shop_debug' ); + $row = $this->row_by_id( ( new WooCommerceSettingsBuilder() )->build( $this->page() ), 'dws-shop_debug' ); self::assertArrayNotHasKey( 'default', $row ); } @@ -90,7 +90,7 @@ public function test_a_field_description_is_emitted_as_the_woocommerce_desc(): v new SettingsField( id: 'tagline', type: 'text', label: 'Tagline', description: 'Shown under the field.' ), ); - $row = $this->row_by_id( ( new WCSettingsBuilder() )->build( $page ), 'dws-shop_tagline' ); + $row = $this->row_by_id( ( new WooCommerceSettingsBuilder() )->build( $page ), 'dws-shop_tagline' ); self::assertSame( 'Shown under the field.', $row['desc'] ); } @@ -98,7 +98,7 @@ public function test_a_field_description_is_emitted_as_the_woocommerce_desc(): v public function test_a_field_row_carries_an_explicit_off_autoload_flag_by_default(): void { // WooCommerce defaults a settings option to autoloaded when the entry omits 'autoload', so the // builder always emits it to keep framework settings out of alloptions unless a field opts in. - $row = $this->row_by_id( ( new WCSettingsBuilder() )->build( $this->page() ), 'dws-shop_store_name' ); + $row = $this->row_by_id( ( new WooCommerceSettingsBuilder() )->build( $this->page() ), 'dws-shop_store_name' ); self::assertFalse( $row['autoload'] ); } @@ -108,13 +108,13 @@ public function test_a_field_opting_into_autoload_emits_a_truthy_autoload_flag() new SettingsField( id: 'cache', type: 'text', label: 'Cache', autoload: true ), ); - $row = $this->row_by_id( ( new WCSettingsBuilder() )->build( $page ), 'dws-shop_cache' ); + $row = $this->row_by_id( ( new WooCommerceSettingsBuilder() )->build( $page ), 'dws-shop_cache' ); self::assertTrue( $row['autoload'] ); } public function test_resolved_options_are_included_for_a_choice_field(): void { - $row = $this->row_by_id( ( new WCSettingsBuilder() )->build( $this->page() ), 'dws-shop_gateway' ); + $row = $this->row_by_id( ( new WooCommerceSettingsBuilder() )->build( $this->page() ), 'dws-shop_gateway' ); self::assertSame( array( @@ -126,7 +126,7 @@ public function test_resolved_options_are_included_for_a_choice_field(): void { } public function test_options_are_omitted_for_a_field_without_any(): void { - $row = $this->row_by_id( ( new WCSettingsBuilder() )->build( $this->page() ), 'dws-shop_store_name' ); + $row = $this->row_by_id( ( new WooCommerceSettingsBuilder() )->build( $this->page() ), 'dws-shop_store_name' ); self::assertArrayNotHasKey( 'options', $row ); } @@ -141,7 +141,7 @@ public function test_options_from_a_closure_are_resolved(): void { ), ); - $row = $this->row_by_id( ( new WCSettingsBuilder() )->build( $page ), 'dws-shop_role' ); + $row = $this->row_by_id( ( new WooCommerceSettingsBuilder() )->build( $page ), 'dws-shop_role' ); self::assertSame( array( 'admin' => 'Admin' ), $row['options'] ); } @@ -159,7 +159,7 @@ public function test_custom_attributes_are_included_when_present(): void { ), ); - $row = $this->row_by_id( ( new WCSettingsBuilder() )->build( $page ), 'dws-shop_qty' ); + $row = $this->row_by_id( ( new WooCommerceSettingsBuilder() )->build( $page ), 'dws-shop_qty' ); self::assertSame( array( @@ -171,7 +171,7 @@ public function test_custom_attributes_are_included_when_present(): void { } public function test_custom_attributes_are_omitted_when_empty(): void { - $row = $this->row_by_id( ( new WCSettingsBuilder() )->build( $this->page() ), 'dws-shop_store_name' ); + $row = $this->row_by_id( ( new WooCommerceSettingsBuilder() )->build( $this->page() ), 'dws-shop_store_name' ); self::assertArrayNotHasKey( 'custom_attributes', $row ); } @@ -181,7 +181,7 @@ public function test_a_truthy_checkbox_default_maps_to_the_wc_yes_string(): void new SettingsField( id: 'flag', type: 'checkbox', label: 'Flag', default_value: true ), ); - $row = $this->row_by_id( ( new WCSettingsBuilder() )->build( $page ), 'dws-shop_flag' ); + $row = $this->row_by_id( ( new WooCommerceSettingsBuilder() )->build( $page ), 'dws-shop_flag' ); self::assertSame( 'yes', $row['default'] ); } @@ -191,7 +191,7 @@ public function test_a_falsy_checkbox_default_maps_to_the_wc_no_string(): void { new SettingsField( id: 'flag', type: 'checkbox', label: 'Flag', default_value: false ), ); - $row = $this->row_by_id( ( new WCSettingsBuilder() )->build( $page ), 'dws-shop_flag' ); + $row = $this->row_by_id( ( new WooCommerceSettingsBuilder() )->build( $page ), 'dws-shop_flag' ); self::assertSame( 'no', $row['default'] ); } @@ -201,7 +201,7 @@ public function test_a_truthy_non_boolean_checkbox_default_maps_to_the_wc_yes_st new SettingsField( id: 'flag', type: 'checkbox', label: 'Flag', default_value: 1 ), ); - $row = $this->row_by_id( ( new WCSettingsBuilder() )->build( $page ), 'dws-shop_flag' ); + $row = $this->row_by_id( ( new WooCommerceSettingsBuilder() )->build( $page ), 'dws-shop_flag' ); self::assertSame( 'yes', $row['default'] ); } @@ -211,7 +211,7 @@ public function test_a_string_yes_checkbox_default_maps_to_the_wc_yes_string(): new SettingsField( id: 'flag', type: 'checkbox', label: 'Flag', default_value: 'yes' ), ); - $row = $this->row_by_id( ( new WCSettingsBuilder() )->build( $page ), 'dws-shop_flag' ); + $row = $this->row_by_id( ( new WooCommerceSettingsBuilder() )->build( $page ), 'dws-shop_flag' ); self::assertSame( 'yes', $row['default'] ); } @@ -222,7 +222,7 @@ public function test_a_string_no_checkbox_default_maps_to_the_wc_no_string(): vo new SettingsField( id: 'flag', type: 'checkbox', label: 'Flag', default_value: 'no' ), ); - $row = $this->row_by_id( ( new WCSettingsBuilder() )->build( $page ), 'dws-shop_flag' ); + $row = $this->row_by_id( ( new WooCommerceSettingsBuilder() )->build( $page ), 'dws-shop_flag' ); self::assertSame( 'no', $row['default'] ); } @@ -233,7 +233,7 @@ public function test_an_arbitrary_string_checkbox_default_maps_to_the_wc_no_stri new SettingsField( id: 'flag', type: 'checkbox', label: 'Flag', default_value: 'anything' ), ); - $row = $this->row_by_id( ( new WCSettingsBuilder() )->build( $page ), 'dws-shop_flag' ); + $row = $this->row_by_id( ( new WooCommerceSettingsBuilder() )->build( $page ), 'dws-shop_flag' ); self::assertSame( 'no', $row['default'] ); } @@ -251,7 +251,7 @@ public function test_non_string_option_labels_are_stringified(): void { ), ); - $row = $this->row_by_id( ( new WCSettingsBuilder() )->build( $page ), 'dws-shop_amount' ); + $row = $this->row_by_id( ( new WooCommerceSettingsBuilder() )->build( $page ), 'dws-shop_amount' ); self::assertSame( array( @@ -267,7 +267,7 @@ public function test_a_non_scalar_option_label_becomes_an_empty_string(): void { new SettingsField( id: 'choice', type: 'select', label: 'Choice', options: array( 'a' => array( 'nested' ) ) ), ); - $row = $this->row_by_id( ( new WCSettingsBuilder() )->build( $page ), 'dws-shop_choice' ); + $row = $this->row_by_id( ( new WooCommerceSettingsBuilder() )->build( $page ), 'dws-shop_choice' ); self::assertSame( array( 'a' => '' ), $row['options'] ); } @@ -287,7 +287,7 @@ public function test_invalid_and_event_handler_attribute_names_are_filtered_out( ), ); - $row = $this->row_by_id( ( new WCSettingsBuilder() )->build( $page ), 'dws-shop_qty' ); + $row = $this->row_by_id( ( new WooCommerceSettingsBuilder() )->build( $page ), 'dws-shop_qty' ); self::assertSame( array( 'min' => '0' ), $row['custom_attributes'] ); } @@ -297,7 +297,7 @@ public function test_a_mixed_case_attribute_name_is_kept(): void { new SettingsField( id: 'x', type: 'text', label: 'X', attributes: array( 'data-Foo' => 'bar' ) ), ); - $row = $this->row_by_id( ( new WCSettingsBuilder() )->build( $page ), 'dws-shop_x' ); + $row = $this->row_by_id( ( new WooCommerceSettingsBuilder() )->build( $page ), 'dws-shop_x' ); self::assertSame( array( 'data-Foo' => 'bar' ), $row['custom_attributes'] ); } @@ -307,7 +307,7 @@ public function test_custom_attributes_are_omitted_when_every_attribute_is_filte new SettingsField( id: 'x', type: 'text', label: 'X', attributes: array( 'onmouseover' => 'evil()' ) ), ); - $row = $this->row_by_id( ( new WCSettingsBuilder() )->build( $page ), 'dws-shop_x' ); + $row = $this->row_by_id( ( new WooCommerceSettingsBuilder() )->build( $page ), 'dws-shop_x' ); self::assertArrayNotHasKey( 'custom_attributes', $row ); } @@ -315,7 +315,7 @@ public function test_custom_attributes_are_omitted_when_every_attribute_is_filte public function test_a_choice_field_with_no_options_still_emits_an_empty_options_array(): void { $page = $this->page_with_field( new SettingsField( id: 'sel', type: 'select', label: 'Sel' ) ); - $row = $this->row_by_id( ( new WCSettingsBuilder() )->build( $page ), 'dws-shop_sel' ); + $row = $this->row_by_id( ( new WooCommerceSettingsBuilder() )->build( $page ), 'dws-shop_sel' ); self::assertArrayHasKey( 'options', $row ); self::assertSame( array(), $row['options'] ); @@ -326,7 +326,7 @@ public function test_options_are_not_emitted_for_a_non_choice_field(): void { new SettingsField( id: 'n', type: 'number', label: 'N', options: array( 'a' => 'A' ) ), ); - $row = $this->row_by_id( ( new WCSettingsBuilder() )->build( $page ), 'dws-shop_n' ); + $row = $this->row_by_id( ( new WooCommerceSettingsBuilder() )->build( $page ), 'dws-shop_n' ); self::assertArrayNotHasKey( 'options', $row ); } @@ -345,7 +345,7 @@ public function test_multiselect_default_values_are_stringified(): void { ), ); - $row = $this->row_by_id( ( new WCSettingsBuilder() )->build( $page ), 'dws-shop_tags' ); + $row = $this->row_by_id( ( new WooCommerceSettingsBuilder() )->build( $page ), 'dws-shop_tags' ); self::assertSame( array( '1', '2' ), $row['default'] ); } @@ -355,7 +355,7 @@ public function test_a_non_array_multiselect_default_becomes_an_empty_array(): v new SettingsField( id: 'tags', type: 'multiselect', label: 'Tags', default_value: 'oops', options: array( 'a' => 'A' ) ), ); - $row = $this->row_by_id( ( new WCSettingsBuilder() )->build( $page ), 'dws-shop_tags' ); + $row = $this->row_by_id( ( new WooCommerceSettingsBuilder() )->build( $page ), 'dws-shop_tags' ); self::assertSame( array(), $row['default'] ); } @@ -368,7 +368,7 @@ public function test_a_page_without_sections_builds_an_empty_array(): void { capability: 'manage_woocommerce', ); - self::assertSame( array(), ( new WCSettingsBuilder() )->build( $page ) ); + self::assertSame( array(), ( new WooCommerceSettingsBuilder() )->build( $page ) ); } private function page(): SettingsPage { diff --git a/tests/Fixtures/consumer-smoke/smoke.php b/tests/Fixtures/consumer-smoke/smoke.php index 8fe40a0..c163d53 100644 --- a/tests/Fixtures/consumer-smoke/smoke.php +++ b/tests/Fixtures/consumer-smoke/smoke.php @@ -65,8 +65,8 @@ // WooCommerce order-field store: the class that references WooCommerce symbols resolves under the // scoped prefix, with those symbols left unprefixed via the fixture's woocommerce-stubs catalog. -if ( ! class_exists( SCOPED_PREFIX . 'DeepWebSolutions\\Framework\\WooCommerce\\OrderData\\OrderFieldStore' ) ) { - $failures[] = 'missing scoped class: DeepWebSolutions\\Framework\\WooCommerce\\OrderData\\OrderFieldStore'; +if ( ! class_exists( SCOPED_PREFIX . 'DeepWebSolutions\\Framework\\WooCommerce\\OrderData\\OrderFieldSurface' ) ) { + $failures[] = 'missing scoped class: DeepWebSolutions\\Framework\\WooCommerce\\OrderData\\OrderFieldSurface'; } // PHP-DI PSR-4 + files-autoloaded factory(). From 3cdb97c69c7151929a704c63584922c6bc7f25de Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Tue, 7 Jul 2026 22:25:24 +0200 Subject: [PATCH 03/10] refactor(infrastructure)!: unify utilities wiring, failure channels, and service shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One wiring verb: register_lifecycle() becomes register_hooks() across the scheduling backends and hook handlers, HookHandlerInterface now declares it, and both facades (Scheduler, HooksService) forward to every member — a composed handler no longer needs out-of-band wiring. One failure channel for framework misuse: unknown named targets throw the new UnknownHookHandlerException / UnknownNoticeStoreException (AbstractRuntimeException family) — add_notice() no longer drops a notice silently via _doing_it_wrong — and AdminNoticeLogger's ctor id check throws the new InvalidNoticeIdentifierException. PSR's InvalidArgumentException stays only where PSR-3 mandates it (level vocabulary). PHPIniSizeConditional validates its byte-shorthand minimum at construction (InvalidConditionalConfigurationException), so a typo'd gate can no longer degrade to always-met. Service-trio trim: HooksService goes final readonly with handlers fixed at construction (the Scheduler pattern); register_handler() and get_handler() are gone; BufferedHookHandler gains DEFAULT_ID. AdminNotice's bool props drop the is_ prefix (dismissible, persistent). ObjectCache::delete() reports bool; both cache generation options use the *_generation vocabulary. Request::wp_parse_args_recursive moves to Arrays::parse_args_recursive (Request.php held nothing else and is deleted). Conditionals ctor summaries normalize to "Constructor.". Assisted-by: Claude Code:claude-fable-5 --- .../AdminNotices/AdminNoticeLogger.php | 38 +++++---- .../AdminNotices/AdminNoticesService.php | 38 +++++---- .../DependencyAdminNoticeRenderer.php | 4 +- .../InvalidNoticeIdentifierException.php | 13 +++ .../UnknownNoticeStoreException.php | 13 +++ .../AdminNotices/ValueObjects/AdminNotice.php | 34 ++++---- .../src/Utilities/Caching/ObjectCache.php | 6 +- .../src/Utilities/Caching/TransientCache.php | 2 +- .../Context/CurrentUserCanConditional.php | 2 +- .../Conditionals/Context/IsCliConditional.php | 2 +- .../PHPExtensionLoadedConditional.php | 2 +- .../PHPFunctionExistsConditional.php | 2 +- .../Dependencies/PHPIniSizeConditional.php | 20 ++++- .../Dependencies/PHPVersionConditional.php | 2 +- .../WPPluginActiveConditional.php | 2 +- .../WPPluginVersionConditional.php | 2 +- .../Dependencies/WPVersionConditional.php | 2 +- ...validConditionalConfigurationException.php | 14 ++++ .../Conditionals/Exceptions/index.php | 1 + .../src/Utilities/Helpers/Arrays.php | 34 +++++++- .../src/Utilities/Helpers/Request.php | 47 ----------- .../UnknownHookHandlerException.php | 13 +++ .../src/Utilities/Hooks/Exceptions/index.php | 1 + .../Hooks/Handlers/BufferedHookHandler.php | 28 ++++++- .../Hooks/Handlers/DirectHookHandler.php | 13 ++- .../Hooks/Handlers/ScopedHookHandler.php | 32 ++++--- .../Utilities/Hooks/HookHandlerInterface.php | 11 +++ .../src/Utilities/Hooks/HooksService.php | 60 ++++++------- .../src/Utilities/Logging/CompositeLogger.php | 4 - .../src/Utilities/Logging/RedactingLogger.php | 4 - .../Backends/ActionSchedulerBackend.php | 2 +- .../Scheduling/Backends/WPCronBackend.php | 4 +- .../src/Utilities/Scheduling/Scheduler.php | 4 +- .../Scheduling/SchedulerBackendInterface.php | 2 +- .../AdminNotices/AdminNoticeLoggerTest.php | 2 +- .../AdminNotices/AdminNoticesServiceTest.php | 46 +++------- .../DependencyAdminNoticeRendererTest.php | 8 +- .../Integration/Caching/ObjectCacheTest.php | 8 +- .../Caching/TransientCacheTest.php | 6 +- .../Hooks/Handlers/ScopedHookHandlerTest.php | 18 ++-- .../Scheduling/Backends/WPCronBackendTest.php | 6 +- .../Integration/Scheduling/SchedulerTest.php | 6 +- .../AdminNotices/AdminNoticeLoggerTest.php | 10 ++- .../AdminNotices/AdminNoticesServiceTest.php | 20 ++++- .../Unit/AdminNotices/NoticeStoreTest.php | 2 +- .../ValueObjects/AdminNoticeTest.php | 84 +++++++++---------- .../PHPIniSizeConditionalTest.php | 71 ++++++++++++++++ .../Utilities/Unit/Helpers/ArraysTest.php | 52 ++++++++++++ .../Utilities/Unit/Helpers/RequestTest.php | 62 -------------- .../Handlers/BufferedHookHandlerTest.php | 3 +- .../Utilities/Unit/Hooks/HooksServiceTest.php | 28 ++++--- .../Unit/Scheduling/SchedulerTest.php | 6 +- 52 files changed, 540 insertions(+), 356 deletions(-) create mode 100644 packages/infrastructure/src/Utilities/AdminNotices/Exceptions/InvalidNoticeIdentifierException.php create mode 100644 packages/infrastructure/src/Utilities/AdminNotices/Exceptions/UnknownNoticeStoreException.php create mode 100644 packages/infrastructure/src/Utilities/Conditionals/Exceptions/InvalidConditionalConfigurationException.php create mode 100644 packages/infrastructure/src/Utilities/Conditionals/Exceptions/index.php delete mode 100644 packages/infrastructure/src/Utilities/Helpers/Request.php create mode 100644 packages/infrastructure/src/Utilities/Hooks/Exceptions/UnknownHookHandlerException.php create mode 100644 packages/infrastructure/src/Utilities/Hooks/Exceptions/index.php create mode 100644 packages/infrastructure/tests/Utilities/Unit/Conditionals/Dependencies/PHPIniSizeConditionalTest.php delete mode 100644 packages/infrastructure/tests/Utilities/Unit/Helpers/RequestTest.php diff --git a/packages/infrastructure/src/Utilities/AdminNotices/AdminNoticeLogger.php b/packages/infrastructure/src/Utilities/AdminNotices/AdminNoticeLogger.php index b4c0d13..d183fc2 100644 --- a/packages/infrastructure/src/Utilities/AdminNotices/AdminNoticeLogger.php +++ b/packages/infrastructure/src/Utilities/AdminNotices/AdminNoticeLogger.php @@ -2,6 +2,8 @@ namespace DeepWebSolutions\Framework\Utilities\AdminNotices; +use DeepWebSolutions\Framework\Utilities\AdminNotices\Exceptions\InvalidNoticeIdentifierException; +use DeepWebSolutions\Framework\Utilities\AdminNotices\Exceptions\UnknownNoticeStoreException; use DeepWebSolutions\Framework\Utilities\AdminNotices\ValueObjects\AdminNotice; use Psr\Log\InvalidArgumentException; use Psr\Log\LoggerInterface; @@ -57,14 +59,16 @@ * @since 2.0.0 * @version 2.0.0 * - * @param AdminNoticesService $service Service the notice is queued through. - * @param string $notice_id Stable ID every admitted record is queued under; must be sanitize_key-stable so AJAX dismissal round-trips. - * @param string $store Name of the service store to queue in; a persistent store surfaces the notice on a later request. - * @param string $minimum_level Lowest PSR-3 level that produces a notice; records below it are dropped. - * @param string $capability Capability required to see the notice. - * @param bool $is_dismissible Whether the notice shows a dismiss button. + * @param AdminNoticesService $service Service the notice is queued through. + * @param string $notice_id Stable ID every admitted record is queued under; must be sanitize_key-stable so AJAX dismissal round-trips. + * @param string $store Name of the service store to queue in; a persistent store surfaces the notice on a later request. + * @param string $minimum_level Lowest PSR-3 level that produces a notice; records below it are dropped. + * @param string $capability Capability required to see the notice. + * @param bool $dismissible Whether the notice shows a dismiss button. * - * @throws InvalidArgumentException When $notice_id is not sanitize_key-stable, $minimum_level is not a PSR-3 level, or $store is not registered on the service. + * @throws InvalidNoticeIdentifierException When $notice_id is not sanitize_key-stable. + * @throws InvalidArgumentException When $minimum_level is not a PSR-3 level. + * @throws UnknownNoticeStoreException When $store is not registered on the service. */ public function __construct( protected AdminNoticesService $service, @@ -72,23 +76,25 @@ public function __construct( protected string $store = AdminNoticesService::DEFAULT_STORE, protected string $minimum_level = LogLevel::ERROR, protected string $capability = 'manage_options', - protected bool $is_dismissible = false, + protected bool $dismissible = false, ) { if ( ! namespace\is_valid_notice_id( $this->notice_id ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- framework-internal exception; never reaches an HTML output context unescaped. - throw new InvalidArgumentException( 'Invalid notice id: ' . $this->notice_id . '. Use a sanitize_key-stable id (lowercase a-z, 0-9, _, -) so AJAX dismissal round-trips.' ); + throw new InvalidNoticeIdentifierException( "Invalid notice id: '$this->notice_id'. Use a sanitize_key-stable id (lowercase a-z, 0-9, _, -) so AJAX dismissal round-trips." ); } + // The minimum level is PSR-3 vocabulary, so its rejection stays the PSR-3 exception type, + // matching the level validation log() itself performs. if ( ! isset( self::SEVERITIES[ $this->minimum_level ] ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- framework-internal exception; never reaches an HTML output context unescaped. - throw new InvalidArgumentException( 'Unknown minimum log level: ' . $this->minimum_level ); + throw new InvalidArgumentException( "Unknown minimum log level: '$this->minimum_level'." ); } - // Validate the target store up front: a misnamed store would otherwise route every record to a - // _doing_it_wrong() no-op in production, silently dropping the very failures this logger surfaces. + // Validate the target store up front: a misnamed store would otherwise surface only when the + // first record is queued — inside the fail-closed kernel boot this logger exists to report. if ( ! isset( $this->service->stores[ $this->store ] ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- framework-internal exception; never reaches an HTML output context unescaped. - throw new InvalidArgumentException( 'Unknown notice store: ' . $this->store ); + throw new UnknownNoticeStoreException( "No notice store is registered under name '$this->store'." ); } } @@ -109,7 +115,7 @@ public function log( $level, string|\Stringable $message, array $context = array $level_key = \is_string( $level ) ? $level : ''; if ( ! isset( self::SEVERITIES[ $level_key ] ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- framework-internal exception; never reaches an HTML output context unescaped. - throw new InvalidArgumentException( 'Unknown log level: ' . ( \is_scalar( $level ) ? (string) $level : \gettype( $level ) ) ); + throw new InvalidArgumentException( \sprintf( "Unknown log level: '%s'.", \is_scalar( $level ) ? (string) $level : \gettype( $level ) ) ); } if ( self::SEVERITIES[ $level_key ] < self::SEVERITIES[ $this->minimum_level ] ) { @@ -121,8 +127,8 @@ public function log( $level, string|\Stringable $message, array $context = array id: $this->notice_id, message: $this->interpolate( (string) $message, $context ), type: $this->notice_type( $level_key ), - is_dismissible: $this->is_dismissible, - is_persistent: true, + dismissible: $this->dismissible, + persistent: true, capability: $this->capability, ), $this->store, diff --git a/packages/infrastructure/src/Utilities/AdminNotices/AdminNoticesService.php b/packages/infrastructure/src/Utilities/AdminNotices/AdminNoticesService.php index d4399af..5acb83c 100644 --- a/packages/infrastructure/src/Utilities/AdminNotices/AdminNoticesService.php +++ b/packages/infrastructure/src/Utilities/AdminNotices/AdminNoticesService.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\Framework\Utilities\AdminNotices; +use DeepWebSolutions\Framework\Utilities\AdminNotices\Exceptions\UnknownNoticeStoreException; use DeepWebSolutions\Framework\Utilities\AdminNotices\ValueObjects\AdminNotice; use DeepWebSolutions\Framework\Utilities\Exceptions\InvalidGlobalNamePrefixException; use DeepWebSolutions\Framework\Storage\MemoryStore; @@ -88,15 +89,13 @@ public function register_hooks(): void { * * @param AdminNotice $notice Notice to queue. * @param string $store Name of the store to queue it in. Defaults to DEFAULT_STORE. + * + * @throws UnknownNoticeStoreException When no store is registered under $store. */ public function add_notice( AdminNotice $notice, string $store = self::DEFAULT_STORE ): void { if ( ! isset( $this->stores[ $store ] ) ) { - \_doing_it_wrong( - __METHOD__, - \esc_html( \sprintf( 'Unknown notice store "%s"; the notice was not queued.', $store ) ), - '2.0.0' - ); - return; + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- framework-internal exception; never reaches an HTML output context unescaped. + throw new UnknownNoticeStoreException( "No notice store is registered under name '$store'." ); } $this->stores[ $store ]->add( $notice ); @@ -111,11 +110,18 @@ public function add_notice( AdminNotice $notice, string $store = self::DEFAULT_S * @param string $id ID of the notice to remove. * @param string|null $store Store to remove it from, or null to search every store. * + * @throws UnknownNoticeStoreException When no store is registered under an explicit $store. + * * @return bool True if a notice was removed from any store, false otherwise. */ public function remove_notice( string $id, ?string $store = null ): bool { if ( null !== $store ) { - return isset( $this->stores[ $store ] ) && $this->stores[ $store ]->remove( $id ); + if ( ! isset( $this->stores[ $store ] ) ) { + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- framework-internal exception; never reaches an HTML output context unescaped. + throw new UnknownNoticeStoreException( "No notice store is registered under name '$store'." ); + } + + return $this->stores[ $store ]->remove( $id ); } $removed = false; @@ -127,6 +133,10 @@ public function remove_notice( string $id, ?string $store = null ): bool { return $removed; } + // endregion + + // region HOOKS + /** * Render every queued notice the current user may see. Hook this onto the `admin_notices` action. * @@ -144,23 +154,19 @@ public function render_notices(): void { continue; } - $suppressed = $notice->is_persistent && $notice->is_dismissible + $suppressed = $notice->persistent && $notice->dismissible && true === $this->dismissals?->is_dismissed( $notice->id ); if ( ! $suppressed ) { $this->render_one( $notice ); } - if ( ! $notice->is_persistent ) { + if ( ! $notice->persistent ) { $store->remove( $notice->id ); } } } } - // endregion - - // region HOOKS - /** * Prints the inline script that turns a notice's dismiss button into a persisted, per-user dismissal. * Hook onto `admin_footer`. No-op unless a dismiss action and a tracker are both configured. The @@ -239,7 +245,7 @@ public function handle_dismiss(): void { protected function is_dismissible_notice_known_to_current_user( string $id ): bool { foreach ( $this->stores as $store ) { $notice = $store->get( $id ); - if ( null !== $notice && $notice->is_persistent && $notice->is_dismissible && \current_user_can( $notice->capability ) ) { + if ( null !== $notice && $notice->persistent && $notice->dismissible && \current_user_can( $notice->capability ) ) { return true; } } @@ -260,7 +266,7 @@ protected function render_one( AdminNotice $notice ): void { // The transport marker only goes on notices a dismissal would actually suppress (persistent + // dismissible), so clicking a one-shot's dismiss never records a stale, never-consulted row. if ( null !== $this->dismiss_action && null !== $this->dismissals - && $notice->is_persistent && $notice->is_dismissible + && $notice->persistent && $notice->dismissible ) { $data_attributes['data-dismiss-action'] = $this->dismiss_action; } @@ -270,7 +276,7 @@ protected function render_one( AdminNotice $notice ): void { array( 'id' => 'dws-notice-' . $notice->id, 'type' => $notice->type->value, - 'dismissible' => $notice->is_dismissible, + 'dismissible' => $notice->dismissible, 'paragraph_wrap' => true, 'attributes' => $data_attributes, ), diff --git a/packages/infrastructure/src/Utilities/AdminNotices/DependencyAdminNoticeRenderer.php b/packages/infrastructure/src/Utilities/AdminNotices/DependencyAdminNoticeRenderer.php index 575a6d6..1edd052 100644 --- a/packages/infrastructure/src/Utilities/AdminNotices/DependencyAdminNoticeRenderer.php +++ b/packages/infrastructure/src/Utilities/AdminNotices/DependencyAdminNoticeRenderer.php @@ -118,8 +118,8 @@ protected function build_notice( DependencyRequirement $requirement ): AdminNoti id: $requirement->get_notice_id(), message: $this->build_message( $requirement ), type: $requirement->get_notice_type(), - is_dismissible: $requirement->is_dismissible(), - is_persistent: $requirement->is_persistent(), + dismissible: $requirement->is_dismissible(), + persistent: $requirement->is_persistent(), capability: $this->capability, ); } diff --git a/packages/infrastructure/src/Utilities/AdminNotices/Exceptions/InvalidNoticeIdentifierException.php b/packages/infrastructure/src/Utilities/AdminNotices/Exceptions/InvalidNoticeIdentifierException.php new file mode 100644 index 0000000..13aa317 --- /dev/null +++ b/packages/infrastructure/src/Utilities/AdminNotices/Exceptions/InvalidNoticeIdentifierException.php @@ -0,0 +1,13 @@ + $this->id, - 'message' => $this->message, - 'type' => $this->type->value, - 'is_dismissible' => $this->is_dismissible, - 'is_persistent' => $this->is_persistent, - 'capability' => $this->capability, + 'id' => $this->id, + 'message' => $this->message, + 'type' => $this->type->value, + 'dismissible' => $this->dismissible, + 'persistent' => $this->persistent, + 'capability' => $this->capability, ); } @@ -103,8 +103,8 @@ public static function from_array( array $data ): self { id: \is_string( $data['id'] ?? null ) ? $data['id'] : '', message: \is_string( $data['message'] ?? null ) ? $data['message'] : '', type: $type, - is_dismissible: \is_bool( $data['is_dismissible'] ?? null ) ? $data['is_dismissible'] : true, - is_persistent: \is_bool( $data['is_persistent'] ?? null ) ? $data['is_persistent'] : false, + dismissible: \is_bool( $data['dismissible'] ?? null ) ? $data['dismissible'] : true, + persistent: \is_bool( $data['persistent'] ?? null ) ? $data['persistent'] : false, capability: \is_string( $data['capability'] ?? null ) ? $data['capability'] : 'manage_options', ); } diff --git a/packages/infrastructure/src/Utilities/Caching/ObjectCache.php b/packages/infrastructure/src/Utilities/Caching/ObjectCache.php index ba564b2..4f8f81d 100644 --- a/packages/infrastructure/src/Utilities/Caching/ObjectCache.php +++ b/packages/infrastructure/src/Utilities/Caching/ObjectCache.php @@ -81,9 +81,11 @@ public function set( string $key, mixed $value ): void { * @version 2.0.0 * * @param string $key Key to delete. + * + * @return bool True when an entry existed and was removed, false otherwise. */ - public function delete( string $key ): void { - \wp_cache_delete( $key, $this->effective_group() ); + public function delete( string $key ): bool { + return \wp_cache_delete( $key, $this->effective_group() ); } /** diff --git a/packages/infrastructure/src/Utilities/Caching/TransientCache.php b/packages/infrastructure/src/Utilities/Caching/TransientCache.php index c95678a..8d3ab6e 100644 --- a/packages/infrastructure/src/Utilities/Caching/TransientCache.php +++ b/packages/infrastructure/src/Utilities/Caching/TransientCache.php @@ -252,7 +252,7 @@ protected function suffix(): int { * @return string */ protected function suffix_key(): string { - return $this->key_prefix . '_cache_invalidation_suffix'; + return $this->key_prefix . '_transient_cache_generation'; } /** diff --git a/packages/infrastructure/src/Utilities/Conditionals/Context/CurrentUserCanConditional.php b/packages/infrastructure/src/Utilities/Conditionals/Context/CurrentUserCanConditional.php index cb71680..dab172a 100644 --- a/packages/infrastructure/src/Utilities/Conditionals/Context/CurrentUserCanConditional.php +++ b/packages/infrastructure/src/Utilities/Conditionals/Context/CurrentUserCanConditional.php @@ -18,7 +18,7 @@ // region MAGIC METHODS /** - * Constructs the conditional with the capability to probe. + * Constructor. * * @since 2.0.0 * @version 2.0.0 diff --git a/packages/infrastructure/src/Utilities/Conditionals/Context/IsCliConditional.php b/packages/infrastructure/src/Utilities/Conditionals/Context/IsCliConditional.php index baac054..583ccec 100644 --- a/packages/infrastructure/src/Utilities/Conditionals/Context/IsCliConditional.php +++ b/packages/infrastructure/src/Utilities/Conditionals/Context/IsCliConditional.php @@ -14,7 +14,7 @@ // region MAGIC METHODS /** - * Constructs the conditional, capturing the SAPI name to compare against. + * Constructor. * * @since 2.0.0 * @version 2.0.0 diff --git a/packages/infrastructure/src/Utilities/Conditionals/Dependencies/PHPExtensionLoadedConditional.php b/packages/infrastructure/src/Utilities/Conditionals/Dependencies/PHPExtensionLoadedConditional.php index e6a5c68..602f2e5 100644 --- a/packages/infrastructure/src/Utilities/Conditionals/Dependencies/PHPExtensionLoadedConditional.php +++ b/packages/infrastructure/src/Utilities/Conditionals/Dependencies/PHPExtensionLoadedConditional.php @@ -14,7 +14,7 @@ // region MAGIC METHODS /** - * Constructs the conditional with the extension name to probe. + * Constructor. * * @since 2.0.0 * @version 2.0.0 diff --git a/packages/infrastructure/src/Utilities/Conditionals/Dependencies/PHPFunctionExistsConditional.php b/packages/infrastructure/src/Utilities/Conditionals/Dependencies/PHPFunctionExistsConditional.php index 17cdd05..3f701e7 100644 --- a/packages/infrastructure/src/Utilities/Conditionals/Dependencies/PHPFunctionExistsConditional.php +++ b/packages/infrastructure/src/Utilities/Conditionals/Dependencies/PHPFunctionExistsConditional.php @@ -14,7 +14,7 @@ // region MAGIC METHODS /** - * Constructs the conditional with the function name to probe. + * Constructor. * * @since 2.0.0 * @version 2.0.0 diff --git a/packages/infrastructure/src/Utilities/Conditionals/Dependencies/PHPIniSizeConditional.php b/packages/infrastructure/src/Utilities/Conditionals/Dependencies/PHPIniSizeConditional.php index b80d1e6..a747398 100644 --- a/packages/infrastructure/src/Utilities/Conditionals/Dependencies/PHPIniSizeConditional.php +++ b/packages/infrastructure/src/Utilities/Conditionals/Dependencies/PHPIniSizeConditional.php @@ -3,6 +3,7 @@ namespace DeepWebSolutions\Framework\Utilities\Conditionals\Dependencies; use DeepWebSolutions\Framework\Core\Conditional\ConditionalInterface; +use DeepWebSolutions\Framework\Utilities\Conditionals\Exceptions\InvalidConditionalConfigurationException; /** * Pre-resolution gate that passes iff a size-valued PHP ini directive provides at least the @@ -17,18 +18,33 @@ // region MAGIC METHODS /** - * Constructs the conditional with the ini directive name and minimum size. + * Constructor. * * @since 2.0.0 * @version 2.0.0 * * @param string $setting PHP ini directive name (e.g., `memory_limit`). * @param string $minimum Minimum size as byte shorthand (e.g., `128M`, `1G`). + * + * @throws InvalidConditionalConfigurationException When $setting is empty or $minimum is not integer byte shorthand. */ public function __construct( protected string $setting, protected string $minimum, - ) {} + ) { + if ( '' === \trim( $setting ) ) { + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- framework-internal exception; never reaches an HTML output context unescaped. + throw new InvalidConditionalConfigurationException( "Invalid ini directive name: '$setting'. Use a non-empty PHP ini directive name (e.g. 'memory_limit')." ); + } + + // The accepted grammar is the well-formed subset of what wp_convert_hr_to_bytes() parses + // (leading digits with an optional single k/m/g multiplier), so is_met() compares exactly + // the bytes the minimum spells out. + if ( 1 !== \preg_match( '/^\d+[kmgKMG]?$/', \trim( $minimum ) ) ) { + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- framework-internal exception; never reaches an HTML output context unescaped. + throw new InvalidConditionalConfigurationException( "Invalid ini size minimum: '$minimum'. Use integer byte shorthand — digits with an optional k/m/g suffix (e.g. '128M', '1g')." ); + } + } // endregion diff --git a/packages/infrastructure/src/Utilities/Conditionals/Dependencies/PHPVersionConditional.php b/packages/infrastructure/src/Utilities/Conditionals/Dependencies/PHPVersionConditional.php index 557817b..e13e7e4 100644 --- a/packages/infrastructure/src/Utilities/Conditionals/Dependencies/PHPVersionConditional.php +++ b/packages/infrastructure/src/Utilities/Conditionals/Dependencies/PHPVersionConditional.php @@ -15,7 +15,7 @@ // region MAGIC METHODS /** - * Constructs the conditional with the minimum PHP version required. + * Constructor. * * @since 2.0.0 * @version 2.0.0 diff --git a/packages/infrastructure/src/Utilities/Conditionals/Dependencies/WPPluginActiveConditional.php b/packages/infrastructure/src/Utilities/Conditionals/Dependencies/WPPluginActiveConditional.php index d3cc006..bf2cdb2 100644 --- a/packages/infrastructure/src/Utilities/Conditionals/Dependencies/WPPluginActiveConditional.php +++ b/packages/infrastructure/src/Utilities/Conditionals/Dependencies/WPPluginActiveConditional.php @@ -15,7 +15,7 @@ // region MAGIC METHODS /** - * Constructs the conditional with the plugin basename to probe. + * Constructor. * * @since 2.0.0 * @version 2.0.0 diff --git a/packages/infrastructure/src/Utilities/Conditionals/Dependencies/WPPluginVersionConditional.php b/packages/infrastructure/src/Utilities/Conditionals/Dependencies/WPPluginVersionConditional.php index 687fc66..d0f1f49 100644 --- a/packages/infrastructure/src/Utilities/Conditionals/Dependencies/WPPluginVersionConditional.php +++ b/packages/infrastructure/src/Utilities/Conditionals/Dependencies/WPPluginVersionConditional.php @@ -16,7 +16,7 @@ // region MAGIC METHODS /** - * Constructs the conditional with the plugin basename to probe and the minimum version required. + * Constructor. * * @since 2.0.0 * @version 2.0.0 diff --git a/packages/infrastructure/src/Utilities/Conditionals/Dependencies/WPVersionConditional.php b/packages/infrastructure/src/Utilities/Conditionals/Dependencies/WPVersionConditional.php index ed8ca36..d2e1e39 100644 --- a/packages/infrastructure/src/Utilities/Conditionals/Dependencies/WPVersionConditional.php +++ b/packages/infrastructure/src/Utilities/Conditionals/Dependencies/WPVersionConditional.php @@ -15,7 +15,7 @@ // region MAGIC METHODS /** - * Constructs the conditional with the minimum WordPress version required. + * Constructor. * * @since 2.0.0 * @version 2.0.0 diff --git a/packages/infrastructure/src/Utilities/Conditionals/Exceptions/InvalidConditionalConfigurationException.php b/packages/infrastructure/src/Utilities/Conditionals/Exceptions/InvalidConditionalConfigurationException.php new file mode 100644 index 0000000..5d987fe --- /dev/null +++ b/packages/infrastructure/src/Utilities/Conditionals/Exceptions/InvalidConditionalConfigurationException.php @@ -0,0 +1,14 @@ + $args Provided arguments. + * @param array $defaults Default arguments. + * + * @return array + */ + public static function parse_args_recursive( array $args, array $defaults ): array { + $result = $defaults; + + foreach ( $args as $key => $value ) { + if ( + \is_array( $value ) + && isset( $result[ $key ] ) + && \is_array( $result[ $key ] ) + && ! \array_is_list( $value ) + && ! \array_is_list( $result[ $key ] ) + ) { + $result[ $key ] = self::parse_args_recursive( $value, $result[ $key ] ); + continue; + } + + $result[ $key ] = $value; + } + + return $result; + } + /** * Inserts entries after a key while preserving associative keys. * diff --git a/packages/infrastructure/src/Utilities/Helpers/Request.php b/packages/infrastructure/src/Utilities/Helpers/Request.php deleted file mode 100644 index 7bcab5c..0000000 --- a/packages/infrastructure/src/Utilities/Helpers/Request.php +++ /dev/null @@ -1,47 +0,0 @@ - $args Provided arguments. - * @param array $defaults Default arguments. - * - * @return array - */ - public static function wp_parse_args_recursive( array $args, array $defaults ): array { - $result = $defaults; - - foreach ( $args as $key => $value ) { - if ( - \is_array( $value ) - && isset( $result[ $key ] ) - && \is_array( $result[ $key ] ) - && ! \array_is_list( $value ) - && ! \array_is_list( $result[ $key ] ) - ) { - $result[ $key ] = self::wp_parse_args_recursive( $value, $result[ $key ] ); - continue; - } - - $result[ $key ] = $value; - } - - return $result; - } - - // endregion -} diff --git a/packages/infrastructure/src/Utilities/Hooks/Exceptions/UnknownHookHandlerException.php b/packages/infrastructure/src/Utilities/Hooks/Exceptions/UnknownHookHandlerException.php new file mode 100644 index 0000000..4203a05 --- /dev/null +++ b/packages/infrastructure/src/Utilities/Hooks/Exceptions/UnknownHookHandlerException.php @@ -0,0 +1,13 @@ +registry->clear_filters(); } + /** + * {@inheritDoc} + * + * The buffer is driven by explicit {@see self::flush()} / {@see self::reset()} calls + * (or a composing ScopedHookHandler), so there is nothing to self-wire. + * + * @since 2.0.0 + * @version 2.0.0 + */ + #[\Override] + public function register_hooks(): void {} + // endregion // region METHODS diff --git a/packages/infrastructure/src/Utilities/Hooks/Handlers/DirectHookHandler.php b/packages/infrastructure/src/Utilities/Hooks/Handlers/DirectHookHandler.php index c0d1db3..896dea6 100644 --- a/packages/infrastructure/src/Utilities/Hooks/Handlers/DirectHookHandler.php +++ b/packages/infrastructure/src/Utilities/Hooks/Handlers/DirectHookHandler.php @@ -36,7 +36,7 @@ * @version 2.0.0 * * @param string $id Handler ID. Defaults to 'direct'. - * @param HookRegistry $registry Internal record store, exposed for inspection and for HooksService composition. Defaults to a fresh HookRegistry. + * @param HookRegistry $registry Internal record store tracking every registration so remove_all_* can revert exhaustively. Defaults to a fresh HookRegistry. */ public function __construct( #[\Override] public string $id = self::DEFAULT_ID, @@ -125,5 +125,16 @@ public function remove_all_filters(): void { $this->registry->clear_filters(); } + /** + * {@inheritDoc} + * + * Direct passthrough registration needs no self-wiring. + * + * @since 2.0.0 + * @version 2.0.0 + */ + #[\Override] + public function register_hooks(): void {} + // endregion } diff --git a/packages/infrastructure/src/Utilities/Hooks/Handlers/ScopedHookHandler.php b/packages/infrastructure/src/Utilities/Hooks/Handlers/ScopedHookHandler.php index d53383c..2f00448 100644 --- a/packages/infrastructure/src/Utilities/Hooks/Handlers/ScopedHookHandler.php +++ b/packages/infrastructure/src/Utilities/Hooks/Handlers/ScopedHookHandler.php @@ -16,13 +16,27 @@ * Use this when a hook should only be live during a specific phase — e.g., admin-only * filters that should not affect the front end, or hooks scoped to a single REST request. * - * Wire the start/end lifecycle by calling {@see self::register_lifecycle()} after + * Wire the start/end lifecycle by calling {@see self::register_hooks()} after * construction; this keeps the constructor side-effect-free for testability. * * @since 2.0.0 * @version 2.0.0 */ final readonly class ScopedHookHandler implements HookHandlerInterface { + // region FIELDS AND CONSTANTS + + /** + * Default handler ID of the internally-composed buffered handler. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @var string + */ + public const DEFAULT_BUFFER_ID = 'scoped-buffer'; + + // endregion + // region MAGIC METHODS /** @@ -40,7 +54,7 @@ public function __construct( #[\Override] public string $id, public string $start_hook, public string $end_hook = '', - public BufferedHookHandler $buffer = new BufferedHookHandler( 'scoped-buffer', new HookRegistry() ), + public BufferedHookHandler $buffer = new BufferedHookHandler( self::DEFAULT_BUFFER_ID, new HookRegistry() ), ) {} // endregion @@ -113,15 +127,12 @@ public function remove_all_filters(): void { $this->buffer->remove_all_filters(); } - // endregion - - // region METHODS - /** - * Wire the start and end WordPress hooks to flush and reset the buffer. + * {@inheritDoc} * - * The flush/reset callbacks are stable [object, method] pairs, so WordPress - * de-duplicates repeat registrations and calling this more than once is harmless. + * Wires the start and end WordPress hooks to flush and reset the buffer. The flush/reset + * callbacks are stable [object, method] pairs, so WordPress de-duplicates repeat + * registrations and calling this more than once is harmless. * * The handler runs a single start->end cycle: reset() empties the queue, so a second * start_hook firing re-registers nothing. Re-queue hooks after a reset for repeat use. @@ -129,7 +140,8 @@ public function remove_all_filters(): void { * @since 2.0.0 * @version 2.0.0 */ - public function register_lifecycle(): void { + #[\Override] + public function register_hooks(): void { \add_action( $this->start_hook, array( $this->buffer, 'flush' ), 10, 0 ); if ( '' !== $this->end_hook ) { \add_action( $this->end_hook, array( $this->buffer, 'reset' ), 10, 0 ); diff --git a/packages/infrastructure/src/Utilities/Hooks/HookHandlerInterface.php b/packages/infrastructure/src/Utilities/Hooks/HookHandlerInterface.php index dd76acd..984173c 100644 --- a/packages/infrastructure/src/Utilities/Hooks/HookHandlerInterface.php +++ b/packages/infrastructure/src/Utilities/Hooks/HookHandlerInterface.php @@ -97,4 +97,15 @@ public function remove_all_actions(): void; * @version 2.0.0 */ public function remove_all_filters(): void; + + /** + * Wires any one-time WordPress self-wiring the handler needs before use. + * + * A consumer calls this once during boot. A handler with nothing to wire implements + * it empty; the scoped handler registers its start and end lifecycle hooks here. + * + * @since 2.0.0 + * @version 2.0.0 + */ + public function register_hooks(): void; } diff --git a/packages/infrastructure/src/Utilities/Hooks/HooksService.php b/packages/infrastructure/src/Utilities/Hooks/HooksService.php index 87f3ef2..25e8b0e 100644 --- a/packages/infrastructure/src/Utilities/Hooks/HooksService.php +++ b/packages/infrastructure/src/Utilities/Hooks/HooksService.php @@ -2,26 +2,26 @@ namespace DeepWebSolutions\Framework\Utilities\Hooks; +use DeepWebSolutions\Framework\Utilities\Hooks\Exceptions\UnknownHookHandlerException; use DeepWebSolutions\Framework\Utilities\Hooks\Handlers\DirectHookHandler; -use OutOfBoundsException; /** * Multi-handler hook registration facade. * - * Holds a registry of HookHandlerInterface instances keyed by ID. Registration calls - * accept an optional handler_id and route to the named handler. The constructor - * parameter default supplies a single DirectHookHandler under the 'direct' ID — an - * omitted argument yields it, while an explicit empty array registers no handlers — - * and 'direct' is the handler used when no handler_id is specified. + * Holds a map of HookHandlerInterface instances keyed by handler ID, fixed at + * construction — the consumer states its handlers, matching the Scheduler's + * consumer-states-its-backends pattern. Registration calls accept an optional + * handler_id and route to the named handler. The constructor parameter default + * supplies a single DirectHookHandler under the 'direct' ID — an omitted argument + * yields it, while an explicit empty array registers no handlers — and 'direct' is + * the handler used when no handler_id is specified. * * Components inject HooksService and call add_action() etc. to register hook callbacks. - * Plugins that need buffered or scoped registration register additional handlers via - * {@see self::register_handler()} during plugin boot. * * @since 2.0.0 * @version 2.0.0 */ -final class HooksService { +final readonly class HooksService { // region FIELDS AND CONSTANTS /** @@ -32,7 +32,7 @@ final class HooksService { * * @var array */ - protected(set) array $handlers = array(); + public array $handlers; // endregion @@ -48,12 +48,15 @@ final class HooksService { * @since 2.0.0 * @version 2.0.0 * - * @param array $initial_handlers Handlers to register up front. Defaults to a single DirectHookHandler. + * @param array $initial_handlers Handlers to register, keyed into the map by each handler's ID. Defaults to a single DirectHookHandler. */ public function __construct( array $initial_handlers = array( new DirectHookHandler() ) ) { + $handlers = array(); foreach ( $initial_handlers as $handler ) { - $this->register_handler( $handler ); + $handlers[ $handler->id ] = $handler; } + + $this->handlers = $handlers; } // endregion @@ -61,30 +64,17 @@ public function __construct( array $initial_handlers = array( new DirectHookHand // region METHODS /** - * Register a handler with the service. Subsequent calls naming this handler's ID - * will route through it. + * Wires every registered handler's one-time WordPress self-wiring, so one consumer + * call during boot prepares each handler (a scoped handler's start/end lifecycle, + * say) before components register hooks through the service. * * @since 2.0.0 * @version 2.0.0 - * - * @param HookHandlerInterface $handler Handler to register. */ - public function register_handler( HookHandlerInterface $handler ): void { - $this->handlers[ $handler->id ] = $handler; - } - - /** - * Look up a registered handler by ID. - * - * @since 2.0.0 - * @version 2.0.0 - * - * @param string $id Handler ID. - * - * @return HookHandlerInterface|null - */ - public function get_handler( string $id ): ?HookHandlerInterface { - return $this->handlers[ $id ] ?? null; + public function register_hooks(): void { + foreach ( $this->handlers as $handler ) { + $handler->register_hooks(); + } } /** @@ -191,13 +181,13 @@ public function remove_all_filters( string $handler_id = DirectHookHandler::DEFA * * @return HookHandlerInterface * - * @throws OutOfBoundsException When no handler is registered under $id. + * @throws UnknownHookHandlerException When no handler is registered under $id. */ protected function resolve_handler( string $id ): HookHandlerInterface { - $handler = $this->get_handler( $id ); + $handler = $this->handlers[ $id ] ?? null; if ( null === $handler ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- framework-internal exception; never reaches an HTML output context unescaped. - throw new OutOfBoundsException( "No hook handler is registered under id '$id'." ); + throw new UnknownHookHandlerException( "No hook handler is registered under id '$id'." ); } return $handler; } diff --git a/packages/infrastructure/src/Utilities/Logging/CompositeLogger.php b/packages/infrastructure/src/Utilities/Logging/CompositeLogger.php index bf3546c..5c22dd0 100644 --- a/packages/infrastructure/src/Utilities/Logging/CompositeLogger.php +++ b/packages/infrastructure/src/Utilities/Logging/CompositeLogger.php @@ -64,10 +64,6 @@ public function __construct( LoggerInterface ...$loggers ) { * @since 2.0.0 * @version 2.0.0 * - * @param mixed $level Log level. - * @param string|\Stringable $message Log message. - * @param array $context Log context. - * * @throws \Throwable The first failure thrown by a delegate logger. */ #[\Override] diff --git a/packages/infrastructure/src/Utilities/Logging/RedactingLogger.php b/packages/infrastructure/src/Utilities/Logging/RedactingLogger.php index 36b64f3..761e329 100644 --- a/packages/infrastructure/src/Utilities/Logging/RedactingLogger.php +++ b/packages/infrastructure/src/Utilities/Logging/RedactingLogger.php @@ -62,10 +62,6 @@ public function __construct( * * @since 2.0.0 * @version 2.0.0 - * - * @param mixed $level Log level. - * @param string|\Stringable $message Log message. - * @param array $context Log context. */ #[\Override] public function log( $level, string|\Stringable $message, array $context = array() ): void { diff --git a/packages/infrastructure/src/Utilities/Scheduling/Backends/ActionSchedulerBackend.php b/packages/infrastructure/src/Utilities/Scheduling/Backends/ActionSchedulerBackend.php index 788582b..a9f329f 100644 --- a/packages/infrastructure/src/Utilities/Scheduling/Backends/ActionSchedulerBackend.php +++ b/packages/infrastructure/src/Utilities/Scheduling/Backends/ActionSchedulerBackend.php @@ -202,7 +202,7 @@ public function is_ready(): bool { * @version 2.0.0 */ #[\Override] - public function register_lifecycle(): void {} + public function register_hooks(): void {} // endregion diff --git a/packages/infrastructure/src/Utilities/Scheduling/Backends/WPCronBackend.php b/packages/infrastructure/src/Utilities/Scheduling/Backends/WPCronBackend.php index 20a20ae..3069278 100644 --- a/packages/infrastructure/src/Utilities/Scheduling/Backends/WPCronBackend.php +++ b/packages/infrastructure/src/Utilities/Scheduling/Backends/WPCronBackend.php @@ -18,7 +18,7 @@ * each distinct interval gets a synthetic schedule 'dws_every_{N}s' injected through the * 'cron_schedules' filter. WordPress resolves that schedule again whenever it reschedules the * event — on a request that never touches this backend, wp-cron included — so the caller wires - * the filter through {@see self::register_lifecycle()} on each load, independent of any schedule + * the filter through {@see self::register_hooks()} on each load, independent of any schedule * call, and its callback rebuilds the interval set from the cron array so an event scheduled on * an earlier request still resolves. WordPress cron has no grouping, so a non-empty group is * rejected on schedule writes and treated as absent by read and clear paths. @@ -220,7 +220,7 @@ public function is_ready(): bool { * @version 2.0.0 */ #[\Override] - public function register_lifecycle(): void { + public function register_hooks(): void { $this->ensure_filter_registered(); } diff --git a/packages/infrastructure/src/Utilities/Scheduling/Scheduler.php b/packages/infrastructure/src/Utilities/Scheduling/Scheduler.php index 4782f1c..e946d22 100644 --- a/packages/infrastructure/src/Utilities/Scheduling/Scheduler.php +++ b/packages/infrastructure/src/Utilities/Scheduling/Scheduler.php @@ -171,9 +171,9 @@ public function is_ready(): bool { * @version 2.0.0 */ #[\Override] - public function register_lifecycle(): void { + public function register_hooks(): void { foreach ( $this->backends as $backend ) { - $backend->register_lifecycle(); + $backend->register_hooks(); } } diff --git a/packages/infrastructure/src/Utilities/Scheduling/SchedulerBackendInterface.php b/packages/infrastructure/src/Utilities/Scheduling/SchedulerBackendInterface.php index 0774af1..4b45cdf 100644 --- a/packages/infrastructure/src/Utilities/Scheduling/SchedulerBackendInterface.php +++ b/packages/infrastructure/src/Utilities/Scheduling/SchedulerBackendInterface.php @@ -117,5 +117,5 @@ public function is_ready(): bool; * @since 2.0.0 * @version 2.0.0 */ - public function register_lifecycle(): void; + public function register_hooks(): void; } diff --git a/packages/infrastructure/tests/Utilities/Integration/AdminNotices/AdminNoticeLoggerTest.php b/packages/infrastructure/tests/Utilities/Integration/AdminNotices/AdminNoticeLoggerTest.php index 414f2c1..8e3b9fd 100644 --- a/packages/infrastructure/tests/Utilities/Integration/AdminNotices/AdminNoticeLoggerTest.php +++ b/packages/infrastructure/tests/Utilities/Integration/AdminNotices/AdminNoticeLoggerTest.php @@ -67,7 +67,7 @@ public function test_a_failed_install_surfaces_a_persistent_admin_notice(): void // The notice persisted to wp_options, so a fresh service — a later request — still reads it. $notice = $this->notice_service()->stores['options']->get( self::NOTICE_ID ); self::assertNotNull( $notice ); - self::assertTrue( $notice->is_persistent ); + self::assertTrue( $notice->persistent ); self::assertSame( NoticeType::Error, $notice->type ); // And it renders for a capable admin. diff --git a/packages/infrastructure/tests/Utilities/Integration/AdminNotices/AdminNoticesServiceTest.php b/packages/infrastructure/tests/Utilities/Integration/AdminNotices/AdminNoticesServiceTest.php index 3f6e636..a44fe29 100644 --- a/packages/infrastructure/tests/Utilities/Integration/AdminNotices/AdminNoticesServiceTest.php +++ b/packages/infrastructure/tests/Utilities/Integration/AdminNotices/AdminNoticesServiceTest.php @@ -74,7 +74,7 @@ public function test_skips_notice_for_user_without_capability_and_leaves_it_queu public function test_non_persistent_notice_is_consumed_after_rendering_once(): void { $this->user_meta_service()->add_notice( - new AdminNotice( 'flash', 'Saved.', NoticeType::Success, is_persistent: false ), + new AdminNotice( 'flash', 'Saved.', NoticeType::Success, persistent: false ), 'user-meta', ); @@ -89,7 +89,7 @@ public function test_non_persistent_notice_is_consumed_after_rendering_once(): v public function test_persistent_notice_recurs_until_dismissed(): void { $this->user_meta_service()->add_notice( - new AdminNotice( 'setup', 'Finish setup.', NoticeType::Warning, is_persistent: true ), + new AdminNotice( 'setup', 'Finish setup.', NoticeType::Warning, persistent: true ), 'user-meta', ); @@ -109,7 +109,7 @@ public function test_one_shot_id_reuse_is_not_suppressed_by_a_prior_dismissal(): $service = $this->user_meta_service(); $service->add_notice( - new AdminNotice( 'reused', 'A brand-new error.', NoticeType::Error, is_persistent: false ), + new AdminNotice( 'reused', 'A brand-new error.', NoticeType::Error, persistent: false ), 'user-meta', ); @@ -124,7 +124,7 @@ public function test_site_wide_dismissal_is_per_user(): void { $this->tracker(), ); $service->add_notice( - new AdminNotice( 'wc_missing', 'WooCommerce is required.', NoticeType::Error, is_persistent: true ), + new AdminNotice( 'wc_missing', 'WooCommerce is required.', NoticeType::Error, persistent: true ), 'options', ); @@ -162,7 +162,7 @@ public function test_site_wide_one_shot_is_consumed_by_the_first_viewer(): void // belong in a per-user store. This test characterizes the single-request behavior. $service = $this->options_service(); $service->add_notice( - new AdminNotice( 'broadcast', 'Seen once, by whoever is first.', NoticeType::Info, is_persistent: false ), + new AdminNotice( 'broadcast', 'Seen once, by whoever is first.', NoticeType::Info, persistent: false ), 'options', ); @@ -173,30 +173,10 @@ public function test_site_wide_one_shot_is_consumed_by_the_first_viewer(): void self::assertSame( '', $this->capture_render( $this->options_service() ) ); } - public function test_unknown_store_triggers_doing_it_wrong_and_stores_nothing(): void { - $fired = 0; - $spy = static function () use ( &$fired ) { - ++$fired; - }; - \add_filter( 'doing_it_wrong_trigger_error', '__return_false' ); - \add_action( 'doing_it_wrong_run', $spy ); - - try { - $service = new AdminNoticesService(); - $service->add_notice( new AdminNotice( 'x', 'msg' ), 'nope' ); - - self::assertGreaterThan( 0, $fired ); - self::assertSame( '', $this->capture_render( $service ) ); - } finally { - \remove_action( 'doing_it_wrong_run', $spy ); - \remove_filter( 'doing_it_wrong_trigger_error', '__return_false' ); - } - } - public function test_render_emits_notice_id_and_scopes_dismiss_action_when_configured(): void { $with = $this->transport_service(); $with->add_notice( - new AdminNotice( 'setup', 'Configure me.', NoticeType::Warning, is_persistent: true ), + new AdminNotice( 'setup', 'Configure me.', NoticeType::Warning, persistent: true ), 'user-meta', ); $with_output = $this->capture_render( $with ); @@ -277,7 +257,7 @@ public function test_register_hooks_without_a_dismiss_action_wires_no_ajax_endpo public function test_handle_dismiss_records_dismissal_with_a_valid_nonce(): void { $this->transport_service()->add_notice( - new AdminNotice( 'dep_woocommerce', 'WooCommerce is required.', NoticeType::Error, is_persistent: true ), + new AdminNotice( 'dep_woocommerce', 'WooCommerce is required.', NoticeType::Error, persistent: true ), 'user-meta', ); $_REQUEST['_wpnonce'] = \wp_create_nonce( self::DISMISS_ACTION ); @@ -328,7 +308,7 @@ public function test_handle_dismiss_ignores_a_well_formed_but_never_rendered_id( public function test_handle_dismiss_ignores_a_known_non_persistent_notice(): void { $this->transport_service()->add_notice( - new AdminNotice( 'flash_notice', 'Saved.', NoticeType::Success, is_persistent: false ), + new AdminNotice( 'flash_notice', 'Saved.', NoticeType::Success, persistent: false ), 'user-meta', ); $_REQUEST['_wpnonce'] = \wp_create_nonce( self::DISMISS_ACTION ); @@ -341,7 +321,7 @@ public function test_handle_dismiss_ignores_a_known_non_persistent_notice(): voi public function test_handle_dismiss_ignores_a_known_non_dismissible_notice(): void { $this->transport_service()->add_notice( - new AdminNotice( 'fixed_notice', 'Fixed.', NoticeType::Info, is_dismissible: false, is_persistent: true ), + new AdminNotice( 'fixed_notice', 'Fixed.', NoticeType::Info, dismissible: false, persistent: true ), 'user-meta', ); $_REQUEST['_wpnonce'] = \wp_create_nonce( self::DISMISS_ACTION ); @@ -354,7 +334,7 @@ public function test_handle_dismiss_ignores_a_known_non_dismissible_notice(): vo public function test_handle_dismiss_ignores_a_known_notice_the_current_user_cannot_see(): void { $this->options_transport_service()->add_notice( - new AdminNotice( 'admin_only', 'Admins only.', NoticeType::Warning, is_persistent: true, capability: 'manage_options' ), + new AdminNotice( 'admin_only', 'Admins only.', NoticeType::Warning, persistent: true, capability: 'manage_options' ), 'options', ); @@ -378,7 +358,7 @@ public function test_handle_dismiss_without_a_transport_records_nothing(): void public function test_endpoint_dismissal_suppresses_the_notice_on_the_next_render(): void { $this->transport_service()->add_notice( - new AdminNotice( 'dep_wc', 'WooCommerce is required.', NoticeType::Error, is_persistent: true ), + new AdminNotice( 'dep_wc', 'WooCommerce is required.', NoticeType::Error, persistent: true ), 'user-meta', ); @@ -398,11 +378,11 @@ public function test_endpoint_dismissal_suppresses_the_notice_on_the_next_render public function test_render_scopes_dismiss_action_only_for_sticky_notices(): void { $service = $this->transport_service(); $service->add_notice( - new AdminNotice( 'sticky_dep', 'Sticky.', NoticeType::Warning, is_dismissible: true, is_persistent: true ), + new AdminNotice( 'sticky_dep', 'Sticky.', NoticeType::Warning, dismissible: true, persistent: true ), 'user-meta', ); $service->add_notice( - new AdminNotice( 'flash_msg', 'Flash.', NoticeType::Info, is_dismissible: true, is_persistent: false ), + new AdminNotice( 'flash_msg', 'Flash.', NoticeType::Info, dismissible: true, persistent: false ), 'user-meta', ); diff --git a/packages/infrastructure/tests/Utilities/Integration/AdminNotices/DependencyAdminNoticeRendererTest.php b/packages/infrastructure/tests/Utilities/Integration/AdminNotices/DependencyAdminNoticeRendererTest.php index 8874f0b..3f2c57f 100644 --- a/packages/infrastructure/tests/Utilities/Integration/AdminNotices/DependencyAdminNoticeRendererTest.php +++ b/packages/infrastructure/tests/Utilities/Integration/AdminNotices/DependencyAdminNoticeRendererTest.php @@ -76,8 +76,8 @@ public function test_unmet_required_dependency_queues_a_blocking_error(): void { self::assertInstanceOf( AdminNotice::class, $notice ); self::assertSame( NoticeType::Error, $notice->type ); - self::assertFalse( $notice->is_dismissible ); - self::assertFalse( $notice->is_persistent ); + self::assertFalse( $notice->dismissible ); + self::assertFalse( $notice->persistent ); self::assertSame( 'activate_plugins', $notice->capability ); self::assertSame( 'Linked Orders requires WooCommerce to be active.', $notice->message ); } @@ -94,8 +94,8 @@ public function test_unmet_optional_dependency_queues_a_dismissible_warning(): v self::assertInstanceOf( AdminNotice::class, $notice ); self::assertSame( NoticeType::Warning, $notice->type ); - self::assertTrue( $notice->is_dismissible ); - self::assertTrue( $notice->is_persistent ); + self::assertTrue( $notice->dismissible ); + self::assertTrue( $notice->persistent ); self::assertSame( 'Jetpack is recommended for Linked Orders.', $notice->message ); } diff --git a/packages/infrastructure/tests/Utilities/Integration/Caching/ObjectCacheTest.php b/packages/infrastructure/tests/Utilities/Integration/Caching/ObjectCacheTest.php index 458a3a1..5fff379 100644 --- a/packages/infrastructure/tests/Utilities/Integration/Caching/ObjectCacheTest.php +++ b/packages/infrastructure/tests/Utilities/Integration/Caching/ObjectCacheTest.php @@ -119,12 +119,18 @@ public function test_delete_removes_one_key_without_flushing_the_group(): void { $cache->set( 'a', 1 ); $cache->set( 'b', 2 ); - $cache->delete( 'a' ); + self::assertTrue( $cache->delete( 'a' ) ); self::assertSame( 'gone', $cache->get( 'a', 'gone' ) ); self::assertSame( 2, $cache->get( 'b' ) ); } + public function test_delete_returns_false_for_an_absent_key(): void { + $cache = new ObjectCache( self::GROUP ); + + self::assertFalse( $cache->delete( 'never-stored' ) ); + } + public function test_remember_does_not_let_a_value_survive_a_flush_during_its_callback(): void { $cache = new ObjectCache( self::GROUP ); diff --git a/packages/infrastructure/tests/Utilities/Integration/Caching/TransientCacheTest.php b/packages/infrastructure/tests/Utilities/Integration/Caching/TransientCacheTest.php index 52be745..6cb7624 100644 --- a/packages/infrastructure/tests/Utilities/Integration/Caching/TransientCacheTest.php +++ b/packages/infrastructure/tests/Utilities/Integration/Caching/TransientCacheTest.php @@ -286,11 +286,11 @@ public function test_flush_generation_suffix_is_not_autoloaded(): void { \wp_cache_delete( 'alloptions', 'options' ); - self::assertArrayNotHasKey( self::PREFIX . '_cache_invalidation_suffix', \wp_load_alloptions() ); + self::assertArrayNotHasKey( self::PREFIX . '_transient_cache_generation', \wp_load_alloptions() ); } public function test_a_corrupt_suffix_degrades_to_generation_one(): void { - \update_option( self::PREFIX . '_cache_invalidation_suffix', 'foo' ); + \update_option( self::PREFIX . '_transient_cache_generation', 'foo' ); $cache = new TransientCache( self::PREFIX ); $cache->set( 'k', 'v', HOUR_IN_SECONDS ); @@ -353,6 +353,6 @@ private function purge( string $prefix ): void { ) ); - \delete_option( $prefix . '_cache_invalidation_suffix' ); + \delete_option( $prefix . '_transient_cache_generation' ); } } diff --git a/packages/infrastructure/tests/Utilities/Integration/Hooks/Handlers/ScopedHookHandlerTest.php b/packages/infrastructure/tests/Utilities/Integration/Hooks/Handlers/ScopedHookHandlerTest.php index d58ef66..0d3d5a6 100644 --- a/packages/infrastructure/tests/Utilities/Integration/Hooks/Handlers/ScopedHookHandlerTest.php +++ b/packages/infrastructure/tests/Utilities/Integration/Hooks/Handlers/ScopedHookHandlerTest.php @@ -36,7 +36,7 @@ public function test_lifecycle_flushes_on_start_and_resets_on_end(): void { $handler = new ScopedHookHandler( 'scoped-test', 'dws_scope_start', 'dws_scope_end' ); $cb = static function (): void {}; $handler->add_action( 'dws_scoped_target', $cb, 10, 1 ); - $handler->register_lifecycle(); + $handler->register_hooks(); // Nothing registered until the start hook fires. self::assertFalse( \has_action( 'dws_scoped_target', $cb ) ); @@ -52,23 +52,23 @@ public function test_without_end_hook_registrations_persist_after_start(): void $handler = new ScopedHookHandler( 'scoped-persist', 'dws_scope_start_2' ); $cb = static function (): void {}; $handler->add_action( 'dws_scoped_target_2', $cb, 10, 1 ); - $handler->register_lifecycle(); + $handler->register_hooks(); \do_action( 'dws_scope_start_2' ); self::assertNotFalse( \has_action( 'dws_scoped_target_2', $cb ) ); } - public function test_register_lifecycle_is_idempotent(): void { + public function test_register_hooks_is_idempotent(): void { $handler = new ScopedHookHandler( 'scoped-idem', 'dws_scope_idem_start' ); $count = 0; $cb = static function () use ( &$count ): void { ++$count; }; $handler->add_action( 'dws_scope_idem_target', $cb, 10, 1 ); - $handler->register_lifecycle(); - $handler->register_lifecycle(); + $handler->register_hooks(); + $handler->register_hooks(); - // Two register_lifecycle calls wire a single flush (stable callbacks de-dup), so one + // Two register_hooks calls wire a single flush (stable callbacks de-dup), so one // start fire registers the queued callback exactly once — it runs once when fired. \do_action( 'dws_scope_idem_start' ); \do_action( 'dws_scope_idem_target' ); @@ -80,7 +80,7 @@ public function test_lifecycle_flushes_and_resets_scoped_filters(): void { $handler = new ScopedHookHandler( 'scoped-filter', 'dws_scope_filter_start', 'dws_scope_filter_end' ); $cb = static fn ( $v ) => $v; $handler->add_filter( 'dws_scoped_filter_target', $cb, 10, 1 ); - $handler->register_lifecycle(); + $handler->register_hooks(); // Nothing registered until the start hook fires. self::assertFalse( \has_filter( 'dws_scoped_filter_target', $cb ) ); @@ -98,7 +98,7 @@ public function test_remove_all_actions_clears_scoped_action_queue(): void { $handler->add_action( 'dws_scope_rm_target', $cb, 10, 1 ); $handler->remove_all_actions(); - $handler->register_lifecycle(); + $handler->register_hooks(); \do_action( 'dws_scope_rm_start' ); // The queue was emptied before flush, so the start hook registers nothing. @@ -111,7 +111,7 @@ public function test_remove_all_filters_clears_scoped_filter_queue(): void { $handler->add_filter( 'dws_scoped_filter_rm_target', $cb, 10, 1 ); $handler->remove_all_filters(); - $handler->register_lifecycle(); + $handler->register_hooks(); \do_action( 'dws_scope_filter_rm_start' ); // The queue was emptied before flush, so the start hook registers nothing. diff --git a/packages/infrastructure/tests/Utilities/Integration/Scheduling/Backends/WPCronBackendTest.php b/packages/infrastructure/tests/Utilities/Integration/Scheduling/Backends/WPCronBackendTest.php index 1afa116..eaf9002 100644 --- a/packages/infrastructure/tests/Utilities/Integration/Scheduling/Backends/WPCronBackendTest.php +++ b/packages/infrastructure/tests/Utilities/Integration/Scheduling/Backends/WPCronBackendTest.php @@ -210,9 +210,9 @@ public function test_register_synthetic_schedules_returns_the_same_schedules_acr self::assertSame( $first, $second ); } - public function test_register_lifecycle_wires_the_schedule_filter_without_a_schedule_call(): void { + public function test_register_hooks_wires_the_schedule_filter_without_a_schedule_call(): void { $backend = $this->backend(); - $backend->register_lifecycle(); + $backend->register_hooks(); // The filter must be present on a request that only boots — wp-cron itself never schedules — // so a recurring event's named schedule resolves when WordPress reschedules it. @@ -237,7 +237,7 @@ public function test_wp_reschedule_event_resolves_the_reconstructed_schedule(): // Simulate a later request: drop the scheduling backend's filter, then wire only the lifecycle. \remove_filter( 'cron_schedules', array( $scheduler, 'register_synthetic_schedules' ) ); - $this->backend()->register_lifecycle(); + $this->backend()->register_hooks(); // wp_reschedule_event re-validates the schedule name through wp_get_schedules(); the reconstructed // 'dws_every_300s' must resolve, or it returns a WP_Error and WordPress drops the recurring event. diff --git a/packages/infrastructure/tests/Utilities/Integration/Scheduling/SchedulerTest.php b/packages/infrastructure/tests/Utilities/Integration/Scheduling/SchedulerTest.php index 591ea92..d1c67a2 100644 --- a/packages/infrastructure/tests/Utilities/Integration/Scheduling/SchedulerTest.php +++ b/packages/infrastructure/tests/Utilities/Integration/Scheduling/SchedulerTest.php @@ -119,7 +119,7 @@ public function test_get_next_scheduled_returns_the_earliest_timestamp_across_bo self::assertSame( $wp_cron_timestamp, new Scheduler( array( new ActionSchedulerBackend(), new WPCronBackend() ) )->get_next_scheduled( self::HOOK ) ); } - public function test_register_lifecycle_through_the_facade_reconstructs_a_wp_cron_recurrence(): void { + public function test_register_hooks_through_the_facade_reconstructs_a_wp_cron_recurrence(): void { // Schedule a recurring event straight on a WordPress cron backend, then drop that backend's own // filter to model a later request where only the lifecycle is wired. The interval is unique to // this test so no other registered 'cron_schedules' callback can resolve the synthetic schedule. @@ -128,9 +128,9 @@ public function test_register_lifecycle_through_the_facade_reconstructs_a_wp_cro \remove_filter( 'cron_schedules', array( $backend, 'register_synthetic_schedules' ) ); self::assertArrayNotHasKey( 'dws_every_271s', \wp_get_schedules() ); - new Scheduler( array( new ActionSchedulerBackend(), new WPCronBackend() ) )->register_lifecycle(); + new Scheduler( array( new ActionSchedulerBackend(), new WPCronBackend() ) )->register_hooks(); - // The facade fans register_lifecycle out to its WordPress cron backend, which rebuilds the synthetic + // The facade fans register_hooks out to its WordPress cron backend, which rebuilds the synthetic // schedule from the cron array so WordPress can reschedule the recurring event. self::assertArrayHasKey( 'dws_every_271s', \wp_get_schedules() ); } diff --git a/packages/infrastructure/tests/Utilities/Unit/AdminNotices/AdminNoticeLoggerTest.php b/packages/infrastructure/tests/Utilities/Unit/AdminNotices/AdminNoticeLoggerTest.php index 818d616..0f3d528 100644 --- a/packages/infrastructure/tests/Utilities/Unit/AdminNotices/AdminNoticeLoggerTest.php +++ b/packages/infrastructure/tests/Utilities/Unit/AdminNotices/AdminNoticeLoggerTest.php @@ -4,6 +4,8 @@ use DeepWebSolutions\Framework\Utilities\AdminNotices\AdminNoticeLogger; use DeepWebSolutions\Framework\Utilities\AdminNotices\AdminNoticesService; +use DeepWebSolutions\Framework\Utilities\AdminNotices\Exceptions\InvalidNoticeIdentifierException; +use DeepWebSolutions\Framework\Utilities\AdminNotices\Exceptions\UnknownNoticeStoreException; use DeepWebSolutions\Framework\Utilities\AdminNotices\NoticeStore; use DeepWebSolutions\Framework\Utilities\AdminNotices\NoticeType; use DeepWebSolutions\Framework\Utilities\AdminNotices\ValueObjects\AdminNotice; @@ -33,8 +35,8 @@ public function test_error_record_queues_a_persistent_notice(): void { self::assertNotNull( $notice ); self::assertSame( 'Installation failed', $notice->message ); self::assertSame( NoticeType::Error, $notice->type ); - self::assertTrue( $notice->is_persistent ); - self::assertFalse( $notice->is_dismissible ); + self::assertTrue( $notice->persistent ); + self::assertFalse( $notice->dismissible ); } public function test_records_below_the_threshold_are_dropped(): void { @@ -169,13 +171,13 @@ public function test_an_unknown_minimum_level_is_rejected_at_construction(): voi public function test_an_unknown_store_is_rejected_at_construction(): void { $service = new AdminNoticesService( array( 'failures' => new NoticeStore( new MemoryStore() ) ) ); - $this->expectException( InvalidArgumentException::class ); + $this->expectException( UnknownNoticeStoreException::class ); new AdminNoticeLogger( $service, 'x', 'typo-store' ); } public function test_an_unstable_notice_id_is_rejected_at_construction(): void { - $this->expectException( InvalidArgumentException::class ); + $this->expectException( InvalidNoticeIdentifierException::class ); new AdminNoticeLogger( $this->service_with( new NoticeStore( new MemoryStore() ) ), 'Bad.Id', 'failures' ); } diff --git a/packages/infrastructure/tests/Utilities/Unit/AdminNotices/AdminNoticesServiceTest.php b/packages/infrastructure/tests/Utilities/Unit/AdminNotices/AdminNoticesServiceTest.php index e6859fe..e96a33d 100644 --- a/packages/infrastructure/tests/Utilities/Unit/AdminNotices/AdminNoticesServiceTest.php +++ b/packages/infrastructure/tests/Utilities/Unit/AdminNotices/AdminNoticesServiceTest.php @@ -3,6 +3,7 @@ namespace DeepWebSolutions\Framework\Utilities\Tests\Unit\AdminNotices; use DeepWebSolutions\Framework\Utilities\AdminNotices\AdminNoticesService; +use DeepWebSolutions\Framework\Utilities\AdminNotices\Exceptions\UnknownNoticeStoreException; use DeepWebSolutions\Framework\Utilities\AdminNotices\NoticeStore; use DeepWebSolutions\Framework\Utilities\AdminNotices\ValueObjects\AdminNotice; use DeepWebSolutions\Framework\Utilities\AdminNotices\NoticeType; @@ -58,6 +59,22 @@ public function test_add_notice_routes_to_a_named_store(): void { self::assertNull( $service->stores['memory']->get( 'x' ) ); } + public function test_add_notice_throws_on_an_unknown_store(): void { + $service = new AdminNoticesService(); + + $this->expectException( UnknownNoticeStoreException::class ); + + $service->add_notice( new AdminNotice( 'x', 'msg' ), 'typo-store' ); + } + + public function test_remove_notice_throws_on_an_unknown_named_store(): void { + $service = new AdminNoticesService(); + + $this->expectException( UnknownNoticeStoreException::class ); + + $service->remove_notice( 'x', 'typo-store' ); + } + public function test_remove_notice_from_a_named_store(): void { $service = new AdminNoticesService(); $service->add_notice( new AdminNotice( 'x', 'msg' ) ); @@ -95,9 +112,6 @@ public function test_remove_notice_named_targets_only_that_store(): void { $service->add_notice( new AdminNotice( 'x', 'msg' ), 'memory' ); $service->add_notice( new AdminNotice( 'x', 'msg' ), 'extra' ); - // An unknown store name on removal is a benign false (no _doing_it_wrong, unlike add_notice). - self::assertFalse( $service->remove_notice( 'x', 'user-meta' ) ); - self::assertTrue( $service->remove_notice( 'x', 'memory' ) ); self::assertFalse( $service->stores['memory']->has( 'x' ) ); self::assertTrue( $service->stores['extra']->has( 'x' ) ); diff --git a/packages/infrastructure/tests/Utilities/Unit/AdminNotices/NoticeStoreTest.php b/packages/infrastructure/tests/Utilities/Unit/AdminNotices/NoticeStoreTest.php index 590817b..213269e 100644 --- a/packages/infrastructure/tests/Utilities/Unit/AdminNotices/NoticeStoreTest.php +++ b/packages/infrastructure/tests/Utilities/Unit/AdminNotices/NoticeStoreTest.php @@ -23,7 +23,7 @@ final class NoticeStoreTest extends TestCase { public function test_add_then_get_round_trips_the_notice(): void { $store = new NoticeStore( new MemoryStore() ); - $notice = new AdminNotice( 'welcome', 'Hello', NoticeType::Success, is_persistent: true ); + $notice = new AdminNotice( 'welcome', 'Hello', NoticeType::Success, persistent: true ); $store->add( $notice ); diff --git a/packages/infrastructure/tests/Utilities/Unit/AdminNotices/ValueObjects/AdminNoticeTest.php b/packages/infrastructure/tests/Utilities/Unit/AdminNotices/ValueObjects/AdminNoticeTest.php index aa4c4d3..64024b8 100644 --- a/packages/infrastructure/tests/Utilities/Unit/AdminNotices/ValueObjects/AdminNoticeTest.php +++ b/packages/infrastructure/tests/Utilities/Unit/AdminNotices/ValueObjects/AdminNoticeTest.php @@ -28,8 +28,8 @@ public function test_constructs_with_required_arguments(): void { self::assertSame( 'my-notice', $notice->id ); self::assertSame( 'Hello world.', $notice->message ); self::assertSame( NoticeType::Info, $notice->type ); - self::assertTrue( $notice->is_dismissible ); - self::assertFalse( $notice->is_persistent ); + self::assertTrue( $notice->dismissible ); + self::assertFalse( $notice->persistent ); self::assertSame( 'manage_options', $notice->capability ); } @@ -38,16 +38,16 @@ public function test_constructs_with_all_arguments(): void { id: 'critical', message: 'Bad.', type: NoticeType::Error, - is_dismissible: false, - is_persistent: true, + dismissible: false, + persistent: true, capability: 'activate_plugins', ); self::assertSame( 'critical', $notice->id ); self::assertSame( 'Bad.', $notice->message ); self::assertSame( NoticeType::Error, $notice->type ); - self::assertFalse( $notice->is_dismissible ); - self::assertTrue( $notice->is_persistent ); + self::assertFalse( $notice->dismissible ); + self::assertTrue( $notice->persistent ); self::assertSame( 'activate_plugins', $notice->capability ); } @@ -55,8 +55,8 @@ public function test_capability_is_the_sixth_positional_argument(): void { $notice = new AdminNotice( 'id', 'msg', NoticeType::Warning, false, true, 'edit_posts' ); self::assertSame( 'edit_posts', $notice->capability ); - self::assertTrue( $notice->is_persistent ); - self::assertFalse( $notice->is_dismissible ); + self::assertTrue( $notice->persistent ); + self::assertFalse( $notice->dismissible ); } public function test_to_array_emits_all_fields_with_type_as_backing_string(): void { @@ -64,19 +64,19 @@ public function test_to_array_emits_all_fields_with_type_as_backing_string(): vo id: 'id1', message: 'msg', type: NoticeType::Warning, - is_dismissible: false, - is_persistent: true, + dismissible: false, + persistent: true, capability: 'edit_posts', ); self::assertSame( array( - 'id' => 'id1', - 'message' => 'msg', - 'type' => 'warning', - 'is_dismissible' => false, - 'is_persistent' => true, - 'capability' => 'edit_posts', + 'id' => 'id1', + 'message' => 'msg', + 'type' => 'warning', + 'dismissible' => false, + 'persistent' => true, + 'capability' => 'edit_posts', ), $notice->to_array(), ); @@ -85,20 +85,20 @@ public function test_to_array_emits_all_fields_with_type_as_backing_string(): vo public function test_from_array_reconstructs_notice(): void { $notice = AdminNotice::from_array( array( - 'id' => 'id1', - 'message' => 'msg', - 'type' => 'error', - 'is_dismissible' => false, - 'is_persistent' => true, - 'capability' => 'manage_woocommerce', + 'id' => 'id1', + 'message' => 'msg', + 'type' => 'error', + 'dismissible' => false, + 'persistent' => true, + 'capability' => 'manage_woocommerce', ), ); self::assertSame( 'id1', $notice->id ); self::assertSame( 'msg', $notice->message ); self::assertSame( NoticeType::Error, $notice->type ); - self::assertFalse( $notice->is_dismissible ); - self::assertTrue( $notice->is_persistent ); + self::assertFalse( $notice->dismissible ); + self::assertTrue( $notice->persistent ); self::assertSame( 'manage_woocommerce', $notice->capability ); } @@ -107,8 +107,8 @@ public function test_to_array_from_array_round_trips_field_for_field(): void { id: 'x', message: '', type: NoticeType::Success, - is_dismissible: false, - is_persistent: true, + dismissible: false, + persistent: true, capability: 'manage_woocommerce', ); @@ -161,8 +161,8 @@ public function test_from_array_absent_flags_fall_back_to_constructor_defaults() ) ); - self::assertTrue( $notice->is_dismissible ); - self::assertFalse( $notice->is_persistent ); + self::assertTrue( $notice->dismissible ); + self::assertFalse( $notice->persistent ); self::assertSame( 'manage_options', $notice->capability ); } @@ -171,15 +171,15 @@ public function test_from_array_falls_back_to_defaults_for_non_bool_flags(): voi // (bool) cast would wrongly make the notice persistent. Non-bools fall back to the defaults. $notice = AdminNotice::from_array( array( - 'id' => 'x', - 'message' => 'm', - 'is_dismissible' => 0, - 'is_persistent' => 'false', + 'id' => 'x', + 'message' => 'm', + 'dismissible' => 0, + 'persistent' => 'false', ), ); - self::assertTrue( $notice->is_dismissible ); - self::assertFalse( $notice->is_persistent ); + self::assertTrue( $notice->dismissible ); + self::assertFalse( $notice->persistent ); } #[DataProvider( 'unstable_ids' )] @@ -248,8 +248,8 @@ public function test_equals_is_false_when_any_attribute_differs(): void { self::assertFalse( $base->equals( new AdminNotice( 'y', 'msg' ) ) ); self::assertFalse( $base->equals( new AdminNotice( 'x', 'other' ) ) ); self::assertFalse( $base->equals( new AdminNotice( 'x', 'msg', NoticeType::Error ) ) ); - self::assertFalse( $base->equals( new AdminNotice( 'x', 'msg', is_dismissible: false ) ) ); - self::assertFalse( $base->equals( new AdminNotice( 'x', 'msg', is_persistent: true ) ) ); + self::assertFalse( $base->equals( new AdminNotice( 'x', 'msg', dismissible: false ) ) ); + self::assertFalse( $base->equals( new AdminNotice( 'x', 'msg', persistent: true ) ) ); self::assertFalse( $base->equals( new AdminNotice( 'x', 'msg', capability: 'edit_posts' ) ) ); } @@ -258,12 +258,12 @@ public function test_json_serialize_reduces_the_type_to_its_backing_string(): vo self::assertSame( array( - 'id' => 'id1', - 'message' => 'msg', - 'type' => 'warning', - 'is_dismissible' => false, - 'is_persistent' => true, - 'capability' => 'edit_posts', + 'id' => 'id1', + 'message' => 'msg', + 'type' => 'warning', + 'dismissible' => false, + 'persistent' => true, + 'capability' => 'edit_posts', ), $notice->jsonSerialize(), ); diff --git a/packages/infrastructure/tests/Utilities/Unit/Conditionals/Dependencies/PHPIniSizeConditionalTest.php b/packages/infrastructure/tests/Utilities/Unit/Conditionals/Dependencies/PHPIniSizeConditionalTest.php new file mode 100644 index 0000000..7f80494 --- /dev/null +++ b/packages/infrastructure/tests/Utilities/Unit/Conditionals/Dependencies/PHPIniSizeConditionalTest.php @@ -0,0 +1,71 @@ +expectNotToPerformAssertions(); + + new PHPIniSizeConditional( 'memory_limit', $minimum ); + } + + /** + * @return array + */ + public static function valid_minimums(): array { + return array( + 'plain digits' => array( '512' ), + 'kilo lowercase' => array( '64k' ), + 'mega uppercase' => array( '128M' ), + 'giga lowercase' => array( '1g' ), + 'surrounding whitespace' => array( ' 128M ' ), + ); + } + + #[DataProvider( 'invalid_minimums' )] + public function test_rejects_a_malformed_minimum( string $minimum ): void { + $this->expectException( InvalidConditionalConfigurationException::class ); + + new PHPIniSizeConditional( 'memory_limit', $minimum ); + } + + /** + * @return array + */ + public static function invalid_minimums(): array { + return array( + 'empty' => array( '' ), + 'whitespace only' => array( ' ' ), + 'suffix only' => array( 'M' ), + 'fractional' => array( '1.5G' ), + 'inner space' => array( '12 M' ), + 'byte word' => array( '128MB' ), + 'negative' => array( '-1' ), + 'double suffix' => array( '1gg' ), + ); + } + + public function test_rejects_an_empty_directive_name(): void { + $this->expectException( InvalidConditionalConfigurationException::class ); + + new PHPIniSizeConditional( '', '128M' ); + } + + public function test_rejects_a_whitespace_only_directive_name(): void { + $this->expectException( InvalidConditionalConfigurationException::class ); + + new PHPIniSizeConditional( ' ', '128M' ); + } +} diff --git a/packages/infrastructure/tests/Utilities/Unit/Helpers/ArraysTest.php b/packages/infrastructure/tests/Utilities/Unit/Helpers/ArraysTest.php index 77dc654..e7c08be 100644 --- a/packages/infrastructure/tests/Utilities/Unit/Helpers/ArraysTest.php +++ b/packages/infrastructure/tests/Utilities/Unit/Helpers/ArraysTest.php @@ -8,6 +8,58 @@ #[CoversClass( Arrays::class )] final class ArraysTest extends TestCase { + public function test_parse_args_recursive_merges_nested_associative_arrays(): void { + self::assertSame( + array( + 'display' => array( + 'mode' => 'compact', + 'limit' => 10, + ), + 'enabled' => true, + ), + Arrays::parse_args_recursive( + array( 'display' => array( 'mode' => 'compact' ) ), + array( + 'display' => array( + 'mode' => 'full', + 'limit' => 10, + ), + 'enabled' => true, + ), + ), + ); + } + + public function test_parse_args_recursive_treats_list_arrays_as_leaf_values(): void { + self::assertSame( + array( 'ids' => array( 3 ) ), + Arrays::parse_args_recursive( + array( 'ids' => array( 3 ) ), + array( 'ids' => array( 1, 2 ) ), + ), + ); + } + + public function test_parse_args_recursive_adds_unknown_argument_keys(): void { + self::assertSame( + array( + 'a' => 'default', + 'b' => 'provided', + ), + Arrays::parse_args_recursive( array( 'b' => 'provided' ), array( 'a' => 'default' ) ), + ); + } + + public function test_parse_args_recursive_lets_a_scalar_argument_replace_an_array_default(): void { + self::assertSame( + array( 'display' => 'compact' ), + Arrays::parse_args_recursive( + array( 'display' => 'compact' ), + array( 'display' => array( 'mode' => 'full' ) ), + ), + ); + } + public function test_insert_after_preserves_associative_keys_and_order(): void { self::assertSame( array( diff --git a/packages/infrastructure/tests/Utilities/Unit/Helpers/RequestTest.php b/packages/infrastructure/tests/Utilities/Unit/Helpers/RequestTest.php deleted file mode 100644 index e40ff8e..0000000 --- a/packages/infrastructure/tests/Utilities/Unit/Helpers/RequestTest.php +++ /dev/null @@ -1,62 +0,0 @@ - array( - 'mode' => 'compact', - 'limit' => 10, - ), - 'enabled' => true, - ), - Request::wp_parse_args_recursive( - array( 'display' => array( 'mode' => 'compact' ) ), - array( - 'display' => array( - 'mode' => 'full', - 'limit' => 10, - ), - 'enabled' => true, - ), - ), - ); - } - - public function test_wp_parse_args_recursive_treats_list_arrays_as_leaf_values(): void { - self::assertSame( - array( 'ids' => array( 3 ) ), - Request::wp_parse_args_recursive( - array( 'ids' => array( 3 ) ), - array( 'ids' => array( 1, 2 ) ), - ), - ); - } - - public function test_wp_parse_args_recursive_adds_unknown_argument_keys(): void { - self::assertSame( - array( - 'a' => 'default', - 'b' => 'provided', - ), - Request::wp_parse_args_recursive( array( 'b' => 'provided' ), array( 'a' => 'default' ) ), - ); - } - - public function test_wp_parse_args_recursive_lets_a_scalar_argument_replace_an_array_default(): void { - self::assertSame( - array( 'display' => 'compact' ), - Request::wp_parse_args_recursive( - array( 'display' => 'compact' ), - array( 'display' => array( 'mode' => 'full' ) ), - ), - ); - } -} diff --git a/packages/infrastructure/tests/Utilities/Unit/Hooks/Handlers/BufferedHookHandlerTest.php b/packages/infrastructure/tests/Utilities/Unit/Hooks/Handlers/BufferedHookHandlerTest.php index 41288d0..ea79280 100644 --- a/packages/infrastructure/tests/Utilities/Unit/Hooks/Handlers/BufferedHookHandlerTest.php +++ b/packages/infrastructure/tests/Utilities/Unit/Hooks/Handlers/BufferedHookHandlerTest.php @@ -14,7 +14,8 @@ final class BufferedHookHandlerTest extends TestCase { public function test_default_id_is_buffered(): void { $handler = new BufferedHookHandler(); - self::assertSame( 'buffered', $handler->id ); + self::assertSame( 'buffered', BufferedHookHandler::DEFAULT_ID ); + self::assertSame( BufferedHookHandler::DEFAULT_ID, $handler->id ); } public function test_custom_id_is_returned(): void { diff --git a/packages/infrastructure/tests/Utilities/Unit/Hooks/HooksServiceTest.php b/packages/infrastructure/tests/Utilities/Unit/Hooks/HooksServiceTest.php index 5d5c0b9..4c11f0a 100644 --- a/packages/infrastructure/tests/Utilities/Unit/Hooks/HooksServiceTest.php +++ b/packages/infrastructure/tests/Utilities/Unit/Hooks/HooksServiceTest.php @@ -2,10 +2,10 @@ namespace DeepWebSolutions\Framework\Utilities\Tests\Unit\Hooks; +use DeepWebSolutions\Framework\Utilities\Hooks\Exceptions\UnknownHookHandlerException; use DeepWebSolutions\Framework\Utilities\Hooks\Handlers\DirectHookHandler; use DeepWebSolutions\Framework\Utilities\Hooks\HookHandlerInterface; use DeepWebSolutions\Framework\Utilities\Hooks\HooksService; -use OutOfBoundsException; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; @@ -16,7 +16,7 @@ final class HooksServiceTest extends TestCase { public function test_constructs_with_default_direct_handler(): void { $service = new HooksService(); - self::assertInstanceOf( DirectHookHandler::class, $service->get_handler( 'direct' ) ); + self::assertInstanceOf( DirectHookHandler::class, $service->handlers['direct'] ?? null ); } public function test_empty_array_yields_zero_handlers(): void { @@ -25,13 +25,12 @@ public function test_empty_array_yields_zero_handlers(): void { self::assertSame( array(), $service->handlers ); } - public function test_register_handler_indexes_by_id(): void { - $service = new HooksService( array() ); + public function test_constructor_indexes_handlers_by_id(): void { $handler = $this->recording_handler( 'buffered' ); - $service->register_handler( $handler ); + $service = new HooksService( array( $handler ) ); - self::assertSame( $handler, $service->get_handler( 'buffered' ) ); + self::assertSame( $handler, $service->handlers['buffered'] ?? null ); } public function test_handlers_returns_all(): void { @@ -49,10 +48,15 @@ public function test_handlers_returns_all(): void { ); } - public function test_get_handler_returns_null_when_not_found(): void { - $service = new HooksService( array( $this->recording_handler( 'direct' ) ) ); + public function test_register_hooks_forwards_to_every_handler(): void { + $direct = $this->recording_handler( 'direct' ); + $buffered = $this->recording_handler( 'buffered' ); + $service = new HooksService( array( $direct, $buffered ) ); + + $service->register_hooks(); - self::assertNull( $service->get_handler( 'missing' ) ); + self::assertSame( array( array( 'register_hooks' ) ), $direct->calls ); + self::assertSame( array( array( 'register_hooks' ) ), $buffered->calls ); } public function test_add_action_routes_to_default_handler(): void { @@ -136,7 +140,7 @@ public function test_remove_all_filters_routes_to_named_handler(): void { public function test_unknown_handler_id_throws(): void { $service = new HooksService( array( $this->recording_handler( 'direct' ) ) ); - $this->expectException( OutOfBoundsException::class ); + $this->expectException( UnknownHookHandlerException::class ); $service->add_action( 'init', static function (): void {}, 10, 1, 'nonexistent' ); } @@ -218,6 +222,10 @@ public function remove_all_actions(): void { public function remove_all_filters(): void { $this->calls[] = array( 'remove_all_filters' ); } + + public function register_hooks(): void { + $this->calls[] = array( 'register_hooks' ); + } }; } } diff --git a/packages/infrastructure/tests/Utilities/Unit/Scheduling/SchedulerTest.php b/packages/infrastructure/tests/Utilities/Unit/Scheduling/SchedulerTest.php index b7e5b7b..e542d1e 100644 --- a/packages/infrastructure/tests/Utilities/Unit/Scheduling/SchedulerTest.php +++ b/packages/infrastructure/tests/Utilities/Unit/Scheduling/SchedulerTest.php @@ -324,12 +324,12 @@ public function test_is_ready_reports_whether_any_backend_is_ready(): void { self::assertFalse( ( new Scheduler( array( $dormant_a, $dormant_b ) ) )->is_ready() ); } - public function test_register_lifecycle_wires_every_backend_ready_or_not(): void { + public function test_register_hooks_wires_every_backend_ready_or_not(): void { $action_scheduler = $this->recording_backend( ready: false ); $wp_cron = $this->recording_backend(); $scheduler = new Scheduler( array( $action_scheduler, $wp_cron ) ); - $scheduler->register_lifecycle(); + $scheduler->register_hooks(); self::assertSame( 1, $action_scheduler->lifecycle_calls ); self::assertSame( 1, $wp_cron->lifecycle_calls ); @@ -438,7 +438,7 @@ public function is_ready(): bool { return $this->ready; } - public function register_lifecycle(): void { + public function register_hooks(): void { ++$this->lifecycle_calls; } } From cc812edc343681c6a244d6090fa6b0a466592d7c Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Tue, 7 Jul 2026 23:01:33 +0200 Subject: [PATCH 04/10] refactor(infrastructure)!: consolidate the object-field seam and harden settings wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ObjectFieldForm owns all four CRUD verbs: store() becomes set(), and get()/has()/delete() move onto the engine with the canonical store-or-revoke and read-semantics docblocks, collapsing four verbatim copies; the three meta-field surfaces and the order surface become one-line delegates. Wiring-time hardening: register_page() throws BackendAlreadyBoundException on a second bind and InvalidSettingsPageException on a page with zero sections in both backends — the authoring-time seam, leaving the render-time capability projection free to empty a page per user. SettingsPage::$sections loses its empty default (declared before the optional $location). MetaBoxPlacement validates screen, context, and priority at construction (InvalidMetaBoxPlacementException, VO family) and exposes get_capability(); the surfaces' priority coercion is gone. The two anonymous filter closures in WordPressSettingsBackend become instance-addressed hook methods third parties can unhook. Docblock alignment: register_page contract reworded backend-neutral; Storage summaries move to third-person indicative; KeyValueStore declares get before set (implementations reordered to match); the protected meta-key helper is resolve_meta_key(); Schema/functions.php signposts its WP-free vs WP-coupled split; the InvalidValueObjectException reason invariant (no template punctuation, reasons end with a period) is recorded and enforced; descriptor invalidity messages carry the terminal period. Assisted-by: Claude Code:claude-fable-5 --- .../core/src/ValueObjects/PluginHeader.php | 2 +- .../BackendAlreadyBoundException.php | 13 +++ .../src/Settings/Backend/Exceptions/index.php | 1 + .../Backend/SettingsBackendInterface.php | 3 +- .../Backend/WordPressSettingsBackend.php | 74 ++++++++++++--- .../InvalidMetaBoxPlacementException.php | 27 ++++++ .../Settings/MetaField/ObjectFieldForm.php | 91 ++++++++++++++++--- .../Surfaces/PostMetaFieldSurface.php | 32 +++---- .../MetaField/Surfaces/TermFieldSurface.php | 21 ++--- .../Surfaces/UserProfileFieldSurface.php | 21 ++--- .../MetaField/ValueObjects/FieldGroup.php | 2 +- .../ValueObjects/MetaBoxPlacement.php | 68 +++++++++++++- .../Schema/ValueObjects/CustomFieldType.php | 2 +- .../Schema/ValueObjects/SettingsField.php | 2 +- .../Schema/ValueObjects/SettingsPage.php | 6 +- .../Schema/ValueObjects/SettingsSection.php | 2 +- .../src/Settings/Schema/functions.php | 7 ++ .../src/Storage/KeyValueStoreInterface.php | 36 ++++---- .../src/Storage/MemoryStore.php | 8 +- .../src/Storage/OptionsStore.php | 18 ++-- .../src/Storage/UserMetaStore.php | 54 +++++------ .../WordPressSettingsBackendTest.php | 11 +++ .../Unit/MetaField/MetaBoxPlacementTest.php | 63 +++++++++++++ .../Unit/MetaField/ObjectFieldFormTest.php | 52 ++++++++--- .../tests/Settings/Unit/SettingsPageTest.php | 2 + .../Unit/WordPressSettingsBackendTest.php | 3 + .../InvalidValueObjectException.php | 2 +- packages/shared/src/Version/Version.php | 4 +- .../Backend/WooCommerceSettingsBackend.php | 9 ++ .../src/OrderData/OrderFieldSurface.php | 27 ++---- ...iptorBackedWooCommerceSettingsPageTest.php | 4 +- .../Unit/WooCommerceSettingsBuilderTest.php | 3 + 32 files changed, 494 insertions(+), 176 deletions(-) create mode 100644 packages/infrastructure/src/Settings/Backend/Exceptions/BackendAlreadyBoundException.php create mode 100644 packages/infrastructure/src/Settings/Backend/Exceptions/index.php create mode 100644 packages/infrastructure/src/Settings/MetaField/Exceptions/InvalidMetaBoxPlacementException.php diff --git a/packages/core/src/ValueObjects/PluginHeader.php b/packages/core/src/ValueObjects/PluginHeader.php index bac0cc9..691a356 100644 --- a/packages/core/src/ValueObjects/PluginHeader.php +++ b/packages/core/src/ValueObjects/PluginHeader.php @@ -137,7 +137,7 @@ public function __construct( if ( ! is_valid_identifier( $slug ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- framework-internal exception; never reaches an HTML output context unescaped. - throw new InvalidPluginHeaderException( "derived slug '$slug' is not a valid identifier. Use a Text Domain (or plugin directory/file name) of a lowercase letter followed by lowercase a-z, 0-9, _, - so derived hook names and REST namespaces stay well-formed" ); + throw new InvalidPluginHeaderException( "derived slug '$slug' is not a valid identifier. Use a Text Domain (or plugin directory/file name) of a lowercase letter followed by lowercase a-z, 0-9, _, - so derived hook names and REST namespaces stay well-formed." ); } $this->slug = $slug; diff --git a/packages/infrastructure/src/Settings/Backend/Exceptions/BackendAlreadyBoundException.php b/packages/infrastructure/src/Settings/Backend/Exceptions/BackendAlreadyBoundException.php new file mode 100644 index 0000000..e2690f7 --- /dev/null +++ b/packages/infrastructure/src/Settings/Backend/Exceptions/BackendAlreadyBoundException.php @@ -0,0 +1,13 @@ +page ) { + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- framework-internal exception; never reaches an HTML output context unescaped. + throw new BackendAlreadyBoundException( "Settings backend is already bound to page '{$this->page->slug}'; use a new backend instance for page '$page->slug'." ); + } + // The authoring-time seam: a registered page with zero sections is a permanently blank admin + // surface, while the render-time capability projection may legitimately empty a page per user. + if ( array() === $page->sections ) { + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- framework-internal exception; never reaches an HTML output context unescaped. + throw new InvalidSettingsPageException( "Settings page '$page->slug' declares no sections; a registered page must carry at least one section to render." ); + } + $this->page = $page; $this->field_section = $this->map_fields( $page ); $this->section_autoload = $this->map_section_autoload( $page ); @@ -297,7 +312,6 @@ public function register_settings(): void { foreach ( $page->sections as $section ) { $option_name = $page->slug . '-' . $section->id; $rest_schema = $this->section_rest_schemas[ $section->id ] ?? null; - $autoload = $this->section_autoload[ $section->id ] ?? false; $args = array( // A REST-exposed section is an object keyed by field id; a plain section is an opaque map. @@ -310,17 +324,55 @@ public function register_settings(): void { } \register_setting( $option_name, $option_name, $args ); - \add_filter( "option_page_capability_{$option_name}", fn () => $page->capability ); - - // The form save calls update_option with no autoload argument, so WP would resolve the option's - // autoload by size rather than the section policy; pin the policy so both write paths agree. - \add_filter( - 'wp_default_autoload_value', - static fn ( ?bool $default_value, string $option ): ?bool => $option === $option_name ? $autoload : $default_value, - 10, - 2, - ); + \add_filter( "option_page_capability_{$option_name}", array( $this, 'filter_option_page_capability' ) ); } + + \add_filter( 'wp_default_autoload_value', array( $this, 'filter_default_autoload' ), 10, 2 ); + } + + /** + * Filters the capability required to save one of the page's section options, so options.php's + * permission check matches the page capability. Hooked to option_page_capability_{$option_name} + * for each section option. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param string $capability Capability options.php would otherwise require. + * + * @return string + */ + public function filter_option_page_capability( string $capability ): string { + return $this->page->capability ?? $capability; + } + + /** + * Pins a section option's default autoload to the section policy. The form save calls update_option + * with no autoload argument, so WordPress would resolve the option's autoload by size rather than + * the section policy; pinning it keeps both write paths in agreement. An option that is not one of + * the page's section options passes through untouched. Hooked to wp_default_autoload_value. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param ?bool $autoload Default autoload value WordPress resolved so far; null lets WordPress decide. + * @param string $option Option being written. + * + * @return ?bool + */ + public function filter_default_autoload( ?bool $autoload, string $option ): ?bool { + $page = $this->page; + if ( null === $page ) { + return $autoload; + } + + foreach ( $page->sections as $section ) { + if ( $page->slug . '-' . $section->id === $option ) { + return $this->section_autoload[ $section->id ] ?? false; + } + } + + return $autoload; } // endregion diff --git a/packages/infrastructure/src/Settings/MetaField/Exceptions/InvalidMetaBoxPlacementException.php b/packages/infrastructure/src/Settings/MetaField/Exceptions/InvalidMetaBoxPlacementException.php new file mode 100644 index 0000000..18a9fa1 --- /dev/null +++ b/packages/infrastructure/src/Settings/MetaField/Exceptions/InvalidMetaBoxPlacementException.php @@ -0,0 +1,27 @@ + 'MetaBoxPlacement'; + } +} diff --git a/packages/infrastructure/src/Settings/MetaField/ObjectFieldForm.php b/packages/infrastructure/src/Settings/MetaField/ObjectFieldForm.php index 05b0a79..80b746f 100644 --- a/packages/infrastructure/src/Settings/MetaField/ObjectFieldForm.php +++ b/packages/infrastructure/src/Settings/MetaField/ObjectFieldForm.php @@ -18,13 +18,15 @@ use function DeepWebSolutions\Framework\Settings\Schema\wordpress_field_type_sanitizers; /** - * Shared render and save engine for object-field surfaces. + * Shared render, save, and field-addressed CRUD engine for object-field surfaces. * * Drives a {@see FieldGroup} against an injected object-meta repository: on render it emits an * object-scoped nonce and each editable field's control; on save it verifies that nonce, processes each - * editable field, and applies the batch through a single apply() call. Object fields are revoke-based: - * an absent value renders unset (never the field default), absent or empty submissions delete the meta - * key, and an invalid present submission preserves the existing value. + * editable field, and applies the batch through a single apply() call. The get/set/has/delete verbs + * address one field by group and field id over the same storage keys, so every surface shares one set + * of value semantics. Object fields are revoke-based: an absent value renders unset (never the field + * default), absent or empty submissions delete the meta key, and an invalid present submission + * preserves the existing value. * * A surface varies only in per-field markup, supplied as a row closure receiving each field, its * rendered control, and the control's DOM id, and in the optional bespoke render/save closures the @@ -114,7 +116,7 @@ public function render( FieldGroup $group, int $object_id, ?\Closure $row = null } // Object fields are revoke-based: an absent meta renders as unset, NOT the field default, so a // value cleared via delete-on-empty does not spring back to its default on the next render. - $value = $this->repository->get( $object_id, $this->meta_key_for( $field ) ); + $value = $this->repository->get( $object_id, $this->resolve_meta_key( $field ) ); $control_name = $group->id . '[' . $field->id . ']'; $control = $this->renderer->render( $field, $value, $control_name ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- FieldRenderer returns escaped markup; a row closure escapes the surface chrome it adds. @@ -166,7 +168,7 @@ public function save( FieldGroup $group, int $object_id, ?int $nonce_object_id = continue; } - $meta_key = $this->meta_key_for( $field ); + $meta_key = $this->resolve_meta_key( $field ); // Object fields are revoke-based: an unsubmitted field deletes its meta key rather than keeping // or defaulting it. Checked before processing because a custom type folds an absent submission to @@ -195,7 +197,28 @@ public function save( FieldGroup $group, int $object_id, ?int $nonce_object_id = } /** - * Stores one field's value for an object with the form path's store-or-revoke semantics: a checkbox + * Retrieves a field's stored value for an object, or $default_value when nothing is stored. Object + * fields are revoke-based, so the field's declared default is never a read-time fallback. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param FieldGroup $group Group that declares the field. + * @param int $object_id Object to read. + * @param string $field_id Field whose value to read. + * @param mixed $default_value Value to return when nothing is stored. + * + * @throws DuplicateSettingsFieldException If two of the group's fields share an id or storage key. + * @throws InvalidSettingsFieldException If the group declares no field with the given id. + * + * @return mixed + */ + public function get( FieldGroup $group, int $object_id, string $field_id, mixed $default_value = null ): mixed { + return $this->repository->get( $object_id, $this->meta_key_of( $group, $object_id, $field_id ), $default_value ); + } + + /** + * Persists one field's value for an object with the form path's store-or-revoke semantics: a checkbox * value is stored in its canonical 'yes'/'no' form (false stores 'no'), and a non-checkbox value a * form save would not store — false, a cleared field ('') or an empty multi-select (array()) — revokes * the meta key instead, exactly like a submission clearing the field. The write is programmatic: the @@ -212,17 +235,55 @@ public function save( FieldGroup $group, int $object_id, ?int $nonce_object_id = * @throws DuplicateSettingsFieldException If two of the group's fields share an id or storage key. * @throws InvalidSettingsFieldException If the group declares no field with the given id. */ - public function store( FieldGroup $group, int $object_id, string $field_id, mixed $value ): void { + public function set( FieldGroup $group, int $object_id, string $field_id, mixed $value ): void { $field = $this->field_of( $group, $object_id, $field_id ); $value = $this->storable_value( $field, $value ); if ( $this->should_store( $value ) ) { - $this->repository->set( $object_id, $this->meta_key_for( $field ), $value ); + $this->repository->set( $object_id, $this->resolve_meta_key( $field ), $value ); } else { - $this->repository->delete( $object_id, $this->meta_key_for( $field ) ); + $this->repository->delete( $object_id, $this->resolve_meta_key( $field ) ); } } + /** + * Whether a real value is stored for a field on an object. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param FieldGroup $group Group that declares the field. + * @param int $object_id Object to check. + * @param string $field_id Field to check. + * + * @throws DuplicateSettingsFieldException If two of the group's fields share an id or storage key. + * @throws InvalidSettingsFieldException If the group declares no field with the given id. + * + * @return bool + */ + public function has( FieldGroup $group, int $object_id, string $field_id ): bool { + return $this->repository->has( $object_id, $this->meta_key_of( $group, $object_id, $field_id ) ); + } + + /** + * Deletes a field's stored value from an object. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param FieldGroup $group Group that declares the field. + * @param int $object_id Object to clear. + * @param string $field_id Field to clear. + * + * @throws DuplicateSettingsFieldException If two of the group's fields share an id or storage key. + * @throws InvalidSettingsFieldException If the group declares no field with the given id. + * + * @return bool True if a value was deleted, false if none existed. + */ + public function delete( FieldGroup $group, int $object_id, string $field_id ): bool { + return $this->repository->delete( $object_id, $this->meta_key_of( $group, $object_id, $field_id ) ); + } + /** * Resolves the storage key for one of a group's fields on an object — the same key render() reads and * save() writes, since the group's fields are built for exactly that object. @@ -240,7 +301,7 @@ public function store( FieldGroup $group, int $object_id, string $field_id, mixe * @return string */ public function meta_key_of( FieldGroup $group, int $object_id, string $field_id ): string { - return $this->meta_key_for( $this->field_of( $group, $object_id, $field_id ) ); + return $this->resolve_meta_key( $this->field_of( $group, $object_id, $field_id ) ); } /** @@ -263,7 +324,7 @@ public function meta_key_of( FieldGroup $group, int $object_id, string $field_id public function meta_keys( FieldGroup $group, int $object_id = 0 ): array { $keys = array(); foreach ( $this->fields_of( $group, $object_id ) as $field ) { - $keys[] = $this->meta_key_for( $field ); + $keys[] = $this->resolve_meta_key( $field ); } return $keys; @@ -334,7 +395,7 @@ protected function fields_of( FieldGroup $group, int $object_id ): array { } $seen_ids[ $field->id ] = true; - $meta_key = $this->meta_key_for( $field ); + $meta_key = $this->resolve_meta_key( $field ); if ( \array_key_exists( $meta_key, $seen_keys ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- framework-internal exception; never reaches an HTML output context unescaped. throw new DuplicateSettingsFieldException( "Duplicate object field storage key in group '$group->id': '$meta_key'" ); @@ -381,14 +442,14 @@ protected function field_of( FieldGroup $group, int $object_id, string $field_id * * @return string */ - protected function meta_key_for( SettingsField $field ): string { + protected function resolve_meta_key( SettingsField $field ): string { return $field->meta_key ?? $field->id; } /** * The canonical stored representation of a raw programmatic value: a checkbox value normalizes to * the canonical 'yes'/'no' string; any other field's value passes through unchanged. Only the - * {@see self::store()} path normalizes here — a form submission's normalization belongs to the field + * {@see self::set()} path normalizes here — a form submission's normalization belongs to the field * processor, whose processed value (a custom sanitizer's output included) is stored verbatim. * * @since 2.0.0 diff --git a/packages/infrastructure/src/Settings/MetaField/Surfaces/PostMetaFieldSurface.php b/packages/infrastructure/src/Settings/MetaField/Surfaces/PostMetaFieldSurface.php index cbe65db..35ae010 100644 --- a/packages/infrastructure/src/Settings/MetaField/Surfaces/PostMetaFieldSurface.php +++ b/packages/infrastructure/src/Settings/MetaField/Surfaces/PostMetaFieldSurface.php @@ -101,8 +101,7 @@ public function register( FieldGroup $group, MetaBoxPlacement $placement ): void } /** - * Retrieves a field's stored value for a post, or $default_value when nothing is stored. Object fields - * are revoke-based, so the field's declared default is never a read-time fallback. + * Retrieves a field's stored value for a post — {@see ObjectFieldForm::get()} for the read semantics. * * @since 2.0.0 * @version 2.0.0 @@ -118,15 +117,11 @@ public function register( FieldGroup $group, MetaBoxPlacement $placement ): void * @return mixed */ public function get( FieldGroup $group, int $post_id, string $field_id, mixed $default_value = null ): mixed { - return $this->repository->get( $post_id, $this->form->meta_key_of( $group, $post_id, $field_id ), $default_value ); + return $this->form->get( $group, $post_id, $field_id, $default_value ); } /** - * Persists a field's value for a post with the form path's store-or-revoke semantics: a checkbox - * value is stored in its canonical 'yes'/'no' form (false stores 'no'), and a non-checkbox value a - * form save would not store — false, a cleared field ('') or an empty multi-select (array()) — - * revokes the meta key instead. The write is programmatic: the descriptor's sanitize/validate seam - * applies to form submissions only. + * Persists a field's value for a post — {@see ObjectFieldForm::set()} for the store-or-revoke semantics. * * @since 2.0.0 * @version 2.0.0 @@ -140,11 +135,11 @@ public function get( FieldGroup $group, int $post_id, string $field_id, mixed $d * @throws InvalidSettingsFieldException If the group declares no field with the given id. */ public function set( FieldGroup $group, int $post_id, string $field_id, mixed $value ): void { - $this->form->store( $group, $post_id, $field_id, $value ); + $this->form->set( $group, $post_id, $field_id, $value ); } /** - * Whether a real value is stored for a field on a post. + * Whether a real value is stored for a field on a post — {@see ObjectFieldForm::has()}. * * @since 2.0.0 * @version 2.0.0 @@ -159,11 +154,11 @@ public function set( FieldGroup $group, int $post_id, string $field_id, mixed $v * @return bool */ public function has( FieldGroup $group, int $post_id, string $field_id ): bool { - return $this->repository->has( $post_id, $this->form->meta_key_of( $group, $post_id, $field_id ) ); + return $this->form->has( $group, $post_id, $field_id ); } /** - * Deletes a field's stored value from a post. + * Deletes a field's stored value from a post — {@see ObjectFieldForm::delete()}. * * @since 2.0.0 * @version 2.0.0 @@ -178,7 +173,7 @@ public function has( FieldGroup $group, int $post_id, string $field_id ): bool { * @return bool True if a value was deleted, false if none existed. */ public function delete( FieldGroup $group, int $post_id, string $field_id ): bool { - return $this->repository->delete( $post_id, $this->form->meta_key_of( $group, $post_id, $field_id ) ); + return $this->form->delete( $group, $post_id, $field_id ); } /** @@ -267,14 +262,12 @@ protected function registrations_for( string $screen ): array { protected function add_box( FieldGroup $group, MetaBoxPlacement $placement, \WP_Post $post ): void { // Gate the box on the same capability as the save, so a user who reaches the edit screen but lacks // the box's capability for this post is neither shown the controls nor disclosed the stored values. - if ( ! \current_user_can( $placement->capability ?? 'edit_post', $post->ID ) ) { + if ( ! \current_user_can( $placement->get_capability(), $post->ID ) ) { return; } - $priority = match ( $placement->priority ) { - 'core', 'high', 'low' => $placement->priority, - default => 'default', - }; + /** @var 'high'|'core'|'default'|'low' $priority */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort -- inline @var type assertion; the placement constructor validates the closed set. + $priority = $placement->priority; \add_meta_box( $group->id, @@ -315,8 +308,7 @@ protected function box_row(): \Closure { * @param int $post_id Post whose meta to write. */ protected function save_box( FieldGroup $group, MetaBoxPlacement $placement, int $post_id ): void { - $capability = $placement->capability ?? 'edit_post'; - if ( ! \current_user_can( $capability, $post_id ) ) { + if ( ! \current_user_can( $placement->get_capability(), $post_id ) ) { return; } diff --git a/packages/infrastructure/src/Settings/MetaField/Surfaces/TermFieldSurface.php b/packages/infrastructure/src/Settings/MetaField/Surfaces/TermFieldSurface.php index c59f21c..8b81b54 100644 --- a/packages/infrastructure/src/Settings/MetaField/Surfaces/TermFieldSurface.php +++ b/packages/infrastructure/src/Settings/MetaField/Surfaces/TermFieldSurface.php @@ -102,8 +102,7 @@ public function register( TermFieldGroup $term_group ): void { } /** - * Retrieves a field's stored value for a term, or $default_value when nothing is stored. Object fields - * are revoke-based, so the field's declared default is never a read-time fallback. + * Retrieves a field's stored value for a term — {@see ObjectFieldForm::get()} for the read semantics. * * @since 2.0.0 * @version 2.0.0 @@ -119,15 +118,11 @@ public function register( TermFieldGroup $term_group ): void { * @return mixed */ public function get( FieldGroup $group, int $term_id, string $field_id, mixed $default_value = null ): mixed { - return $this->repository->get( $term_id, $this->form->meta_key_of( $group, $term_id, $field_id ), $default_value ); + return $this->form->get( $group, $term_id, $field_id, $default_value ); } /** - * Persists a field's value for a term with the form path's store-or-revoke semantics: a checkbox - * value is stored in its canonical 'yes'/'no' form (false stores 'no'), and a non-checkbox value a - * form save would not store — false, a cleared field ('') or an empty multi-select (array()) — - * revokes the meta key instead. The write is programmatic: the descriptor's sanitize/validate seam - * applies to form submissions only. + * Persists a field's value for a term — {@see ObjectFieldForm::set()} for the store-or-revoke semantics. * * @since 2.0.0 * @version 2.0.0 @@ -141,11 +136,11 @@ public function get( FieldGroup $group, int $term_id, string $field_id, mixed $d * @throws InvalidSettingsFieldException If the group declares no field with the given id. */ public function set( FieldGroup $group, int $term_id, string $field_id, mixed $value ): void { - $this->form->store( $group, $term_id, $field_id, $value ); + $this->form->set( $group, $term_id, $field_id, $value ); } /** - * Whether a real value is stored for a field on a term. + * Whether a real value is stored for a field on a term — {@see ObjectFieldForm::has()}. * * @since 2.0.0 * @version 2.0.0 @@ -160,11 +155,11 @@ public function set( FieldGroup $group, int $term_id, string $field_id, mixed $v * @return bool */ public function has( FieldGroup $group, int $term_id, string $field_id ): bool { - return $this->repository->has( $term_id, $this->form->meta_key_of( $group, $term_id, $field_id ) ); + return $this->form->has( $group, $term_id, $field_id ); } /** - * Deletes a field's stored value from a term. + * Deletes a field's stored value from a term — {@see ObjectFieldForm::delete()}. * * @since 2.0.0 * @version 2.0.0 @@ -179,7 +174,7 @@ public function has( FieldGroup $group, int $term_id, string $field_id ): bool { * @return bool True if a value was deleted, false if none existed. */ public function delete( FieldGroup $group, int $term_id, string $field_id ): bool { - return $this->repository->delete( $term_id, $this->form->meta_key_of( $group, $term_id, $field_id ) ); + return $this->form->delete( $group, $term_id, $field_id ); } /** diff --git a/packages/infrastructure/src/Settings/MetaField/Surfaces/UserProfileFieldSurface.php b/packages/infrastructure/src/Settings/MetaField/Surfaces/UserProfileFieldSurface.php index 0845548..4565376 100644 --- a/packages/infrastructure/src/Settings/MetaField/Surfaces/UserProfileFieldSurface.php +++ b/packages/infrastructure/src/Settings/MetaField/Surfaces/UserProfileFieldSurface.php @@ -103,8 +103,7 @@ public function register( UserProfileFieldGroup $profile ): void { } /** - * Retrieves a field's stored value for a user, or $default_value when nothing is stored. Object fields - * are revoke-based, so the field's declared default is never a read-time fallback. + * Retrieves a field's stored value for a user — {@see ObjectFieldForm::get()} for the read semantics. * * @since 2.0.0 * @version 2.0.0 @@ -120,15 +119,11 @@ public function register( UserProfileFieldGroup $profile ): void { * @return mixed */ public function get( FieldGroup $group, int $user_id, string $field_id, mixed $default_value = null ): mixed { - return $this->repository->get( $user_id, $this->form->meta_key_of( $group, $user_id, $field_id ), $default_value ); + return $this->form->get( $group, $user_id, $field_id, $default_value ); } /** - * Persists a field's value for a user with the form path's store-or-revoke semantics: a checkbox - * value is stored in its canonical 'yes'/'no' form (false stores 'no'), and a non-checkbox value a - * form save would not store — false, a cleared field ('') or an empty multi-select (array()) — - * revokes the meta key instead. The write is programmatic: the descriptor's sanitize/validate seam - * applies to form submissions only. + * Persists a field's value for a user — {@see ObjectFieldForm::set()} for the store-or-revoke semantics. * * @since 2.0.0 * @version 2.0.0 @@ -142,11 +137,11 @@ public function get( FieldGroup $group, int $user_id, string $field_id, mixed $d * @throws InvalidSettingsFieldException If the group declares no field with the given id. */ public function set( FieldGroup $group, int $user_id, string $field_id, mixed $value ): void { - $this->form->store( $group, $user_id, $field_id, $value ); + $this->form->set( $group, $user_id, $field_id, $value ); } /** - * Whether a real value is stored for a field on a user. + * Whether a real value is stored for a field on a user — {@see ObjectFieldForm::has()}. * * @since 2.0.0 * @version 2.0.0 @@ -161,11 +156,11 @@ public function set( FieldGroup $group, int $user_id, string $field_id, mixed $v * @return bool */ public function has( FieldGroup $group, int $user_id, string $field_id ): bool { - return $this->repository->has( $user_id, $this->form->meta_key_of( $group, $user_id, $field_id ) ); + return $this->form->has( $group, $user_id, $field_id ); } /** - * Deletes a field's stored value from a user. + * Deletes a field's stored value from a user — {@see ObjectFieldForm::delete()}. * * @since 2.0.0 * @version 2.0.0 @@ -180,7 +175,7 @@ public function has( FieldGroup $group, int $user_id, string $field_id ): bool { * @return bool True if a value was deleted, false if none existed. */ public function delete( FieldGroup $group, int $user_id, string $field_id ): bool { - return $this->repository->delete( $user_id, $this->form->meta_key_of( $group, $user_id, $field_id ) ); + return $this->form->delete( $group, $user_id, $field_id ); } /** diff --git a/packages/infrastructure/src/Settings/MetaField/ValueObjects/FieldGroup.php b/packages/infrastructure/src/Settings/MetaField/ValueObjects/FieldGroup.php index b5b84b4..d969459 100644 --- a/packages/infrastructure/src/Settings/MetaField/ValueObjects/FieldGroup.php +++ b/packages/infrastructure/src/Settings/MetaField/ValueObjects/FieldGroup.php @@ -82,7 +82,7 @@ public function __construct( ) { if ( ! is_valid_identifier( $id ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- framework-internal exception; never reaches an HTML output context unescaped. - throw new InvalidFieldGroupException( "Invalid field group id: '$id'" ); + throw new InvalidFieldGroupException( "Invalid field group id: '$id'." ); } $this->fields_provider = \Closure::fromCallable( $fields_provider ); diff --git a/packages/infrastructure/src/Settings/MetaField/ValueObjects/MetaBoxPlacement.php b/packages/infrastructure/src/Settings/MetaField/ValueObjects/MetaBoxPlacement.php index 49c6044..81d0b7f 100644 --- a/packages/infrastructure/src/Settings/MetaField/ValueObjects/MetaBoxPlacement.php +++ b/packages/infrastructure/src/Settings/MetaField/ValueObjects/MetaBoxPlacement.php @@ -2,19 +2,47 @@ namespace DeepWebSolutions\Framework\Settings\MetaField\ValueObjects; +use DeepWebSolutions\Framework\Settings\MetaField\Exceptions\InvalidMetaBoxPlacementException; use DeepWebSolutions\Framework\Shared\ValueObject\AbstractValueObject; +use function DeepWebSolutions\Framework\Shared\Identifier\is_valid_identifier; + /** * Value object for a meta box's WordPress placement. * * The add_meta_box() triple — screen, context, priority — plus an optional capability that overrides a * registrar's default object capability for the box. Carried alongside a {@see FieldGroup} by the - * surfaces that register WordPress meta boxes. + * surfaces that register WordPress meta boxes. The screen is interpolated into hook names, so it must + * match the shared identifier charset; context and priority are validated against WordPress' closed sets. * * @since 2.0.0 * @version 2.0.0 */ final readonly class MetaBoxPlacement extends AbstractValueObject { + // region FIELDS AND CONSTANTS + + /** + * The meta-box contexts WordPress recognizes on the edit screens. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @var list + */ + protected const CONTEXTS = array( 'normal', 'side', 'advanced' ); + + /** + * The meta-box priorities WordPress recognizes. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @var list + */ + protected const PRIORITIES = array( 'high', 'core', 'default', 'low' ); + + // endregion + // region MAGIC METHODS /** @@ -23,17 +51,51 @@ * @since 2.0.0 * @version 2.0.0 * - * @param string $screen Screen or object type the box attaches to. + * @param string $screen Screen or object type the box attaches to; a lowercase token matching the shared identifier charset. * @param string $context WordPress meta-box context (normal, side, advanced). * @param string $priority WordPress meta-box priority (high, core, default, low). * @param ?string $capability Capability overriding the registrar's default object capability for the box; null keeps the default. + * + * @throws InvalidMetaBoxPlacementException If $screen does not match the identifier charset, or $context or $priority is outside its closed set. */ public function __construct( public string $screen, public string $context, public string $priority, public ?string $capability = null, - ) {} + ) { + // The surfaces interpolate the screen into hook names (add_meta_boxes_{screen}, save_post_{screen}), + // so it is held to the shared identifier charset rather than WordPress' looser sanitize_key set. + if ( ! is_valid_identifier( $screen ) ) { + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- framework-internal exception; never reaches an HTML output context unescaped. + throw new InvalidMetaBoxPlacementException( "invalid screen: '$screen'. Use an identifier of a lowercase letter followed by lowercase a-z, 0-9, _, - so hook names derived from it stay well-formed." ); + } + if ( ! \in_array( $context, self::CONTEXTS, true ) ) { + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- framework-internal exception; never reaches an HTML output context unescaped. + throw new InvalidMetaBoxPlacementException( "invalid context '$context'; expected one of 'normal', 'side', 'advanced'." ); + } + if ( ! \in_array( $priority, self::PRIORITIES, true ) ) { + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- framework-internal exception; never reaches an HTML output context unescaped. + throw new InvalidMetaBoxPlacementException( "invalid priority '$priority'; expected one of 'high', 'core', 'default', 'low'." ); + } + } + + // endregion + + // region GETTERS + + /** + * The capability gating the box: the placement's override, or the object's own 'edit_post' meta + * capability when none is set. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @return string + */ + public function get_capability(): string { + return $this->capability ?? 'edit_post'; + } // endregion } diff --git a/packages/infrastructure/src/Settings/Schema/ValueObjects/CustomFieldType.php b/packages/infrastructure/src/Settings/Schema/ValueObjects/CustomFieldType.php index b37235c..23a026c 100644 --- a/packages/infrastructure/src/Settings/Schema/ValueObjects/CustomFieldType.php +++ b/packages/infrastructure/src/Settings/Schema/ValueObjects/CustomFieldType.php @@ -58,7 +58,7 @@ public function __construct( ) { if ( ! is_valid_identifier( $type ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- framework-internal exception; never reaches an HTML output context unescaped. - throw new InvalidCustomFieldTypeException( "Invalid custom field type: '$type'" ); + throw new InvalidCustomFieldTypeException( "Invalid custom field type: '$type'." ); } if ( null !== FieldType::tryFrom( $type ) ) { diff --git a/packages/infrastructure/src/Settings/Schema/ValueObjects/SettingsField.php b/packages/infrastructure/src/Settings/Schema/ValueObjects/SettingsField.php index c9de8a3..d4b38b8 100644 --- a/packages/infrastructure/src/Settings/Schema/ValueObjects/SettingsField.php +++ b/packages/infrastructure/src/Settings/Schema/ValueObjects/SettingsField.php @@ -91,7 +91,7 @@ public function __construct( ) { if ( ! is_valid_identifier( $id ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- framework-internal exception; never reaches an HTML output context unescaped. - throw new InvalidSettingsFieldException( "Invalid settings field id: '$id'" ); + throw new InvalidSettingsFieldException( "Invalid settings field id: '$id'." ); } $this->sanitize = null !== $sanitize ? \Closure::fromCallable( $sanitize ) : null; diff --git a/packages/infrastructure/src/Settings/Schema/ValueObjects/SettingsPage.php b/packages/infrastructure/src/Settings/Schema/ValueObjects/SettingsPage.php index 62d8aaf..daf6005 100644 --- a/packages/infrastructure/src/Settings/Schema/ValueObjects/SettingsPage.php +++ b/packages/infrastructure/src/Settings/Schema/ValueObjects/SettingsPage.php @@ -30,8 +30,8 @@ * @param string $page_title Title rendered at the top of the page. * @param string $menu_title Label shown in the admin menu. * @param string $capability Capability required to view and save the page. - * @param ?string $location Backend-interpreted placement (an admin parent-menu slug, or a settings-tab id); null uses the backend default. * @param list $sections Sections composing the page, in display order. + * @param ?string $location Backend-interpreted placement (an admin parent-menu slug, or a settings-tab id); null uses the backend default. * * @throws InvalidSettingsPageException If $slug does not match the slug charset. */ @@ -40,12 +40,12 @@ public function __construct( public string $page_title, public string $menu_title, public string $capability, + public array $sections, public ?string $location = null, - public array $sections = array(), ) { if ( ! is_valid_identifier( $slug ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- framework-internal exception; never reaches an HTML output context unescaped. - throw new InvalidSettingsPageException( "Invalid settings page slug: '$slug'" ); + throw new InvalidSettingsPageException( "Invalid settings page slug: '$slug'." ); } } diff --git a/packages/infrastructure/src/Settings/Schema/ValueObjects/SettingsSection.php b/packages/infrastructure/src/Settings/Schema/ValueObjects/SettingsSection.php index cc35bb5..0697137 100644 --- a/packages/infrastructure/src/Settings/Schema/ValueObjects/SettingsSection.php +++ b/packages/infrastructure/src/Settings/Schema/ValueObjects/SettingsSection.php @@ -39,7 +39,7 @@ public function __construct( ) { if ( ! is_valid_identifier( $id ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- framework-internal exception; never reaches an HTML output context unescaped. - throw new InvalidSettingsSectionException( "Invalid settings section id: '$id'" ); + throw new InvalidSettingsSectionException( "Invalid settings section id: '$id'." ); } } diff --git a/packages/infrastructure/src/Settings/Schema/functions.php b/packages/infrastructure/src/Settings/Schema/functions.php index 438ff89..44b8a2d 100644 --- a/packages/infrastructure/src/Settings/Schema/functions.php +++ b/packages/infrastructure/src/Settings/Schema/functions.php @@ -1,5 +1,12 @@ entries[ $key ] = $value; + public function get( string $key, mixed $default_value = null ): mixed { + return \array_key_exists( $key, $this->entries ) ? $this->entries[ $key ] : $default_value; } /** @@ -50,8 +50,8 @@ public function set( string $key, mixed $value ): void { * @version 2.0.0 */ #[\Override] - public function get( string $key, mixed $default_value = null ): mixed { - return \array_key_exists( $key, $this->entries ) ? $this->entries[ $key ] : $default_value; + public function set( string $key, mixed $value ): void { + $this->entries[ $key ] = $value; } /** diff --git a/packages/infrastructure/src/Storage/OptionsStore.php b/packages/infrastructure/src/Storage/OptionsStore.php index 5d41cd4..0236076 100644 --- a/packages/infrastructure/src/Storage/OptionsStore.php +++ b/packages/infrastructure/src/Storage/OptionsStore.php @@ -45,10 +45,9 @@ public function __construct( * @version 2.0.0 */ #[\Override] - public function set( string $key, mixed $value ): void { - $entries = $this->load(); - $entries[ $key ] = $value; - $this->save( $entries ); + public function get( string $key, mixed $default_value = null ): mixed { + $entries = $this->load(); + return \array_key_exists( $key, $entries ) ? $entries[ $key ] : $default_value; } /** @@ -58,9 +57,10 @@ public function set( string $key, mixed $value ): void { * @version 2.0.0 */ #[\Override] - public function get( string $key, mixed $default_value = null ): mixed { - $entries = $this->load(); - return \array_key_exists( $key, $entries ) ? $entries[ $key ] : $default_value; + public function set( string $key, mixed $value ): void { + $entries = $this->load(); + $entries[ $key ] = $value; + $this->save( $entries ); } /** @@ -118,7 +118,7 @@ public function clear(): void { // region HELPERS /** - * Load the entries array from wp_options. Returns an empty array if the option doesn't + * Loads the entries array from wp_options. Returns an empty array if the option doesn't * exist or is corrupted (non-array value). * * @since 2.0.0 @@ -132,7 +132,7 @@ protected function load(): array { } /** - * Persist the entries array to wp_options. + * Persists the entries array to wp_options. * * @since 2.0.0 * @version 2.0.0 diff --git a/packages/infrastructure/src/Storage/UserMetaStore.php b/packages/infrastructure/src/Storage/UserMetaStore.php index 9ba23be..98e3b1d 100644 --- a/packages/infrastructure/src/Storage/UserMetaStore.php +++ b/packages/infrastructure/src/Storage/UserMetaStore.php @@ -46,50 +46,50 @@ public function __construct( // region INHERITED METHODS /** - * Persist a value under the given key for a user. Overwrites any existing value at the same key. + * Retrieves a value by key for a user, or the default if no value is stored under it. * * @since 2.0.0 * @version 2.0.0 * - * @param string $key Identifier under which to store the value. - * @param mixed $value Value to persist. - * @param int $user_id User to target, or 0 for the current user. Defaults to 0. + * @param string $key Identifier to look up. + * @param mixed $default_value Value to return when no entry exists at $key. Defaults to null. + * @param int $user_id User to target, or 0 for the current user. Defaults to 0. + * + * @return mixed */ #[\Override] - public function set( string $key, mixed $value, int $user_id = 0 ): void { + public function get( string $key, mixed $default_value = null, int $user_id = 0 ): mixed { $user_id = $this->resolve_user_id( $user_id ); if ( $user_id < 1 ) { - return; + return $default_value; } - $entries = $this->load( $user_id ); - $entries[ $key ] = $value; - $this->save( $user_id, $entries ); + $entries = $this->load( $user_id ); + return \array_key_exists( $key, $entries ) ? $entries[ $key ] : $default_value; } /** - * Retrieve a value by key for a user, or the default if no value is stored under it. + * Persists a value under the given key for a user. Overwrites any existing value at the same key. * * @since 2.0.0 * @version 2.0.0 * - * @param string $key Identifier to look up. - * @param mixed $default_value Value to return when no entry exists at $key. Defaults to null. - * @param int $user_id User to target, or 0 for the current user. Defaults to 0. - * - * @return mixed + * @param string $key Identifier under which to store the value. + * @param mixed $value Value to persist. + * @param int $user_id User to target, or 0 for the current user. Defaults to 0. */ #[\Override] - public function get( string $key, mixed $default_value = null, int $user_id = 0 ): mixed { + public function set( string $key, mixed $value, int $user_id = 0 ): void { $user_id = $this->resolve_user_id( $user_id ); if ( $user_id < 1 ) { - return $default_value; + return; } - $entries = $this->load( $user_id ); - return \array_key_exists( $key, $entries ) ? $entries[ $key ] : $default_value; + $entries = $this->load( $user_id ); + $entries[ $key ] = $value; + $this->save( $user_id, $entries ); } /** - * Check whether a value is stored at the given key for a user. + * Checks whether a value is stored at the given key for a user. * * @since 2.0.0 * @version 2.0.0 @@ -109,7 +109,7 @@ public function has( string $key, int $user_id = 0 ): bool { } /** - * Delete the value stored at the given key for a user. + * Deletes the value stored at the given key for a user. * * @since 2.0.0 * @version 2.0.0 @@ -117,7 +117,7 @@ public function has( string $key, int $user_id = 0 ): bool { * @param string $key Identifier to delete. * @param int $user_id User to target, or 0 for the current user. Defaults to 0. * - * @return bool True if a value was deleted, false if no value existed under the key. + * @return bool True if a value was deleted, false if none existed. */ #[\Override] public function delete( string $key, int $user_id = 0 ): bool { @@ -135,7 +135,7 @@ public function delete( string $key, int $user_id = 0 ): bool { } /** - * Return all stored values for a user as a key-indexed array. + * Returns all stored values for a user as a key-indexed array. * * @since 2.0.0 * @version 2.0.0 @@ -154,7 +154,7 @@ public function get_all( int $user_id = 0 ): array { } /** - * Remove every stored value for a user. + * Removes every stored value for a user. * * @since 2.0.0 * @version 2.0.0 @@ -175,7 +175,7 @@ public function clear( int $user_id = 0 ): void { // region HELPERS /** - * Resolve a passed user ID to a concrete target, defaulting to the current user when 0. + * Resolves a passed user ID to a concrete target, defaulting to the current user when 0. * Non-positive results signal "no valid target" and are handled as no-ops by the callers. * * @since 2.0.0 @@ -190,7 +190,7 @@ protected function resolve_user_id( int $user_id ): int { } /** - * Load the entries array from user_meta for the given user. Returns an empty array if + * Loads the entries array from user_meta for the given user. Returns an empty array if * the meta key doesn't exist or holds a non-array value. * * @since 2.0.0 @@ -206,7 +206,7 @@ protected function load( int $user_id ): array { } /** - * Persist the entries array to user_meta for the given user. + * Persists the entries array to user_meta for the given user. * * @since 2.0.0 * @version 2.0.0 diff --git a/packages/infrastructure/tests/Settings/Integration/WordPressSettingsBackendTest.php b/packages/infrastructure/tests/Settings/Integration/WordPressSettingsBackendTest.php index eaecb42..ed6535e 100644 --- a/packages/infrastructure/tests/Settings/Integration/WordPressSettingsBackendTest.php +++ b/packages/infrastructure/tests/Settings/Integration/WordPressSettingsBackendTest.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\Framework\Settings\Tests\Integration; +use DeepWebSolutions\Framework\Settings\Backend\Exceptions\BackendAlreadyBoundException; use DeepWebSolutions\Framework\Settings\Backend\WordPressSettingsBackend; use DeepWebSolutions\Framework\Settings\Schema\Exceptions\DuplicateSettingsFieldException; use DeepWebSolutions\Framework\Settings\Schema\Exceptions\DuplicateSettingsSectionException; @@ -371,6 +372,16 @@ public function test_a_duplicate_section_id_on_a_page_throws(): void { ( new WordPressSettingsBackend() )->register_page( $page ); } + public function test_registering_a_second_page_on_the_same_instance_throws(): void { + $backend = $this->register( $this->page() ); + + // The backend is per-page: a second registration would silently re-route every field lookup to the + // new page while the first page's hooks stay live, so it must fail loudly instead. + $this->expectException( BackendAlreadyBoundException::class ); + + $backend->register_page( $this->page() ); + } + public function test_a_programmatic_delete_bypasses_the_registered_sanitizer(): void { $backend = $this->register( $this->page() ); \do_action( 'admin_init' ); diff --git a/packages/infrastructure/tests/Settings/Unit/MetaField/MetaBoxPlacementTest.php b/packages/infrastructure/tests/Settings/Unit/MetaField/MetaBoxPlacementTest.php index 8daed98..824baef 100644 --- a/packages/infrastructure/tests/Settings/Unit/MetaField/MetaBoxPlacementTest.php +++ b/packages/infrastructure/tests/Settings/Unit/MetaField/MetaBoxPlacementTest.php @@ -2,15 +2,21 @@ namespace DeepWebSolutions\Framework\Settings\Tests\Unit\MetaField; +use DeepWebSolutions\Framework\Settings\MetaField\Exceptions\InvalidMetaBoxPlacementException; use DeepWebSolutions\Framework\Settings\MetaField\ValueObjects\MetaBoxPlacement; use DeepWebSolutions\Framework\Shared\ValueObject\AbstractValueObject; +use DeepWebSolutions\Framework\Shared\ValueObject\Exceptions\InvalidValueObjectException; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\Attributes\UsesFunction; use PHPUnit\Framework\TestCase; #[CoversClass( MetaBoxPlacement::class )] #[UsesClass( AbstractValueObject::class )] +#[UsesClass( InvalidMetaBoxPlacementException::class )] +#[UsesClass( InvalidValueObjectException::class )] +#[UsesFunction( 'DeepWebSolutions\Framework\Shared\Identifier\is_valid_identifier' )] #[UsesFunction( 'DeepWebSolutions\Framework\Shared\Reflection\get_public_property_names' )] #[UsesFunction( 'DeepWebSolutions\Framework\Shared\Reflection\convert_to_primitives' )] final class MetaBoxPlacementTest extends TestCase { @@ -58,4 +64,61 @@ public function test_json_serialization_carries_all_four_properties(): void { $placement->jsonSerialize(), ); } + + #[DataProvider( 'invalid_screens' )] + public function test_a_screen_outside_the_identifier_charset_is_rejected( string $invalid_screen ): void { + $this->expectException( InvalidMetaBoxPlacementException::class ); + + new MetaBoxPlacement( screen: $invalid_screen, context: 'normal', priority: 'default' ); + } + + /** + * @return array + */ + public static function invalid_screens(): array { + return array( + 'empty' => array( '' ), + 'leading digit' => array( '1post' ), + 'leading underscore' => array( '_post' ), + 'uppercase' => array( 'Post' ), + 'space' => array( 'shop order' ), + ); + } + + public function test_a_context_outside_the_closed_set_is_rejected(): void { + $this->expectException( InvalidMetaBoxPlacementException::class ); + + new MetaBoxPlacement( screen: 'post', context: 'sidebar', priority: 'default' ); + } + + public function test_a_priority_outside_the_closed_set_is_rejected(): void { + $this->expectException( InvalidMetaBoxPlacementException::class ); + + new MetaBoxPlacement( screen: 'post', context: 'side', priority: 'urgent' ); + } + + #[DataProvider( 'valid_contexts_and_priorities' )] + public function test_every_context_and_priority_in_the_closed_sets_is_accepted( string $context, string $priority ): void { + $placement = new MetaBoxPlacement( screen: 'post', context: $context, priority: $priority ); + + self::assertSame( $context, $placement->context ); + self::assertSame( $priority, $placement->priority ); + } + + /** + * @return array + */ + public static function valid_contexts_and_priorities(): array { + return array( + 'normal + high' => array( 'normal', 'high' ), + 'side + core' => array( 'side', 'core' ), + 'advanced + default' => array( 'advanced', 'default' ), + 'normal + low' => array( 'normal', 'low' ), + ); + } + + public function test_get_capability_returns_the_override_or_the_edit_post_fallback(): void { + self::assertSame( 'edit_post', ( new MetaBoxPlacement( screen: 'post', context: 'side', priority: 'default' ) )->get_capability() ); + self::assertSame( 'manage_woocommerce', ( new MetaBoxPlacement( screen: 'product', context: 'side', priority: 'default', capability: 'manage_woocommerce' ) )->get_capability() ); + } } diff --git a/packages/infrastructure/tests/Settings/Unit/MetaField/ObjectFieldFormTest.php b/packages/infrastructure/tests/Settings/Unit/MetaField/ObjectFieldFormTest.php index 7610c91..76717d9 100644 --- a/packages/infrastructure/tests/Settings/Unit/MetaField/ObjectFieldFormTest.php +++ b/packages/infrastructure/tests/Settings/Unit/MetaField/ObjectFieldFormTest.php @@ -132,57 +132,57 @@ public function test_meta_keys_evaluates_the_provider_for_an_explicit_object(): self::assertSame( array( '_dws_note_9' ), $this->form()->meta_keys( $group, 9 ) ); } - public function test_store_writes_under_the_resolved_storage_key(): void { + public function test_set_writes_under_the_resolved_storage_key(): void { $repository = new InMemoryObjectMetaRepository(); $form = new ObjectFieldForm( $repository ); $group = $this->group( new SettingsField( id: 'note', type: 'text', label: 'Note', meta_key: '_dws_note' ) ); - $form->store( $group, 7, 'note', 'hello' ); + $form->set( $group, 7, 'note', 'hello' ); self::assertSame( 'hello', $repository->get( 7, '_dws_note' ) ); self::assertFalse( $repository->has( 7, 'note' ) ); } - public function test_store_normalizes_a_checkbox_to_its_canonical_yes_no_form(): void { + public function test_set_normalizes_a_checkbox_to_its_canonical_yes_no_form(): void { $repository = new InMemoryObjectMetaRepository(); $form = new ObjectFieldForm( $repository ); $group = $this->group( new SettingsField( id: 'flag', type: 'checkbox', label: 'Flag' ) ); - $form->store( $group, 7, 'flag', '1' ); + $form->set( $group, 7, 'flag', '1' ); self::assertSame( 'yes', $repository->get( 7, 'flag' ) ); - $form->store( $group, 7, 'flag', false ); + $form->set( $group, 7, 'flag', false ); self::assertSame( 'no', $repository->get( 7, 'flag' ) ); self::assertTrue( $repository->has( 7, 'flag' ) ); } - public function test_store_revokes_the_key_for_each_value_a_form_save_would_not_store(): void { + public function test_set_revokes_the_key_for_each_value_a_form_save_would_not_store(): void { $repository = new InMemoryObjectMetaRepository(); $form = new ObjectFieldForm( $repository ); $group = $this->group( new SettingsField( id: 'note', type: 'text', label: 'Note' ) ); foreach ( array( false, '', array() ) as $empty ) { - $form->store( $group, 7, 'note', 'kept' ); - $form->store( $group, 7, 'note', $empty ); + $form->set( $group, 7, 'note', 'kept' ); + $form->set( $group, 7, 'note', $empty ); self::assertFalse( $repository->has( 7, 'note' ) ); } } - public function test_store_preserves_a_meaningful_zero(): void { + public function test_set_preserves_a_meaningful_zero(): void { $repository = new InMemoryObjectMetaRepository(); $form = new ObjectFieldForm( $repository ); $group = $this->group( new SettingsField( id: 'note', type: 'text', label: 'Note' ) ); - $form->store( $group, 7, 'note', '0' ); + $form->set( $group, 7, 'note', '0' ); self::assertSame( '0', $repository->get( 7, 'note' ) ); } - public function test_store_rejects_a_field_the_group_does_not_declare(): void { + public function test_set_rejects_a_field_the_group_does_not_declare(): void { $this->expectException( InvalidSettingsFieldException::class ); - ( new ObjectFieldForm( new InMemoryObjectMetaRepository() ) )->store( + ( new ObjectFieldForm( new InMemoryObjectMetaRepository() ) )->set( $this->group( new SettingsField( id: 'note', type: 'text', label: 'Note' ) ), 7, 'missing', @@ -190,6 +190,34 @@ public function test_store_rejects_a_field_the_group_does_not_declare(): void { ); } + public function test_get_reads_the_resolved_storage_key_and_falls_back_to_the_caller_default(): void { + $repository = new InMemoryObjectMetaRepository(); + $form = new ObjectFieldForm( $repository ); + $group = $this->group( new SettingsField( id: 'note', type: 'text', label: 'Note', meta_key: '_dws_note', default_value: 'declared-default' ) ); + + self::assertNull( $form->get( $group, 7, 'note' ) ); + self::assertSame( 'fallback', $form->get( $group, 7, 'note', 'fallback' ) ); + + $repository->set( 7, '_dws_note', 'hello' ); + + self::assertSame( 'hello', $form->get( $group, 7, 'note' ) ); + } + + public function test_has_and_delete_address_the_resolved_storage_key(): void { + $repository = new InMemoryObjectMetaRepository(); + $form = new ObjectFieldForm( $repository ); + $group = $this->group( new SettingsField( id: 'note', type: 'text', label: 'Note', meta_key: '_dws_note' ) ); + + self::assertFalse( $form->has( $group, 7, 'note' ) ); + + $repository->set( 7, '_dws_note', 'hello' ); + + self::assertTrue( $form->has( $group, 7, 'note' ) ); + self::assertTrue( $form->delete( $group, 7, 'note' ) ); + self::assertFalse( $form->has( $group, 7, 'note' ) ); + self::assertFalse( $form->delete( $group, 7, 'note' ) ); + } + private function form(): ObjectFieldForm { $repository = new class() implements ObjectMetaRepositoryInterface { public function get( int $object_id, string $meta_key, mixed $default_value = null ): mixed { diff --git a/packages/infrastructure/tests/Settings/Unit/SettingsPageTest.php b/packages/infrastructure/tests/Settings/Unit/SettingsPageTest.php index 5527d60..1678cc9 100644 --- a/packages/infrastructure/tests/Settings/Unit/SettingsPageTest.php +++ b/packages/infrastructure/tests/Settings/Unit/SettingsPageTest.php @@ -53,6 +53,7 @@ public function test_accepts_valid_slugs( string $valid_slug ): void { page_title: 'T', menu_title: 'T', capability: 'manage_options', + sections: array(), ); self::assertSame( $valid_slug, $page->slug ); @@ -79,6 +80,7 @@ public function test_rejects_invalid_slugs( string $invalid_slug ): void { page_title: 'T', menu_title: 'T', capability: 'manage_options', + sections: array(), ); } diff --git a/packages/infrastructure/tests/Settings/Unit/WordPressSettingsBackendTest.php b/packages/infrastructure/tests/Settings/Unit/WordPressSettingsBackendTest.php index e166824..eb63bf8 100644 --- a/packages/infrastructure/tests/Settings/Unit/WordPressSettingsBackendTest.php +++ b/packages/infrastructure/tests/Settings/Unit/WordPressSettingsBackendTest.php @@ -56,11 +56,14 @@ public function test_option_keys_needs_no_registration_and_ignores_field_count() } public function test_option_keys_is_empty_for_a_sectionless_page(): void { + // A sectionless page is never declared by a consumer (sections is a required parameter) but is a + // real derived shape: a page projection whose sections were all dropped still enumerates cleanly. $page = new SettingsPage( slug: 'dws-shop', page_title: 'Shop', menu_title: 'Shop', capability: 'manage_options', + sections: array(), ); self::assertSame( array(), ( new WordPressSettingsBackend() )->option_keys( $page ) ); diff --git a/packages/shared/src/ValueObject/Exceptions/InvalidValueObjectException.php b/packages/shared/src/ValueObject/Exceptions/InvalidValueObjectException.php index 116b4ce..45ec6f8 100644 --- a/packages/shared/src/ValueObject/Exceptions/InvalidValueObjectException.php +++ b/packages/shared/src/ValueObject/Exceptions/InvalidValueObjectException.php @@ -34,7 +34,7 @@ abstract class InvalidValueObjectException extends AbstractInvalidArgumentExcept * @since 2.0.0 * @version 2.0.0 * - * @param string $reason Why the value object is invalid. + * @param string $reason Why the value object is invalid; supplies its own terminal period — the message template appends no punctuation. * @param int $code Exception code. * @param \Throwable|null $previous Previous exception for chaining. */ diff --git a/packages/shared/src/Version/Version.php b/packages/shared/src/Version/Version.php index cdc9661..df46b01 100644 --- a/packages/shared/src/Version/Version.php +++ b/packages/shared/src/Version/Version.php @@ -115,7 +115,7 @@ public static function from_string( string $value ): self { $pattern = '/^\d+(\.\d+){0,2}(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/'; if ( 1 !== \preg_match( $pattern, $value ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- framework-internal exception; never reaches an HTML output context unescaped. - throw new InvalidVersionException( "'$value' does not parse as a version" ); + throw new InvalidVersionException( "'$value' does not parse as a version." ); } return new self( $value ); @@ -142,7 +142,7 @@ public static function from_string( string $value ): self { */ public static function from_parts( int $major, ?int $minor = null, ?int $patch = null, ?string $prerelease = null, ?string $build = null ): self { if ( null === $minor && null !== $patch ) { - throw new InvalidVersionException( 'a patch version requires a minor version' ); + throw new InvalidVersionException( 'a patch version requires a minor version.' ); } $value = (string) $major; diff --git a/packages/woocommerce/src/Backend/WooCommerceSettingsBackend.php b/packages/woocommerce/src/Backend/WooCommerceSettingsBackend.php index 739ae81..8bef9da 100644 --- a/packages/woocommerce/src/Backend/WooCommerceSettingsBackend.php +++ b/packages/woocommerce/src/Backend/WooCommerceSettingsBackend.php @@ -6,6 +6,7 @@ use DeepWebSolutions\Framework\Settings\Schema\Exceptions\DuplicateSettingsFieldException; use DeepWebSolutions\Framework\Settings\Schema\Exceptions\DuplicateSettingsSectionException; use DeepWebSolutions\Framework\Settings\Schema\Exceptions\InvalidSettingsFieldException; +use DeepWebSolutions\Framework\Settings\Schema\Exceptions\InvalidSettingsPageException; use DeepWebSolutions\Framework\Settings\Schema\ValueObjects\SettingsField; use DeepWebSolutions\Framework\Settings\Schema\ValueObjects\SettingsPage; use DeepWebSolutions\Framework\WooCommerce\Backend\Exceptions\UnsupportedSettingsPageCapabilityException; @@ -87,6 +88,7 @@ public function __construct( * @since 2.0.0 * @version 2.0.0 * + * @throws InvalidSettingsPageException If the page declares no sections. * @throws DuplicateSettingsSectionException If two sections on the page share an id. * @throws DuplicateSettingsFieldException If two fields on the page share an id. * @throws UnsupportedSettingsPageCapabilityException If the page capability differs from WooCommerce's settings capability. @@ -95,6 +97,13 @@ public function __construct( public function register_page( SettingsPage $page ): void { $this->assert_supported_page_capability( $page ); + // The authoring-time seam: a registered page with zero sections is a permanently blank settings + // tab, while the render-time capability projection may legitimately empty a page per user. + if ( array() === $page->sections ) { + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- framework-internal exception; never reaches an HTML output context unescaped. + throw new InvalidSettingsPageException( "Settings page '$page->slug' declares no sections; a registered page must carry at least one section to render." ); + } + $this->page = $page; $this->fields = $this->map_fields( $page ); diff --git a/packages/woocommerce/src/OrderData/OrderFieldSurface.php b/packages/woocommerce/src/OrderData/OrderFieldSurface.php index 5e9515a..6a17bd5 100644 --- a/packages/woocommerce/src/OrderData/OrderFieldSurface.php +++ b/packages/woocommerce/src/OrderData/OrderFieldSurface.php @@ -143,8 +143,7 @@ public function register( FieldGroup $group, MetaBoxPlacement $placement ): void } /** - * Retrieves a field's stored value for an order, or $default_value when nothing is stored. Object fields - * are revoke-based, so the field's declared default is never a read-time fallback. + * Retrieves a field's stored value for an order — {@see ObjectFieldForm::get()} for the read semantics. * * @since 2.0.0 * @version 2.0.0 @@ -160,15 +159,11 @@ public function register( FieldGroup $group, MetaBoxPlacement $placement ): void * @return mixed */ public function get( FieldGroup $group, int $order_id, string $field_id, mixed $default_value = null ): mixed { - return $this->repository->get( $order_id, $this->form->meta_key_of( $group, $order_id, $field_id ), $default_value ); + return $this->form->get( $group, $order_id, $field_id, $default_value ); } /** - * Persists a field's value for an order with the form path's store-or-revoke semantics: a checkbox - * value is stored in its canonical 'yes'/'no' form (false stores 'no'), and a non-checkbox value a - * form save would not store — false, a cleared field ('') or an empty multi-select (array()) — - * revokes the meta key instead. The write is programmatic: the descriptor's sanitize/validate seam - * applies to form submissions only. + * Persists a field's value for an order — {@see ObjectFieldForm::set()} for the store-or-revoke semantics. * * @since 2.0.0 * @version 2.0.0 @@ -182,11 +177,11 @@ public function get( FieldGroup $group, int $order_id, string $field_id, mixed $ * @throws InvalidSettingsFieldException If the group declares no field with the given id. */ public function set( FieldGroup $group, int $order_id, string $field_id, mixed $value ): void { - $this->form->store( $group, $order_id, $field_id, $value ); + $this->form->set( $group, $order_id, $field_id, $value ); } /** - * Whether a real value is stored for a field on an order. + * Whether a real value is stored for a field on an order — {@see ObjectFieldForm::has()}. * * @since 2.0.0 * @version 2.0.0 @@ -201,11 +196,11 @@ public function set( FieldGroup $group, int $order_id, string $field_id, mixed $ * @return bool */ public function has( FieldGroup $group, int $order_id, string $field_id ): bool { - return $this->repository->has( $order_id, $this->form->meta_key_of( $group, $order_id, $field_id ) ); + return $this->form->has( $group, $order_id, $field_id ); } /** - * Deletes a field's stored value from an order. + * Deletes a field's stored value from an order — {@see ObjectFieldForm::delete()}. * * @since 2.0.0 * @version 2.0.0 @@ -220,7 +215,7 @@ public function has( FieldGroup $group, int $order_id, string $field_id ): bool * @return bool True if a value was deleted, false if none existed. */ public function delete( FieldGroup $group, int $order_id, string $field_id ): bool { - return $this->repository->delete( $order_id, $this->form->meta_key_of( $group, $order_id, $field_id ) ); + return $this->form->delete( $group, $order_id, $field_id ); } /** @@ -329,10 +324,8 @@ protected function add_box( FieldGroup $group, MetaBoxPlacement $placement, stri return; } - $priority = match ( $placement->priority ) { - 'core', 'high', 'low' => $placement->priority, - default => 'default', - }; + /** @var 'high'|'core'|'default'|'low' $priority */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort -- inline @var type assertion; the placement constructor validates the closed set. + $priority = $placement->priority; \add_meta_box( $group->id, diff --git a/packages/woocommerce/tests/Integration/DescriptorBackedWooCommerceSettingsPageTest.php b/packages/woocommerce/tests/Integration/DescriptorBackedWooCommerceSettingsPageTest.php index 9ba6b54..d666679 100644 --- a/packages/woocommerce/tests/Integration/DescriptorBackedWooCommerceSettingsPageTest.php +++ b/packages/woocommerce/tests/Integration/DescriptorBackedWooCommerceSettingsPageTest.php @@ -47,7 +47,7 @@ public function test_recovers_its_descriptor_id_and_label_from_the_static_map(): public function test_falls_back_to_the_slug_when_the_descriptor_has_no_location(): void { DescriptorBackedWooCommerceSettingsPage::bind( FooWooCommerceSettingsPage::class, - new SettingsPage( slug: 'dws-foo', page_title: 'Foo', menu_title: 'Foo', capability: 'manage_woocommerce' ), + new SettingsPage( slug: 'dws-foo', page_title: 'Foo', menu_title: 'Foo', capability: 'manage_woocommerce', sections: array() ), ); self::assertSame( 'dws-foo', ( new FooWooCommerceSettingsPage() )->get_id() ); @@ -56,7 +56,7 @@ public function test_falls_back_to_the_slug_when_the_descriptor_has_no_location( public function test_the_tab_id_is_sanitized_for_woocommerce_routing(): void { DescriptorBackedWooCommerceSettingsPage::bind( FooWooCommerceSettingsPage::class, - new SettingsPage( slug: 'dws-foo', page_title: 'Foo', menu_title: 'Foo', capability: 'manage_woocommerce', location: 'DWS Foo' ), + new SettingsPage( slug: 'dws-foo', page_title: 'Foo', menu_title: 'Foo', capability: 'manage_woocommerce', sections: array(), location: 'DWS Foo' ), ); // WooCommerce routes the settings screen by sanitize_title($_GET['tab']) but the page registers its diff --git a/packages/woocommerce/tests/Unit/WooCommerceSettingsBuilderTest.php b/packages/woocommerce/tests/Unit/WooCommerceSettingsBuilderTest.php index e2ed3f8..339d5e5 100644 --- a/packages/woocommerce/tests/Unit/WooCommerceSettingsBuilderTest.php +++ b/packages/woocommerce/tests/Unit/WooCommerceSettingsBuilderTest.php @@ -361,11 +361,14 @@ public function test_a_non_array_multiselect_default_becomes_an_empty_array(): v } public function test_a_page_without_sections_builds_an_empty_array(): void { + // A sectionless page is a real derived shape: the editable-page projection drops every section the + // current user cannot edit, and the builder must degrade to an empty settings array, not fatal. $page = new SettingsPage( slug: 'dws-shop', page_title: 'DWS Shop', menu_title: 'DWS Shop', capability: 'manage_woocommerce', + sections: array(), ); self::assertSame( array(), ( new WooCommerceSettingsBuilder() )->build( $page ) ); From 482a1f9ccb2996ef04c2294195f229f2f0e451cd Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Tue, 7 Jul 2026 23:40:46 +0200 Subject: [PATCH 05/10] refactor(woocommerce)!: route product-field CRUD through WC CRUD and align the package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Product-field persistence no longer assumes post meta: get/set/has/delete and the panel render go through wc_get_product() and WC_Data meta methods, so the surface follows whatever datastore WooCommerce backs products with. has() keeps its real-stored-value semantics by filtering out the injected meta_id=0 default rows (the same discrimination the before-save strip uses). The three default-injection filters remain postmeta-coupled by nature — the recorded re-entry trigger is a non-postmeta product datastore, which needs a new injection seam only. Family alignment: CRUD addresses (section_id, product_id, field_id) — descriptor first, like every other field surface — and the singular meta_key() resolver is protected. to_yes_no() and the package's functions autoload are gone (Schema's normalize_checkbox_value is the one canonical mapping); stringify_option_labels() and meta_box_row_html() land in Schema/functions.php, deleting the private copies in the builder, product renderer, and both meta-box surfaces. OrderMetaRepository composes MetadataRepository(MetaType::Post) as its non-order fallback instead of inlining it. UnsupportedSettingsPageCapabilityException rebases onto AbstractRuntimeException per the recorded Unsupported* rule. The WC settings backend gains the late-registration logger diagnostic its WordPress sibling has. ProductDataFieldRenderer accepts CustomFieldType registrations (render-only bridge; tab-level custom_renderers win, and the per-field sanitize requirement stays). WC conditionals become final readonly; ProductDataTab opens per the descriptor grammar; save-path @throws parity lands on the post and term surfaces. Assisted-by: Claude Code:claude-fable-5 --- composer.lock | 7 +- .../Surfaces/PostMetaFieldSurface.php | 13 +- .../MetaField/Surfaces/TermFieldSurface.php | 6 + .../src/Settings/Schema/functions.php | 45 +++++- .../Settings/Unit/SchemaFunctionsTest.php | 23 +++ packages/woocommerce/composer.json | 5 +- packages/woocommerce/functions.php | 9 -- packages/woocommerce/phpstan.neon | 1 - ...pportedSettingsPageCapabilityException.php | 4 +- .../Backend/WooCommerceSettingsBackend.php | 10 ++ .../Backend/WooCommerceSettingsBuilder.php | 29 +--- .../WooCommerceDbVersionConditional.php | 6 +- .../WooCommerceVersionConditional.php | 6 +- .../src/OrderData/OrderFieldSurface.php | 9 +- .../src/OrderData/OrderMetaRepository.php | 48 +++--- .../ProductData/ProductDataFieldRenderer.php | 48 +++--- .../ProductData/ProductDataFieldSurface.php | 127 ++++++++------- .../src/ProductData/ProductDataTab.php | 6 +- packages/woocommerce/src/functions.php | 21 --- .../ProductDataFieldSurfaceTest.php | 144 ++++++++++++++---- .../woocommerce/tests/Unit/FunctionsTest.php | 46 ------ .../Unit/ProductDataFieldRendererTest.php | 41 ++++- .../Unit/WooCommerceSettingsBuilderTest.php | 3 +- 23 files changed, 393 insertions(+), 264 deletions(-) delete mode 100644 packages/woocommerce/functions.php delete mode 100644 packages/woocommerce/src/functions.php delete mode 100644 packages/woocommerce/tests/Unit/FunctionsTest.php diff --git a/composer.lock b/composer.lock index 6e20763..9b6b224 100644 --- a/composer.lock +++ b/composer.lock @@ -334,7 +334,7 @@ "dist": { "type": "path", "url": "packages/woocommerce", - "reference": "9c4f77356ad588c05b9ec9ec8f3a951aa55b21e5" + "reference": "e2842b1d5915abc0a6279d593be244bb000ec004" }, "require": { "ahegyes/wp-framework-core": "^2.0@dev", @@ -370,10 +370,7 @@ "autoload": { "psr-4": { "DeepWebSolutions\\Framework\\WooCommerce\\": "src/" - }, - "files": [ - "functions.php" - ] + } }, "autoload-dev": { "psr-4": { diff --git a/packages/infrastructure/src/Settings/MetaField/Surfaces/PostMetaFieldSurface.php b/packages/infrastructure/src/Settings/MetaField/Surfaces/PostMetaFieldSurface.php index 35ae010..bad8342 100644 --- a/packages/infrastructure/src/Settings/MetaField/Surfaces/PostMetaFieldSurface.php +++ b/packages/infrastructure/src/Settings/MetaField/Surfaces/PostMetaFieldSurface.php @@ -14,7 +14,7 @@ use DeepWebSolutions\Framework\Storage\ObjectMeta\MetaType; use DeepWebSolutions\Framework\Storage\ObjectMeta\ObjectMetaRepositoryInterface; -use function DeepWebSolutions\Framework\Settings\Schema\field_label_html; +use function DeepWebSolutions\Framework\Settings\Schema\meta_box_row_html; /** * Surface that mounts a field group onto the post edit screen as a meta box and stores its fields as post meta. @@ -219,6 +219,8 @@ public function add_boxes( \WP_Post $post ): void { * @version 2.0.0 * * @param int $post_id Post whose meta to write. + * + * @throws DuplicateSettingsFieldException If two of a group's fields share an id or storage key. */ public function save_boxes( int $post_id ): void { $post_type = \get_post_type( $post_id ); @@ -280,9 +282,7 @@ protected function add_box( FieldGroup $group, MetaBoxPlacement $placement, \WP_ } /** - * The row closure wrapping each control in a meta-box row, its label bound to the control's DOM id. - * A div, not a paragraph: a radio fieldset or the description paragraph inside a p would be reparsed - * as invalid HTML. + * The row closure wrapping each control in a meta-box row — {@see meta_box_row_html()}. * * @since 2.0.0 * @version 2.0.0 @@ -290,8 +290,7 @@ protected function add_box( FieldGroup $group, MetaBoxPlacement $placement, \WP_ * @return \Closure */ protected function box_row(): \Closure { - return static fn ( SettingsField $field, string $control, string $control_id ): string => - '
' . field_label_html( $field, $control_id ) . '
' . $control . '
'; + return static fn ( SettingsField $field, string $control, string $control_id ): string => meta_box_row_html( $field, $control, $control_id ); } /** @@ -306,6 +305,8 @@ protected function box_row(): \Closure { * @param FieldGroup $group Group to save. * @param MetaBoxPlacement $placement Placement whose capability gates the save. * @param int $post_id Post whose meta to write. + * + * @throws DuplicateSettingsFieldException If two of the group's fields share an id or storage key. */ protected function save_box( FieldGroup $group, MetaBoxPlacement $placement, int $post_id ): void { if ( ! \current_user_can( $placement->get_capability(), $post_id ) ) { diff --git a/packages/infrastructure/src/Settings/MetaField/Surfaces/TermFieldSurface.php b/packages/infrastructure/src/Settings/MetaField/Surfaces/TermFieldSurface.php index 8b81b54..3a56c80 100644 --- a/packages/infrastructure/src/Settings/MetaField/Surfaces/TermFieldSurface.php +++ b/packages/infrastructure/src/Settings/MetaField/Surfaces/TermFieldSurface.php @@ -247,6 +247,8 @@ public function render_edit_term( \WP_Term $term ): void { * @version 2.0.0 * * @param int $term_id Term whose meta to write. + * + * @throws DuplicateSettingsFieldException If two of a group's fields share an id or storage key. */ public function save_created_term( int $term_id ): void { $this->save_term( $term_id, 0 ); @@ -260,6 +262,8 @@ public function save_created_term( int $term_id ): void { * @version 2.0.0 * * @param int $term_id Term whose meta to write. + * + * @throws DuplicateSettingsFieldException If two of a group's fields share an id or storage key. */ public function save_edited_term( int $term_id ): void { $this->save_term( $term_id ); @@ -277,6 +281,8 @@ public function save_edited_term( int $term_id ): void { * * @param int $term_id Term whose meta to write. * @param ?int $nonce_object_id Object id the nonce is bound to; null uses $term_id. + * + * @throws DuplicateSettingsFieldException If two of a group's fields share an id or storage key. */ protected function save_term( int $term_id, ?int $nonce_object_id = null ): void { if ( ! \current_user_can( 'edit_term', $term_id ) ) { diff --git a/packages/infrastructure/src/Settings/Schema/functions.php b/packages/infrastructure/src/Settings/Schema/functions.php index 44b8a2d..a5a1060 100644 --- a/packages/infrastructure/src/Settings/Schema/functions.php +++ b/packages/infrastructure/src/Settings/Schema/functions.php @@ -1,8 +1,9 @@ $options Resolved value-to-label map. + * + * @return array + */ +function stringify_option_labels( array $options ): array { + $labels = array(); + foreach ( $options as $value => $label ) { + $labels[ $value ] = namespace\stringify_for_output( $label ); + } + + return $labels; +} + /** * Renders a field's visible label for a surface's label cell as an escaped HTML string: a label * element bound to the control's DOM id where a single control carries the accessible name, or the @@ -173,6 +196,24 @@ function field_label_html( SettingsField $field, string $control_id ): string { return \sprintf( '', \esc_attr( $control_id ), \esc_html( $field->label ) ); } +/** + * Renders a field's meta-box row as an escaped HTML string: the control wrapped in the shared row + * markup, its label bound to the control's DOM id. A div, not a paragraph: a radio fieldset or the + * description paragraph inside a p would be reparsed as invalid HTML. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param SettingsField $field Field the row hosts. + * @param string $control Escaped HTML markup of the field's rendered control. + * @param string $control_id DOM id of the field's rendered control; '' renders no label association. + * + * @return string + */ +function meta_box_row_html( SettingsField $field, string $control, string $control_id ): string { + return '
' . namespace\field_label_html( $field, $control_id ) . '
' . $control . '
'; +} + /** * Whether the current user may edit a field: a field with no capability is always editable, otherwise * the current user must hold the field's primitive capability. Object-scoped checks stay with the diff --git a/packages/infrastructure/tests/Settings/Unit/SchemaFunctionsTest.php b/packages/infrastructure/tests/Settings/Unit/SchemaFunctionsTest.php index 635c302..7def604 100644 --- a/packages/infrastructure/tests/Settings/Unit/SchemaFunctionsTest.php +++ b/packages/infrastructure/tests/Settings/Unit/SchemaFunctionsTest.php @@ -23,6 +23,7 @@ use function DeepWebSolutions\Framework\Settings\Schema\resolve_field_control_id; use function DeepWebSolutions\Framework\Settings\Schema\rest_schema_for_field; use function DeepWebSolutions\Framework\Settings\Schema\stringify_for_output; +use function DeepWebSolutions\Framework\Settings\Schema\stringify_option_labels; use function DeepWebSolutions\Framework\Settings\Schema\wordpress_field_type_sanitizers; #[CoversFunction( 'DeepWebSolutions\Framework\Settings\Schema\assert_unique_section_and_field_ids' )] @@ -33,6 +34,7 @@ #[CoversFunction( 'DeepWebSolutions\Framework\Settings\Schema\normalize_checkbox_value' )] #[CoversFunction( 'DeepWebSolutions\Framework\Settings\Schema\rest_schema_for_field' )] #[CoversFunction( 'DeepWebSolutions\Framework\Settings\Schema\stringify_for_output' )] +#[CoversFunction( 'DeepWebSolutions\Framework\Settings\Schema\stringify_option_labels' )] #[CoversFunction( 'DeepWebSolutions\Framework\Settings\Schema\wordpress_field_type_sanitizers' )] #[UsesClass( SettingsField::class )] #[UsesClass( SettingsSection::class )] @@ -352,6 +354,27 @@ public static function output_stringification_matrix(): array { ); } + public function test_stringify_option_labels_coerces_each_label_and_preserves_keys(): void { + $labels = stringify_option_labels( + array( + 'stripe' => 'Stripe', + 1 => 100, + 'bad' => array( 'nested' ), + 'flag' => false, + ), + ); + + self::assertSame( + array( + 'stripe' => 'Stripe', + 1 => '100', + 'bad' => '', + 'flag' => '', + ), + $labels, + ); + } + public function test_the_number_type_sanitizer_coerces_numeric_input_and_rejects_the_rest(): void { $number = wordpress_field_type_sanitizers()[ FieldType::Number->value ]; diff --git a/packages/woocommerce/composer.json b/packages/woocommerce/composer.json index 2fb085e..89343a3 100644 --- a/packages/woocommerce/composer.json +++ b/packages/woocommerce/composer.json @@ -27,10 +27,7 @@ "autoload": { "psr-4": { "DeepWebSolutions\\Framework\\WooCommerce\\": "src/" - }, - "files": [ - "functions.php" - ] + } }, "autoload-dev": { "psr-4": { diff --git a/packages/woocommerce/functions.php b/packages/woocommerce/functions.php deleted file mode 100644 index 6c906ae..0000000 --- a/packages/woocommerce/functions.php +++ /dev/null @@ -1,9 +0,0 @@ -/functions.php files. Composer autoloads only this file. - * - * @since 2.0.0 - * @version 2.0.0 - */ - -require_once __DIR__ . '/src/functions.php'; diff --git a/packages/woocommerce/phpstan.neon b/packages/woocommerce/phpstan.neon index e260ad0..970557f 100644 --- a/packages/woocommerce/phpstan.neon +++ b/packages/woocommerce/phpstan.neon @@ -8,6 +8,5 @@ parameters: - ../../vendor/php-stubs/woocommerce-stubs/woocommerce-stubs.php - ../../vendor/php-stubs/woocommerce-stubs/woocommerce-packages-stubs.php paths: - - functions.php - src - tests diff --git a/packages/woocommerce/src/Backend/Exceptions/UnsupportedSettingsPageCapabilityException.php b/packages/woocommerce/src/Backend/Exceptions/UnsupportedSettingsPageCapabilityException.php index c4c9b49..c0046df 100644 --- a/packages/woocommerce/src/Backend/Exceptions/UnsupportedSettingsPageCapabilityException.php +++ b/packages/woocommerce/src/Backend/Exceptions/UnsupportedSettingsPageCapabilityException.php @@ -2,7 +2,7 @@ namespace DeepWebSolutions\Framework\WooCommerce\Backend\Exceptions; -use DeepWebSolutions\Framework\Shared\Exception\AbstractInvalidArgumentException; +use DeepWebSolutions\Framework\Shared\Exception\AbstractRuntimeException; /** * Thrown when a WooCommerce settings page declares a capability WooCommerce cannot enforce on save. @@ -10,4 +10,4 @@ * @since 2.0.0 * @version 2.0.0 */ -final class UnsupportedSettingsPageCapabilityException extends AbstractInvalidArgumentException {} +final class UnsupportedSettingsPageCapabilityException extends AbstractRuntimeException {} diff --git a/packages/woocommerce/src/Backend/WooCommerceSettingsBackend.php b/packages/woocommerce/src/Backend/WooCommerceSettingsBackend.php index 8bef9da..a0118c8 100644 --- a/packages/woocommerce/src/Backend/WooCommerceSettingsBackend.php +++ b/packages/woocommerce/src/Backend/WooCommerceSettingsBackend.php @@ -10,6 +10,7 @@ use DeepWebSolutions\Framework\Settings\Schema\ValueObjects\SettingsField; use DeepWebSolutions\Framework\Settings\Schema\ValueObjects\SettingsPage; use DeepWebSolutions\Framework\WooCommerce\Backend\Exceptions\UnsupportedSettingsPageCapabilityException; +use Psr\Log\LoggerInterface; use function DeepWebSolutions\Framework\Settings\Schema\assert_unique_section_and_field_ids; use function DeepWebSolutions\Framework\Settings\Schema\is_field_editable_by_current_user; @@ -73,9 +74,11 @@ final class WooCommerceSettingsBackend implements SettingsBackendInterface { * @version 2.0.0 * * @param class-string $page_class Consumer subclass that renders the page as a WooCommerce tab. + * @param ?LoggerInterface $logger Logger for late-registration diagnostics; null silences them. */ public function __construct( protected string $page_class, + protected ?LoggerInterface $logger = null, ) {} // endregion @@ -107,6 +110,13 @@ public function register_page( SettingsPage $page ): void { $this->page = $page; $this->fields = $this->map_fields( $page ); + if ( \did_filter( 'woocommerce_get_settings_pages' ) > 0 ) { + $this->logger?->warning( + 'Settings page registered after woocommerce_get_settings_pages fired; its tab will not appear.', + array( 'slug' => $page->slug ), + ); + } + // Bind and instantiate inside the filter, not here: WooCommerce loads WC_Settings_Page (the page // subclass's parent) only when it builds its settings pages, just before applying this filter. // Touching the subclass at registration time (plugins_loaded) would fatal on the missing parent. diff --git a/packages/woocommerce/src/Backend/WooCommerceSettingsBuilder.php b/packages/woocommerce/src/Backend/WooCommerceSettingsBuilder.php index dbd7cf2..b5718a7 100644 --- a/packages/woocommerce/src/Backend/WooCommerceSettingsBuilder.php +++ b/packages/woocommerce/src/Backend/WooCommerceSettingsBuilder.php @@ -9,8 +9,9 @@ use DeepWebSolutions\Framework\Settings\Schema\ValueObjects\SettingsSection; use function DeepWebSolutions\Framework\Settings\Schema\filter_field_attributes; +use function DeepWebSolutions\Framework\Settings\Schema\normalize_checkbox_value; use function DeepWebSolutions\Framework\Settings\Schema\stringify_for_output; -use function DeepWebSolutions\Framework\WooCommerce\to_yes_no; +use function DeepWebSolutions\Framework\Settings\Schema\stringify_option_labels; /** * Translates a settings page descriptor into WooCommerce's settings-array format. @@ -132,7 +133,7 @@ protected function build_field( string $slug, SettingsField $field ): array { // A choice field always carries an options array: WooCommerce iterates it unconditionally when rendering. if ( $this->expects_options( $field->type ) ) { - $entry['options'] = $this->stringify_labels( $this->options_resolver->resolve( $field->options ) ); + $entry['options'] = stringify_option_labels( $this->options_resolver->resolve( $field->options ) ); } $attributes = filter_field_attributes( $field->attributes ); @@ -155,7 +156,7 @@ protected function build_field( string $slug, SettingsField $field ): array { */ protected function map_default( SettingsField $field ): mixed { return match ( $field->type ) { - FieldType::Checkbox->value => to_yes_no( $field->default_value ), + FieldType::Checkbox->value => normalize_checkbox_value( $field->default_value ), // WooCommerce matches multiselect selections with a strict (string) in_array, so the set must be strings. FieldType::Multiselect->value => $this->stringify_selected( $field->default_value ), default => $field->default_value, @@ -180,28 +181,6 @@ protected function expects_options( string $type ): bool { ); } - /** - * Stringifies a resolved options map's labels, matching the framework renderer's coercion. - * - * WooCommerce passes each option label through esc_html(), which expects a string; a non-scalar - * label becomes an empty string, exactly as the WordPress field renderer coerces it. - * - * @since 2.0.0 - * @version 2.0.0 - * - * @param array $options Resolved value-to-label map. - * - * @return array - */ - protected function stringify_labels( array $options ): array { - $labels = array(); - foreach ( $options as $value => $label ) { - $labels[ $value ] = stringify_for_output( $label ); - } - - return $labels; - } - /** * Stringifies a multiselect default's selected values; a non-array default selects nothing. * diff --git a/packages/woocommerce/src/Conditionals/Dependencies/WooCommerceDbVersionConditional.php b/packages/woocommerce/src/Conditionals/Dependencies/WooCommerceDbVersionConditional.php index 248feec..95f3b2e 100644 --- a/packages/woocommerce/src/Conditionals/Dependencies/WooCommerceDbVersionConditional.php +++ b/packages/woocommerce/src/Conditionals/Dependencies/WooCommerceDbVersionConditional.php @@ -11,11 +11,11 @@ * @since 2.0.0 * @version 2.0.0 */ -final class WooCommerceDbVersionConditional implements ConditionalInterface { +final readonly class WooCommerceDbVersionConditional implements ConditionalInterface { // region MAGIC METHODS /** - * Constructs the conditional with the minimum WooCommerce database version required. + * Constructor. * * @since 2.0.0 * @version 2.0.0 @@ -23,7 +23,7 @@ final class WooCommerceDbVersionConditional implements ConditionalInterface { * @param Version $minimum Minimum WooCommerce database version that satisfies the gate. */ public function __construct( - protected readonly Version $minimum, + protected Version $minimum, ) {} // endregion diff --git a/packages/woocommerce/src/Conditionals/Dependencies/WooCommerceVersionConditional.php b/packages/woocommerce/src/Conditionals/Dependencies/WooCommerceVersionConditional.php index 0ce3c90..cfbed2e 100644 --- a/packages/woocommerce/src/Conditionals/Dependencies/WooCommerceVersionConditional.php +++ b/packages/woocommerce/src/Conditionals/Dependencies/WooCommerceVersionConditional.php @@ -11,11 +11,11 @@ * @since 2.0.0 * @version 2.0.0 */ -final class WooCommerceVersionConditional implements ConditionalInterface { +final readonly class WooCommerceVersionConditional implements ConditionalInterface { // region MAGIC METHODS /** - * Constructs the conditional with the minimum WooCommerce version required. + * Constructor. * * @since 2.0.0 * @version 2.0.0 @@ -23,7 +23,7 @@ final class WooCommerceVersionConditional implements ConditionalInterface { * @param Version $minimum Minimum WooCommerce version that satisfies the gate. */ public function __construct( - protected readonly Version $minimum, + protected Version $minimum, ) {} // endregion diff --git a/packages/woocommerce/src/OrderData/OrderFieldSurface.php b/packages/woocommerce/src/OrderData/OrderFieldSurface.php index 6a17bd5..4be63a6 100644 --- a/packages/woocommerce/src/OrderData/OrderFieldSurface.php +++ b/packages/woocommerce/src/OrderData/OrderFieldSurface.php @@ -14,7 +14,7 @@ use DeepWebSolutions\Framework\Storage\ObjectMeta\ObjectMetaRepositoryInterface; use DeepWebSolutions\Framework\WooCommerce\OrderData\Exceptions\UnsupportedOrderScreenException; -use function DeepWebSolutions\Framework\Settings\Schema\field_label_html; +use function DeepWebSolutions\Framework\Settings\Schema\meta_box_row_html; /** * Surface that mounts a field group onto the WooCommerce order edit screen as a meta box and stores its fields as order meta. @@ -338,9 +338,7 @@ protected function add_box( FieldGroup $group, MetaBoxPlacement $placement, stri } /** - * The row closure wrapping each control in a meta-box row, its label bound to the control's DOM id. - * A div, not a paragraph: a radio fieldset or the description paragraph inside a p would be reparsed - * as invalid HTML. + * The row closure wrapping each control in a meta-box row — {@see meta_box_row_html()}. * * @since 2.0.0 * @version 2.0.0 @@ -348,8 +346,7 @@ protected function add_box( FieldGroup $group, MetaBoxPlacement $placement, stri * @return \Closure */ protected function box_row(): \Closure { - return static fn ( SettingsField $field, string $control, string $control_id ): string => - '
' . field_label_html( $field, $control_id ) . '
' . $control . '
'; + return static fn ( SettingsField $field, string $control, string $control_id ): string => meta_box_row_html( $field, $control, $control_id ); } /** diff --git a/packages/woocommerce/src/OrderData/OrderMetaRepository.php b/packages/woocommerce/src/OrderData/OrderMetaRepository.php index dfb8068..e0bf73b 100644 --- a/packages/woocommerce/src/OrderData/OrderMetaRepository.php +++ b/packages/woocommerce/src/OrderData/OrderMetaRepository.php @@ -2,20 +2,39 @@ namespace DeepWebSolutions\Framework\WooCommerce\OrderData; +use DeepWebSolutions\Framework\Storage\ObjectMeta\MetadataRepository; +use DeepWebSolutions\Framework\Storage\ObjectMeta\MetaType; use DeepWebSolutions\Framework\Storage\ObjectMeta\ObjectMetaRepositoryInterface; /** * Object-meta repository over WooCommerce orders. * * Reads and writes order meta through WC_Order, so a value follows the order whichever table backs it - * under HPOS, and falls back to post meta for an object id that is not an order. The batch apply() - * persists an order's queued writes and deletes in a single save() — and only when something changed, - * so a no-op submission writes nothing. CRUD keys off meta_exists() rather than value truthiness. + * under HPOS, and delegates an object id that is not an order to the composed fallback repository. The + * batch apply() persists an order's queued writes and deletes in a single save() — and only when + * something changed, so a no-op submission writes nothing. CRUD keys off meta_exists() rather than + * value truthiness. * * @since 2.0.0 * @version 2.0.0 */ final readonly class OrderMetaRepository implements ObjectMetaRepositoryInterface { + // region MAGIC METHODS + + /** + * Constructor. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param ObjectMetaRepositoryInterface $fallback Repository handling an object id that is not an order. + */ + public function __construct( + protected ObjectMetaRepositoryInterface $fallback = new MetadataRepository( MetaType::Post ), + ) {} + + // endregion + // region INHERITED METHODS /** @@ -31,7 +50,7 @@ public function get( int $object_id, string $meta_key, mixed $default_value = nu return $order->meta_exists( $meta_key ) ? $order->get_meta( $meta_key, true ) : $default_value; } - return \metadata_exists( 'post', $object_id, $meta_key ) ? \get_post_meta( $object_id, $meta_key, true ) : $default_value; + return $this->fallback->get( $object_id, $meta_key, $default_value ); } /** @@ -49,11 +68,7 @@ public function set( int $object_id, string $meta_key, mixed $value ): void { return; } - // update_post_meta() unslashes both the meta key and the value; slash the key, and any string or - // array value, first so backslashes survive the round-trip — get()/has() look the key up raw, so a - // raw write would store it under a different key. Other value shapes pass through unchanged. - $slashed = ( \is_string( $value ) || \is_array( $value ) ) ? \wp_slash( $value ) : $value; - \update_post_meta( $object_id, \wp_slash( $meta_key ), $slashed ); + $this->fallback->set( $object_id, $meta_key, $value ); } /** @@ -69,7 +84,7 @@ public function has( int $object_id, string $meta_key ): bool { return $order->meta_exists( $meta_key ); } - return \metadata_exists( 'post', $object_id, $meta_key ); + return $this->fallback->has( $object_id, $meta_key ); } /** @@ -90,8 +105,7 @@ public function delete( int $object_id, string $meta_key ): bool { return true; } - // delete_post_meta() unslashes the key as update_post_meta() does; slash it so a backslash key matches. - return \delete_post_meta( $object_id, \wp_slash( $meta_key ) ); + return $this->fallback->delete( $object_id, $meta_key ); } /** @@ -123,15 +137,7 @@ public function apply( int $object_id, array $sets, array $deletes ): void { return; } - foreach ( $sets as $meta_key => $value ) { - $this->set( $object_id, (string) $meta_key, $value ); - } - foreach ( $deletes as $meta_key ) { - // Mirror the order path: skip an absent key so the fallback runs no delete query for a never-set one. - if ( $this->has( $object_id, $meta_key ) ) { - $this->delete( $object_id, $meta_key ); - } - } + $this->fallback->apply( $object_id, $sets, $deletes ); } // endregion diff --git a/packages/woocommerce/src/ProductData/ProductDataFieldRenderer.php b/packages/woocommerce/src/ProductData/ProductDataFieldRenderer.php index 55714af..52d93e3 100644 --- a/packages/woocommerce/src/ProductData/ProductDataFieldRenderer.php +++ b/packages/woocommerce/src/ProductData/ProductDataFieldRenderer.php @@ -5,11 +5,13 @@ use DeepWebSolutions\Framework\Settings\Schema\Exceptions\UnknownFieldTypeException; use DeepWebSolutions\Framework\Settings\Schema\Field\FieldType; use DeepWebSolutions\Framework\Settings\Schema\Options\OptionsResolver; +use DeepWebSolutions\Framework\Settings\Schema\ValueObjects\CustomFieldType; use DeepWebSolutions\Framework\Settings\Schema\ValueObjects\SettingsField; use function DeepWebSolutions\Framework\Settings\Schema\filter_field_attributes; +use function DeepWebSolutions\Framework\Settings\Schema\normalize_checkbox_value; use function DeepWebSolutions\Framework\Settings\Schema\stringify_for_output; -use function DeepWebSolutions\Framework\WooCommerce\to_yes_no; +use function DeepWebSolutions\Framework\Settings\Schema\stringify_option_labels; /** * Renders a product-data field as a native WooCommerce control. @@ -17,7 +19,9 @@ * Maps a SettingsField to the argument array WooCommerce's woocommerce_wp_* control functions consume, * then dispatches to the function for the field's type, so a product-data tab renders with the markup the * product editor's panel expects. A checkbox value is normalized to WooCommerce's yes/no string, on which - * its control's checked state turns. WooCommerce escapes and emits the control; an unknown type throws. + * its control's checked state turns. WooCommerce escapes and emits the control. A type outside the + * taxonomy renders through the registered custom type's seam by echoing its returned markup; a type + * neither the taxonomy nor the registry covers throws. * * @since 2.0.0 * @version 2.0.0 @@ -31,10 +35,12 @@ * @since 2.0.0 * @version 2.0.0 * - * @param OptionsResolver $options_resolver Resolver for choice fields' option sets. + * @param OptionsResolver $options_resolver Resolver for choice fields' option sets. + * @param array $custom_types Registry of render seams for types outside the taxonomy, keyed by type token; a tab-level custom renderer for the same token takes precedence. */ public function __construct( protected OptionsResolver $options_resolver = new OptionsResolver(), + protected array $custom_types = array(), ) {} // endregion @@ -70,19 +76,19 @@ public function args( SettingsField $field, FieldType $type, mixed $value, strin switch ( $type ) { case FieldType::Checkbox: - $args['value'] = to_yes_no( $value ); + $args['value'] = normalize_checkbox_value( $value ); break; case FieldType::Multiselect: $args['name'] = $meta_key . '[]'; // WooCommerce marks options selected via in_array() over the values, so the keys are irrelevant. $args['value'] = \is_array( $value ) ? $value : array(); - $args['options'] = $this->stringify_labels( $this->options_resolver->resolve( $field->options ) ); + $args['options'] = stringify_option_labels( $this->options_resolver->resolve( $field->options ) ); $custom_attributes['multiple'] = 'multiple'; break; case FieldType::Select: case FieldType::Radio: $args['value'] = stringify_for_output( $value ); - $args['options'] = $this->stringify_labels( $this->options_resolver->resolve( $field->options ) ); + $args['options'] = stringify_option_labels( $this->options_resolver->resolve( $field->options ) ); break; case FieldType::Textarea: $args['value'] = stringify_for_output( $value ); @@ -101,7 +107,8 @@ public function args( SettingsField $field, FieldType $type, mixed $value, strin } /** - * Renders a field's control by dispatching to its WooCommerce control function. + * Renders a field's control: a taxonomy type dispatches to its WooCommerce control function; a type in + * the custom-type registry echoes the markup its render seam returns. * * @since 2.0.0 * @version 2.0.0 @@ -110,11 +117,16 @@ public function args( SettingsField $field, FieldType $type, mixed $value, strin * @param mixed $value Current value to bind into the control. * @param string $meta_key Product-meta key, used as the control id and name. * - * @throws UnknownFieldTypeException If the field declares a type outside the taxonomy. + * @throws UnknownFieldTypeException If the field declares a type outside both the taxonomy and the custom-type registry. */ public function render( SettingsField $field, mixed $value, string $meta_key ): void { $type = FieldType::tryFrom( $field->type ); if ( null === $type ) { + if ( isset( $this->custom_types[ $field->type ] ) ) { + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- the custom type's render seam returns escaped markup. + echo (string) ( $this->custom_types[ $field->type ]->render )( $field, $value, $meta_key ); + return; + } // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- framework-internal exception; never reaches an HTML output context unescaped. throw new UnknownFieldTypeException( "Unknown settings field type: '$field->type'" ); } @@ -141,28 +153,18 @@ public function render( SettingsField $field, mixed $value, string $meta_key ): } } - // endregion - - // region HELPERS - /** - * Stringifies a resolved options map's labels; a non-scalar label becomes an empty string, as WooCommerce - * passes each label through esc_html(). + * Whether a custom-type render seam is registered for a type token. * * @since 2.0.0 * @version 2.0.0 * - * @param array $options Resolved value-to-label map. + * @param string $type Field-type token to check. * - * @return array + * @return bool */ - protected function stringify_labels( array $options ): array { - $labels = array(); - foreach ( $options as $value => $label ) { - $labels[ $value ] = stringify_for_output( $label ); - } - - return $labels; + public function has_custom_type( string $type ): bool { + return isset( $this->custom_types[ $type ] ); } // endregion diff --git a/packages/woocommerce/src/ProductData/ProductDataFieldSurface.php b/packages/woocommerce/src/ProductData/ProductDataFieldSurface.php index 6feebe8..b73929b 100644 --- a/packages/woocommerce/src/ProductData/ProductDataFieldSurface.php +++ b/packages/woocommerce/src/ProductData/ProductDataFieldSurface.php @@ -13,11 +13,12 @@ use DeepWebSolutions\Framework\WooCommerce\ProductData\Exceptions\InvalidProductDataTabException; use function DeepWebSolutions\Framework\Settings\Schema\is_field_editable_by_current_user; +use function DeepWebSolutions\Framework\Settings\Schema\normalize_checkbox_value; use function DeepWebSolutions\Framework\Settings\Schema\wordpress_field_type_sanitizers; -use function DeepWebSolutions\Framework\WooCommerce\to_yes_no; /** - * Surface that mounts a WooCommerce product-data settings tab and persists its fields as product meta. + * Surface that mounts a WooCommerce product-data settings tab and persists its fields as product meta + * through WooCommerce's product CRUD. * * One surface drives one tab. register_tab() wires WooCommerce's three product hooks — add the tab, render * its panel, save it — plus the two default-metadata filters that make a product predating a field render @@ -123,20 +124,27 @@ public function register_tab( ProductDataTab $tab ): void { \add_filter( 'woocommerce_product_data_tabs', array( $this, 'register_tab_filter' ) ); \add_action( 'woocommerce_product_data_panels', array( $this, 'render_panel' ) ); \add_action( 'woocommerce_process_product_meta', array( $this, 'save' ) ); + + // Default injection is inherently post-meta-coupled: default_post_metadata and + // woocommerce_data_store_wp_post_read_meta are seams of WooCommerce's post-backed product datastore, + // and the pre-save strip discriminates by raw postmeta row. The CRUD and save paths read and write + // through WooCommerce's product CRUD, so a non-postmeta product datastore needs a new injection seam + // only. \add_filter( 'default_post_metadata', array( $this, 'inject_default' ), 99, 4 ); \add_filter( 'woocommerce_data_store_wp_post_read_meta', array( $this, 'inject_default_bulk' ), 99, 2 ); \add_action( 'woocommerce_before_product_object_save', array( $this, 'strip_injected_defaults' ) ); } /** - * Returns a field's effective value for a product: its stored value, or its descriptor default while none is - * stored on a supported product; the caller fallback for a non-product or an unsupported one. + * Returns a field's effective value for a product, read through WooCommerce's product CRUD: its stored + * value, or its descriptor default while none is stored on a supported product; the caller fallback for + * a non-product or an unsupported one. * * @since 2.0.0 * @version 2.0.0 * - * @param int $product_id Product to read. * @param string $section_id Section the field belongs to. + * @param int $product_id Product to read. * @param string $field_id Field to read. * @param mixed $default_value Value returned for a non-product or an unsupported product with nothing stored. * @@ -144,13 +152,13 @@ public function register_tab( ProductDataTab $tab ): void { * * @return mixed */ - public function get( int $product_id, string $section_id, string $field_id, mixed $default_value = null ): mixed { - $meta_key = $this->require_meta_key( $section_id, $field_id ); + public function get( string $section_id, int $product_id, string $field_id, mixed $default_value = null ): mixed { + $meta_key = $this->meta_key( $section_id, $field_id ); $product = \wc_get_product( $product_id ); if ( ! $product instanceof \WC_Product ) { return $default_value; } - if ( \metadata_exists( 'post', $product_id, $meta_key ) ) { + if ( $this->has_persisted_meta( $product, $meta_key ) ) { return $product->get_meta( $meta_key, true ); } @@ -160,20 +168,20 @@ public function get( int $product_id, string $section_id, string $field_id, mixe } /** - * Persists a field's value for a product. + * Persists a field's value for a product through WooCommerce's product CRUD. * * @since 2.0.0 * @version 2.0.0 * - * @param int $product_id Product to write. * @param string $section_id Section the field belongs to. + * @param int $product_id Product to write. * @param string $field_id Field to write. * @param mixed $value Value to persist. * * @throws InvalidSettingsFieldException If the field is not registered on this tab. */ - public function set( int $product_id, string $section_id, string $field_id, mixed $value ): void { - $meta_key = $this->require_meta_key( $section_id, $field_id ); + public function set( string $section_id, int $product_id, string $field_id, mixed $value ): void { + $meta_key = $this->meta_key( $section_id, $field_id ); $product = \wc_get_product( $product_id ); if ( ! $product instanceof \WC_Product ) { return; @@ -181,7 +189,7 @@ public function set( int $product_id, string $section_id, string $field_id, mixe // A checkbox persists as WooCommerce's yes/no string on every write path, so its render and reads agree. if ( FieldType::Checkbox === FieldType::tryFrom( $this->by_meta_key[ $meta_key ]->type ) ) { - $value = to_yes_no( $value ); + $value = normalize_checkbox_value( $value ); } $product->update_meta_data( $meta_key, $value ); @@ -197,45 +205,45 @@ public function set( int $product_id, string $section_id, string $field_id, mixe } /** - * Whether a real value is stored for a field on a product — the injected default does not count. + * Whether a real value is stored for a field on a product, read through WooCommerce's product CRUD — + * the injected default does not count. * * @since 2.0.0 * @version 2.0.0 * - * @param int $product_id Product to check. * @param string $section_id Section the field belongs to. + * @param int $product_id Product to check. * @param string $field_id Field to check. * * @throws InvalidSettingsFieldException If the field is not registered on this tab. * * @return bool */ - public function has( int $product_id, string $section_id, string $field_id ): bool { - return \metadata_exists( 'post', $product_id, $this->require_meta_key( $section_id, $field_id ) ); + public function has( string $section_id, int $product_id, string $field_id ): bool { + $meta_key = $this->meta_key( $section_id, $field_id ); + $product = \wc_get_product( $product_id ); + + return $product instanceof \WC_Product && $this->has_persisted_meta( $product, $meta_key ); } /** - * Deletes a field's stored value from a product. + * Deletes a field's stored value from a product through WooCommerce's product CRUD. * * @since 2.0.0 * @version 2.0.0 * - * @param int $product_id Product to clear. * @param string $section_id Section the field belongs to. + * @param int $product_id Product to clear. * @param string $field_id Field to clear. * * @throws InvalidSettingsFieldException If the field is not registered on this tab. * * @return bool True if a stored value was deleted, false if none existed. */ - public function delete( int $product_id, string $section_id, string $field_id ): bool { - $meta_key = $this->require_meta_key( $section_id, $field_id ); - if ( ! \metadata_exists( 'post', $product_id, $meta_key ) ) { - return false; - } - - $product = \wc_get_product( $product_id ); - if ( ! $product instanceof \WC_Product ) { + public function delete( string $section_id, int $product_id, string $field_id ): bool { + $meta_key = $this->meta_key( $section_id, $field_id ); + $product = \wc_get_product( $product_id ); + if ( ! $product instanceof \WC_Product || ! $this->has_persisted_meta( $product, $meta_key ) ) { return false; } @@ -245,23 +253,6 @@ public function delete( int $product_id, string $section_id, string $field_id ): return true; } - /** - * Resolves a field's product-meta key. - * - * @since 2.0.0 - * @version 2.0.0 - * - * @param string $section_id Section the field belongs to. - * @param string $field_id Field to resolve. - * - * @throws InvalidSettingsFieldException If the field is not registered on this tab. - * - * @return string - */ - public function meta_key( string $section_id, string $field_id ): string { - return $this->require_meta_key( $section_id, $field_id ); - } - /** * Returns every meta key the tab owns, for the consumer's uninstall cleanup. * @@ -318,6 +309,10 @@ public function render_panel(): void { if ( ! $this->is_supported( $product_id ) ) { return; } + $product = \wc_get_product( $product_id ); + if ( ! $product instanceof \WC_Product ) { + return; + } $tab = $this->tab(); echo '
'; @@ -331,12 +326,13 @@ public function render_panel(): void { echo '
'; foreach ( $fields as $field ) { $meta_key = $this->meta_key_for( $section->id, $field ); - $value = \get_post_meta( $product_id, $meta_key, true ); + $value = $product->get_meta( $meta_key, true ); - if ( null !== FieldType::tryFrom( $field->type ) ) { - $this->renderer->render( $field, $value, $meta_key ); - } elseif ( isset( $tab->custom_renderers[ $field->type ] ) ) { + // The tab's own renderer for a non-taxonomy type wins over a renderer-registered custom type. + if ( null === FieldType::tryFrom( $field->type ) && isset( $tab->custom_renderers[ $field->type ] ) ) { ( $tab->custom_renderers[ $field->type ] )( $field, $value, $meta_key ); + } else { + $this->renderer->render( $field, $value, $meta_key ); } } echo '
'; @@ -423,7 +419,7 @@ public function inject_default( mixed $value, int $object_id, string $meta_key, * @version 2.0.0 * * @param array $meta_data Raw meta rows WooCommerce read for the object. - * @param object $wc_object Object the meta was read for. + * @param object $wc_object Object the meta was read for. * * @return array */ @@ -519,7 +515,9 @@ protected function index_fields( ProductDataTab $tab ): void { /** * Rejects a custom field type the tab cannot handle: render and save both need a consumer seam, so a type - * outside the framework taxonomy must declare a renderer on the tab and a sanitize callback on the field. + * outside the framework taxonomy must declare a renderer — on the tab, or a CustomFieldType registered on + * the field renderer — and a sanitize callback on the field. The custom-type registry is render-only, so + * the sanitize requirement holds either way. * * @since 2.0.0 * @version 2.0.0 @@ -533,7 +531,7 @@ protected function assert_custom_field_complete( ProductDataTab $tab, SettingsFi if ( null !== FieldType::tryFrom( $field->type ) ) { return; } - if ( ! isset( $tab->custom_renderers[ $field->type ] ) ) { + if ( ! isset( $tab->custom_renderers[ $field->type ] ) && ! $this->renderer->has_custom_type( $field->type ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- framework-internal exception; never reaches an HTML output context unescaped. throw new InvalidProductDataTabException( "Custom field type '$field->type' on tab '$tab->slug' has no renderer." ); } @@ -571,7 +569,7 @@ protected function meta_key_for( string $section_id, SettingsField $field ): str * * @return string */ - protected function require_meta_key( string $section_id, string $field_id ): string { + protected function meta_key( string $section_id, string $field_id ): string { $address = $this->address( $section_id, $field_id ); if ( ! isset( $this->by_address[ $address ] ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- framework-internal exception; never reaches an HTML output context unescaped. @@ -581,6 +579,29 @@ protected function require_meta_key( string $section_id, string $field_id ): str return $this->by_address[ $address ]; } + /** + * Whether a product carries a persisted row for a meta key. A row hydrated from storage holds a + * positive meta id, while the bulk-read injection splices its synthetic default rows with meta id 0, + * so an injected default never counts as stored. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param \WC_Product $product Product whose meta to inspect. + * @param string $meta_key Meta key to look for. + * + * @return bool + */ + protected function has_persisted_meta( \WC_Product $product, string $meta_key ): bool { + foreach ( $product->get_meta_data() as $meta ) { + if ( $meta_key === $meta->key && (int) ( $meta->id ?? 0 ) > 0 ) { + return true; + } + } + + return false; + } + /** * The index key for a field address. * @@ -655,7 +676,7 @@ protected function submitted_value( SettingsField $field, string $meta_key ): Ab */ protected function default_value( SettingsField $field ): mixed { return FieldType::Checkbox === FieldType::tryFrom( $field->type ) - ? to_yes_no( $field->default_value ) + ? normalize_checkbox_value( $field->default_value ) : $field->default_value; } diff --git a/packages/woocommerce/src/ProductData/ProductDataTab.php b/packages/woocommerce/src/ProductData/ProductDataTab.php index b2c04a4..c19461d 100644 --- a/packages/woocommerce/src/ProductData/ProductDataTab.php +++ b/packages/woocommerce/src/ProductData/ProductDataTab.php @@ -9,7 +9,7 @@ use function DeepWebSolutions\Framework\Shared\Identifier\is_valid_identifier; /** - * Declarative description of a WooCommerce product-data settings tab — a custom panel in the product + * Descriptor for a WooCommerce product-data settings tab — a custom panel in the product * editor's Product data meta box. * * Its sections (reusing the settings SettingsSection) group SettingsField controls persisted as product @@ -35,7 +35,9 @@ /** * Renderers for field types outside the framework taxonomy, keyed by type token. Signature - * `(SettingsField $field, mixed $value, string $meta_key): void`. + * `(SettingsField $field, mixed $value, string $meta_key): void`; each echoes its own markup. A + * tab-level renderer takes precedence over a CustomFieldType the field renderer registers for the + * same type token. * * @since 2.0.0 * @version 2.0.0 diff --git a/packages/woocommerce/src/functions.php b/packages/woocommerce/src/functions.php deleted file mode 100644 index 23c4218..0000000 --- a/packages/woocommerce/src/functions.php +++ /dev/null @@ -1,21 +0,0 @@ - 'addon' ); \do_action( 'woocommerce_process_product_meta', $this->product_id ); - self::assertSame( 'addon', $store->get( $this->product_id, 'general', 'warranty-type' ) ); + self::assertSame( 'addon', $store->get( 'general', $this->product_id, 'warranty-type' ) ); } public function test_save_applies_the_field_sanitizer(): void { @@ -170,7 +172,7 @@ public function test_save_applies_the_field_sanitizer(): void { $_POST = array( '_dws-wrwc_general_code' => 'abc' ); \do_action( 'woocommerce_process_product_meta', $this->product_id ); - self::assertSame( 'ABC', $store->get( $this->product_id, 'general', 'code' ) ); + self::assertSame( 'ABC', $store->get( 'general', $this->product_id, 'code' ) ); } public function test_save_applies_the_builtin_default_sanitizer(): void { @@ -185,7 +187,7 @@ public function test_save_applies_the_builtin_default_sanitizer(): void { $_POST = array( '_dws-wrwc_general_code' => $raw ); \do_action( 'woocommerce_process_product_meta', $this->product_id ); - self::assertSame( \sanitize_text_field( $raw ), $store->get( $this->product_id, 'general', 'code' ) ); + self::assertSame( \sanitize_text_field( $raw ), $store->get( 'general', $this->product_id, 'code' ) ); } public function test_save_preserves_an_existing_value_when_a_present_submission_is_invalid(): void { @@ -203,12 +205,12 @@ public function test_save_preserves_an_existing_value_when_a_present_submission_ ), ), ); - $store->set( $this->product_id, 'general', 'warranty-type', 'global' ); + $store->set( 'general', $this->product_id, 'warranty-type', 'global' ); $_POST = array( '_dws-wrwc_general_warranty-type' => 'tampered' ); \do_action( 'woocommerce_process_product_meta', $this->product_id ); - self::assertSame( 'global', $store->get( $this->product_id, 'general', 'warranty-type' ) ); + self::assertSame( 'global', $store->get( 'general', $this->product_id, 'warranty-type' ) ); } public function test_save_is_skipped_for_an_unsupported_product(): void { @@ -241,7 +243,7 @@ public function test_save_persists_a_multiselect_selection(): void { $_POST = array( '_dws-wrwc_general_locations' => array( 'cart', 'email' ) ); \do_action( 'woocommerce_process_product_meta', $this->product_id ); - self::assertEqualsCanonicalizing( array( 'cart', 'email' ), $store->get( $this->product_id, 'general', 'locations' ) ); + self::assertEqualsCanonicalizing( array( 'cart', 'email' ), $store->get( 'general', $this->product_id, 'locations' ) ); } public function test_save_preserves_a_checkbox_when_validation_rejects_the_submission(): void { @@ -254,12 +256,12 @@ public function test_save_preserves_a_checkbox_when_validation_rejects_the_submi ); // Prior value differs from the rejected submission so accept-and-store would land 'yes', not the // preserved 'no' — the assertion fails unless the rejection-preserve branch actually fires. - $store->set( $this->product_id, 'general', 'flag', false ); + $store->set( 'general', $this->product_id, 'flag', false ); $_POST = array( '_dws-wrwc_general_flag' => 'yes' ); \do_action( 'woocommerce_process_product_meta', $this->product_id ); - self::assertSame( 'no', $store->get( $this->product_id, 'general', 'flag' ) ); + self::assertSame( 'no', $store->get( 'general', $this->product_id, 'flag' ) ); } public function test_save_runs_sanitize_and_validate_on_a_custom_field(): void { @@ -283,7 +285,7 @@ public function test_save_runs_sanitize_and_validate_on_a_custom_field(): void { // Sanitize trims to 'reject'; the validator rejects it, so the field clears to the sanitized empty // (sanitize of an absent submission) rather than the descriptor default. - self::assertSame( '', $store->get( $this->product_id, 'general', 'span' ) ); + self::assertSame( '', $store->get( 'general', $this->product_id, 'span' ) ); } public function test_a_custom_field_without_a_renderer_is_rejected_at_registration(): void { @@ -331,7 +333,7 @@ public function test_an_absent_custom_field_stores_the_sanitized_empty_not_a_nul $_POST = array(); \do_action( 'woocommerce_process_product_meta', $this->product_id ); - self::assertSame( 'sanitized:', $store->get( $this->product_id, 'general', 'span' ) ); + self::assertSame( 'sanitized:', $store->get( 'general', $this->product_id, 'span' ) ); } public function test_a_non_scalar_custom_field_submission_is_coerced_before_sanitize(): void { @@ -357,7 +359,7 @@ public function test_a_non_scalar_custom_field_submission_is_coerced_before_sani \do_action( 'woocommerce_process_product_meta', $this->product_id ); self::assertSame( '', $seen ); - self::assertSame( 'sanitized:', $store->get( $this->product_id, 'general', 'span' ) ); + self::assertSame( 'sanitized:', $store->get( 'general', $this->product_id, 'span' ) ); } public function test_the_before_save_hook_strips_an_injected_default(): void { @@ -607,7 +609,83 @@ public function test_a_custom_field_type_renders_via_its_renderer_and_saves_via_ $_POST = array( '_dws-wrwc_general_span' => '12' ); \do_action( 'woocommerce_process_product_meta', $this->product_id ); - self::assertSame( array( 'raw' => '12' ), $store->get( $this->product_id, 'general', 'span' ) ); + self::assertSame( array( 'raw' => '12' ), $store->get( 'general', $this->product_id, 'span' ) ); + } + + public function test_a_renderer_registered_custom_type_counts_as_wired_and_renders_through_the_bridge(): void { + $this->set_current_product( $this->product_id ); + $store = new ProductDataFieldSurface( + new ProductDataFieldRenderer( + custom_types: array( + 'dws_rds' => new CustomFieldType( + 'dws_rds', + static fn ( SettingsField $field, mixed $value, string $name ): string => '', + ), + ), + ), + ); + + // No tab-level renderer: the renderer-registered CustomFieldType satisfies the render requirement. + $store->register_tab( + $this->tab_with( + new SettingsField( id: 'span', type: 'dws_rds', label: 'Span', sanitize: static fn ( mixed $v ): string => (string) $v ), + ), + ); + + \ob_start(); + \do_action( 'woocommerce_product_data_panels' ); + $html = (string) \ob_get_clean(); + + self::assertStringContainsString( 'dws-rds-bridge', $html ); + self::assertStringContainsString( 'data-key="_dws-wrwc_general_span"', $html ); + } + + public function test_a_tab_level_custom_renderer_wins_over_a_renderer_registered_custom_type(): void { + $this->set_current_product( $this->product_id ); + $store = new ProductDataFieldSurface( + new ProductDataFieldRenderer( + custom_types: array( + 'dws_rds' => new CustomFieldType( 'dws_rds', static fn (): string => '' ), + ), + ), + ); + + $store->register_tab( + $this->tab_with( + new SettingsField( id: 'span', type: 'dws_rds', label: 'Span', sanitize: static fn ( mixed $v ): string => (string) $v ), + array( + 'dws_rds' => static function ( SettingsField $field, mixed $value, string $meta_key ): void { + echo ''; + }, + ), + ), + ); + + \ob_start(); + \do_action( 'woocommerce_product_data_panels' ); + $html = (string) \ob_get_clean(); + + self::assertStringContainsString( 'dws-rds-tab', $html ); + self::assertStringNotContainsString( 'dws-rds-bridge', $html ); + } + + public function test_a_renderer_registered_custom_type_still_requires_a_sanitize_callback(): void { + $store = new ProductDataFieldSurface( + new ProductDataFieldRenderer( + custom_types: array( + 'dws_rds' => new CustomFieldType( 'dws_rds', static fn (): string => '' ), + ), + ), + ); + + $this->expectException( InvalidProductDataTabException::class ); + + // The custom-type registry is render-only, so a covered type without a field sanitize still fails. + $store->register_tab( + $this->tab_with( + new SettingsField( id: 'span', type: 'dws_rds', label: 'Span' ), + ), + ); } // endregion @@ -618,15 +696,15 @@ public function test_crud_round_trips_by_section_and_field(): void { $store = new ProductDataFieldSurface(); $store->register_tab( $this->tab() ); - self::assertFalse( $store->has( $this->product_id, 'general', 'code' ) ); - self::assertFalse( $store->delete( $this->product_id, 'general', 'code' ) ); + self::assertFalse( $store->has( 'general', $this->product_id, 'code' ) ); + self::assertFalse( $store->delete( 'general', $this->product_id, 'code' ) ); - $store->set( $this->product_id, 'general', 'code', 'X1' ); - self::assertTrue( $store->has( $this->product_id, 'general', 'code' ) ); - self::assertSame( 'X1', $store->get( $this->product_id, 'general', 'code' ) ); + $store->set( 'general', $this->product_id, 'code', 'X1' ); + self::assertTrue( $store->has( 'general', $this->product_id, 'code' ) ); + self::assertSame( 'X1', $store->get( 'general', $this->product_id, 'code' ) ); - self::assertTrue( $store->delete( $this->product_id, 'general', 'code' ) ); - self::assertFalse( $store->has( $this->product_id, 'general', 'code' ) ); + self::assertTrue( $store->delete( 'general', $this->product_id, 'code' ) ); + self::assertFalse( $store->has( 'general', $this->product_id, 'code' ) ); } public function test_set_normalizes_a_checkbox_value_to_yes_no(): void { @@ -634,9 +712,9 @@ public function test_set_normalizes_a_checkbox_value_to_yes_no(): void { $store->register_tab( $this->tab_with( new SettingsField( id: 'flag', type: 'checkbox', label: 'Flag' ) ) ); // A boolean written through CRUD must persist as WooCommerce's 'yes', matching the form-save path. - $store->set( $this->product_id, 'general', 'flag', true ); + $store->set( 'general', $this->product_id, 'flag', true ); - self::assertSame( 'yes', $store->get( $this->product_id, 'general', 'flag' ) ); + self::assertSame( 'yes', $store->get( 'general', $this->product_id, 'flag' ) ); } public function test_set_persists_a_value_equal_to_the_default(): void { @@ -645,10 +723,10 @@ public function test_set_persists_a_value_equal_to_the_default(): void { // Setting a field to a value that equals its default must persist a real row — matching the form save's // store-all — rather than be mistaken for an injected default and stripped by the pre-save hook. - $store->set( $this->product_id, 'general', 'warranty-type', 'global' ); + $store->set( 'general', $this->product_id, 'warranty-type', 'global' ); - self::assertTrue( $store->has( $this->product_id, 'general', 'warranty-type' ) ); - self::assertSame( 'global', $store->get( $this->product_id, 'general', 'warranty-type' ) ); + self::assertTrue( $store->has( 'general', $this->product_id, 'warranty-type' ) ); + self::assertSame( 'global', $store->get( 'general', $this->product_id, 'warranty-type' ) ); } public function test_get_returns_the_descriptor_default_for_an_unstored_supported_field(): void { @@ -657,15 +735,15 @@ public function test_get_returns_the_descriptor_default_for_an_unstored_supporte // get() reads the descriptor default while nothing is stored, agreeing with the injected read paths and // with has() reporting no real value yet. - self::assertFalse( $store->has( $this->product_id, 'general', 'warranty-type' ) ); - self::assertSame( 'global', $store->get( $this->product_id, 'general', 'warranty-type' ) ); + self::assertFalse( $store->has( 'general', $this->product_id, 'warranty-type' ) ); + self::assertSame( 'global', $store->get( 'general', $this->product_id, 'warranty-type' ) ); } public function test_get_returns_the_caller_fallback_for_an_unsupported_product(): void { $store = new ProductDataFieldSurface(); $store->register_tab( $this->tab( supports: static fn ( int $product_id ): bool => false ) ); - self::assertSame( 'na', $store->get( $this->product_id, 'general', 'warranty-type', 'na' ) ); + self::assertSame( 'na', $store->get( 'general', $this->product_id, 'warranty-type', 'na' ) ); } public function test_meta_key_derivation_and_override(): void { @@ -688,12 +766,18 @@ public function test_meta_key_derivation_and_override(): void { ), ); - self::assertSame( '_dws-wrwc_general_derived', $store->meta_key( 'general', 'derived' ) ); - self::assertSame( '_legacy_v1_key', $store->meta_key( 'general', 'explicit' ) ); self::assertEqualsCanonicalizing( array( '_dws-wrwc_general_derived', '_legacy_v1_key' ), $store->meta_keys(), ); + + // The CRUD addressing resolves to those exact keys: a write by section/field id lands on the derived + // key for a plain field and on the byte-exact override for a legacy one. + $store->set( 'general', $this->product_id, 'derived', 'd-value' ); + $store->set( 'general', $this->product_id, 'explicit', 'e-value' ); + + self::assertSame( 'd-value', \get_post_meta( $this->product_id, '_dws-wrwc_general_derived', true ) ); + self::assertSame( 'e-value', \get_post_meta( $this->product_id, '_legacy_v1_key', true ) ); } public function test_a_duplicate_meta_key_is_rejected(): void { @@ -720,7 +804,7 @@ public function test_crud_on_an_unregistered_field_throws(): void { $this->expectException( InvalidSettingsFieldException::class ); - $store->get( $this->product_id, 'general', 'nope' ); + $store->get( 'general', $this->product_id, 'nope' ); } // endregion diff --git a/packages/woocommerce/tests/Unit/FunctionsTest.php b/packages/woocommerce/tests/Unit/FunctionsTest.php deleted file mode 100644 index bcf68ac..0000000 --- a/packages/woocommerce/tests/Unit/FunctionsTest.php +++ /dev/null @@ -1,46 +0,0 @@ - - */ - public static function yes_no_matrix(): array { - return array( - 'bool true' => array( true, 'yes' ), - 'int 1' => array( 1, 'yes' ), - 'string 1' => array( '1', 'yes' ), - 'string yes' => array( 'yes', 'yes' ), - 'bool false' => array( false, 'no' ), - 'int 0' => array( 0, 'no' ), - 'string 0' => array( '0', 'no' ), - 'string no' => array( 'no', 'no' ), - 'string off' => array( 'off', 'no' ), - 'string false' => array( 'false', 'no' ), - 'string on' => array( 'on', 'no' ), - 'arbitrary string' => array( 'anything', 'no' ), - 'empty string' => array( '', 'no' ), - 'null' => array( null, 'no' ), - 'array' => array( array( 'yes' ), 'no' ), - 'int 2' => array( 2, 'no' ), - ); - } -} diff --git a/packages/woocommerce/tests/Unit/ProductDataFieldRendererTest.php b/packages/woocommerce/tests/Unit/ProductDataFieldRendererTest.php index 16a3884..5fab835 100644 --- a/packages/woocommerce/tests/Unit/ProductDataFieldRendererTest.php +++ b/packages/woocommerce/tests/Unit/ProductDataFieldRendererTest.php @@ -2,8 +2,10 @@ namespace DeepWebSolutions\Framework\WooCommerce\Tests\Unit; +use DeepWebSolutions\Framework\Settings\Schema\Exceptions\UnknownFieldTypeException; use DeepWebSolutions\Framework\Settings\Schema\Field\FieldType; use DeepWebSolutions\Framework\Settings\Schema\Options\OptionsResolver; +use DeepWebSolutions\Framework\Settings\Schema\ValueObjects\CustomFieldType; use DeepWebSolutions\Framework\Settings\Schema\ValueObjects\SettingsField; use DeepWebSolutions\Framework\WooCommerce\ProductData\ProductDataFieldRenderer; use PHPUnit\Framework\Attributes\CoversClass; @@ -15,11 +17,13 @@ #[UsesClass( SettingsField::class )] #[UsesClass( OptionsResolver::class )] #[UsesClass( FieldType::class )] +#[UsesClass( CustomFieldType::class )] #[UsesFunction( 'DeepWebSolutions\Framework\Settings\Schema\filter_field_attributes' )] #[UsesFunction( 'DeepWebSolutions\Framework\Settings\Schema\is_checkbox_checked' )] #[UsesFunction( 'DeepWebSolutions\Framework\Shared\Identifier\is_valid_identifier' )] +#[UsesFunction( 'DeepWebSolutions\Framework\Settings\Schema\normalize_checkbox_value' )] #[UsesFunction( 'DeepWebSolutions\Framework\Settings\Schema\stringify_for_output' )] -#[UsesFunction( 'DeepWebSolutions\Framework\WooCommerce\to_yes_no' )] +#[UsesFunction( 'DeepWebSolutions\Framework\Settings\Schema\stringify_option_labels' )] final class ProductDataFieldRendererTest extends TestCase { public function test_text_args_carry_id_name_label_value_and_type(): void { $field = new SettingsField( id: 'store', type: 'text', label: 'Store' ); @@ -215,4 +219,39 @@ public function test_custom_attributes_are_omitted_when_empty(): void { self::assertArrayNotHasKey( 'custom_attributes', $args ); } + + public function test_a_registered_custom_type_renders_by_echoing_its_returned_markup(): void { + $renderer = new ProductDataFieldRenderer( + custom_types: array( + 'dws_rds' => new CustomFieldType( + 'dws_rds', + static fn ( SettingsField $field, mixed $value, string $name ): string => '', + ), + ), + ); + + \ob_start(); + $renderer->render( new SettingsField( id: 'span', type: 'dws_rds', label: 'Span' ), '12', '_p_general_span' ); + $html = (string) \ob_get_clean(); + + self::assertSame( '', $html ); + } + + public function test_a_type_outside_the_taxonomy_and_the_registry_throws(): void { + $this->expectException( UnknownFieldTypeException::class ); + + ( new ProductDataFieldRenderer() )->render( new SettingsField( id: 'x', type: 'dws_unwired', label: 'X' ), '', '_p_x' ); + } + + public function test_has_custom_type_reports_registry_membership(): void { + $renderer = new ProductDataFieldRenderer( + custom_types: array( + 'dws_rds' => new CustomFieldType( 'dws_rds', static fn (): string => '' ), + ), + ); + + self::assertTrue( $renderer->has_custom_type( 'dws_rds' ) ); + self::assertFalse( $renderer->has_custom_type( 'dws_other' ) ); + self::assertFalse( ( new ProductDataFieldRenderer() )->has_custom_type( 'dws_rds' ) ); + } } diff --git a/packages/woocommerce/tests/Unit/WooCommerceSettingsBuilderTest.php b/packages/woocommerce/tests/Unit/WooCommerceSettingsBuilderTest.php index 339d5e5..280f996 100644 --- a/packages/woocommerce/tests/Unit/WooCommerceSettingsBuilderTest.php +++ b/packages/woocommerce/tests/Unit/WooCommerceSettingsBuilderTest.php @@ -20,8 +20,9 @@ #[UsesFunction( 'DeepWebSolutions\Framework\Settings\Schema\filter_field_attributes' )] #[UsesFunction( 'DeepWebSolutions\Framework\Settings\Schema\is_checkbox_checked' )] #[UsesFunction( 'DeepWebSolutions\Framework\Shared\Identifier\is_valid_identifier' )] +#[UsesFunction( 'DeepWebSolutions\Framework\Settings\Schema\normalize_checkbox_value' )] #[UsesFunction( 'DeepWebSolutions\Framework\Settings\Schema\stringify_for_output' )] -#[UsesFunction( 'DeepWebSolutions\Framework\WooCommerce\to_yes_no' )] +#[UsesFunction( 'DeepWebSolutions\Framework\Settings\Schema\stringify_option_labels' )] final class WooCommerceSettingsBuilderTest extends TestCase { public function test_emits_a_title_fields_sectionend_sequence_per_section(): void { $built = ( new WooCommerceSettingsBuilder() )->build( $this->page() ); From 2f0ebfc5d01d5be3c351e6b88cf8e76f0d4db0b5 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Wed, 8 Jul 2026 01:36:35 +0200 Subject: [PATCH 06/10] refactor(tests): give shared machinery a home and mirror src topology MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Support traits replace the copy-paste families: CreatesUsers (six user factories), IsolatesHooks (six wp_filter snapshot/restore blocks — woocommerce imports the infrastructure trait; cross-package test imports resolve through the root autoload-dev), RequiresWooCommerce (four skip guards), and core's NormalizesHookTables (two copies). The three meta-field surface suites inherit eight shared behaviors from ObjectFieldSurfaceContractTestCase (not glob-collected; the order surface stays standalone — extending it would invert package direction in the split mirrors). 26 test files move to mirror src/ within each suite root (Schema concept folders, Backend/, ProductData/, ValueObjects/); Integration tests carry exhaustive Uses* attributes in every package; the 41 silence-golden index.php files leave tests (they never deploy); woocommerce's byte-copy InMemoryObjectMetaRepository imports infrastructure's. Style: the lone createMock becomes a recording logger, falsy-provider twins align, test regions drop, VersionTest's try/catch loops become data providers (+6 cases), OptionsStore combined tests split, surface test locals rename $store -> $surface. Unit suite: 727 tests green (x2 runs, order-independent). Integration in wp-env: 560 tests, two failures pre-existing on the branch — fixed in the next commit. Assisted-by: Claude Code:claude-fable-5 --- .../Integration/CheckRequirementsTest.php | 4 + .../OutputRequirementsErrorTest.php | 2 + .../tests/Integration/PluginKernelTest.php | 27 +- .../tests/Support/NormalizesHookTables.php | 21 ++ packages/core/tests/Unit/PluginKernelTest.php | 19 +- .../WordPressSettingsBackendTest.php | 2 +- .../Surfaces/PostMetaFieldSurfaceTest.php | 67 ++-- .../Surfaces/TermFieldSurfaceTest.php | 45 +-- .../Surfaces/UserProfileFieldSurfaceTest.php | 45 +-- .../Integration/MetaField/Surfaces/index.php | 1 - .../Settings/Integration/MetaField/index.php | 1 - .../CrossComponentSettingsTest.php | 2 +- .../{ => Schema/Field}/FieldRendererTest.php | 2 +- .../{ => Schema}/SchemaFunctionsTest.php | 23 +- .../tests/Settings/Integration/index.php | 1 - .../tests/Settings/Support/CreatesUsers.php | 27 ++ .../tests/Settings/Support/IsolatesHooks.php | 41 +++ .../WordPressSettingsBackendTest.php | 2 +- .../ObjectFieldSurfaceContractTestCase.php | 115 +++++++ .../Surfaces/PostMetaFieldSurfaceTest.php | 101 +----- .../Surfaces/TermFieldSurfaceTest.php | 95 +----- .../Surfaces/UserProfileFieldSurfaceTest.php | 95 +----- .../Unit/MetaField/Surfaces/index.php | 1 - .../{ => ValueObjects}/FieldGroupTest.php | 2 +- .../MetaBoxPlacementTest.php | 2 +- .../{ => ValueObjects}/TermFieldGroupTest.php | 2 +- .../UserProfileFieldGroupTest.php | 2 +- .../tests/Settings/Unit/MetaField/index.php | 1 - .../SettingsFieldAggregatorTest.php | 2 +- .../Errors}/FieldProcessingErrorTest.php | 2 +- .../{ => Schema/Field}/FieldProcessorTest.php | 2 +- .../Options}/OptionsResolverTest.php | 2 +- .../Unit/{ => Schema}/SchemaFunctionsTest.php | 2 +- .../ValueObjects}/CustomFieldTypeTest.php | 2 +- .../ValueObjects}/SettingsFieldTest.php | 2 +- .../ValueObjects}/SettingsPageTest.php | 2 +- .../ValueObjects}/SettingsSectionTest.php | 2 +- .../tests/Settings/Unit/index.php | 1 - .../Storage/Integration/ObjectMeta/index.php | 1 - .../Storage/Integration/OptionsStoreTest.php | 18 +- .../Storage/Integration/UserMetaStoreTest.php | 20 +- .../tests/Storage/Integration/index.php | 1 - .../tests/Storage/Unit/index.php | 1 - .../AdminNotices/AdminNoticeLoggerTest.php | 22 +- .../AdminNotices/AdminNoticesServiceTest.php | 24 +- .../DependencyAdminNoticeRendererTest.php | 20 +- .../DismissedNoticesTrackerTest.php | 20 +- .../Integration/AdminNotices/index.php | 1 - .../Integration/Caching/ObjectCacheTest.php | 28 +- .../Utilities/Integration/Caching/index.php | 1 - .../Conditionals/Context/index.php | 1 - .../Conditionals/Dependencies/index.php | 1 - .../Integration/Conditionals/index.php | 1 - .../Integration/Hooks/Handlers/index.php | 1 - .../Utilities/Integration/Hooks/index.php | 1 - .../Integration/Permissions/index.php | 1 - .../Integration/Scheduling/Backends/index.php | 1 - .../Integration/Scheduling/index.php | 1 - .../Unit/AdminNotices/ValueObjects/index.php | 1 - .../Utilities/Unit/AdminNotices/index.php | 1 - .../tests/Utilities/Unit/Caching/index.php | 1 - .../Unit/Conditionals/Context/index.php | 1 - .../Unit/Conditionals/Dependencies/index.php | 1 - .../Utilities/Unit/Conditionals/index.php | 1 - .../tests/Utilities/Unit/Helpers/index.php | 1 - .../Utilities/Unit/Hooks/Handlers/index.php | 1 - .../tests/Utilities/Unit/Hooks/index.php | 1 - .../tests/Utilities/Unit/Logging/index.php | 1 - .../Backends/ActionSchedulerBackendTest.php | 34 +- .../Unit/Scheduling/Backends/index.php | 1 - .../Unit/Scheduling/Errors/index.php | 1 - .../tests/Utilities/Unit/Scheduling/index.php | 1 - .../ValueObject/AbstractValueObjectTest.php | 3 + .../shared/tests/Unit/Version/VersionTest.php | 54 ++-- .../Fixtures/InMemoryObjectMetaRepository.php | 48 --- ...iptorBackedWooCommerceSettingsPageTest.php | 2 +- .../WooCommerceSettingsBackendTest.php | 2 +- .../Conditionals/Dependencies/index.php | 1 - .../tests/Integration/Conditionals/index.php | 1 - .../tests/Integration/Fixtures/index.php | 1 - .../tests/Integration/Logging/index.php | 1 - .../OrderData/OrderFieldSurfaceTest.php | 123 +++---- .../OrderData/OrderMetaRepositoryTest.php | 31 +- .../tests/Integration/OrderData/index.php | 1 - .../ProductDataFieldRendererTest.php | 12 +- .../ProductDataFieldSurfaceTest.php | 306 ++++++++---------- .../woocommerce/tests/Integration/index.php | 1 - .../tests/Support/RequiresWooCommerce.php | 17 + .../WooCommerceSettingsBackendTest.php | 2 +- .../WooCommerceSettingsBuilderTest.php | 2 +- .../Unit/Conditionals/Dependencies/index.php | 1 - .../tests/Unit/Conditionals/index.php | 1 - .../Unit/OrderData/OrderFieldSurfaceTest.php | 40 +-- .../ProductDataFieldRendererTest.php | 2 +- .../{ => ProductData}/ProductDataTabTest.php | 2 +- packages/woocommerce/tests/Unit/index.php | 1 - 96 files changed, 658 insertions(+), 976 deletions(-) create mode 100644 packages/core/tests/Support/NormalizesHookTables.php rename packages/infrastructure/tests/Settings/Integration/{ => Backend}/WordPressSettingsBackendTest.php (99%) delete mode 100644 packages/infrastructure/tests/Settings/Integration/MetaField/Surfaces/index.php delete mode 100644 packages/infrastructure/tests/Settings/Integration/MetaField/index.php rename packages/infrastructure/tests/Settings/Integration/{ => Schema/Aggregation}/CrossComponentSettingsTest.php (98%) rename packages/infrastructure/tests/Settings/Integration/{ => Schema/Field}/FieldRendererTest.php (99%) rename packages/infrastructure/tests/Settings/Integration/{ => Schema}/SchemaFunctionsTest.php (81%) delete mode 100644 packages/infrastructure/tests/Settings/Integration/index.php create mode 100644 packages/infrastructure/tests/Settings/Support/CreatesUsers.php create mode 100644 packages/infrastructure/tests/Settings/Support/IsolatesHooks.php rename packages/infrastructure/tests/Settings/Unit/{ => Backend}/WordPressSettingsBackendTest.php (97%) create mode 100644 packages/infrastructure/tests/Settings/Unit/MetaField/Surfaces/ObjectFieldSurfaceContractTestCase.php delete mode 100644 packages/infrastructure/tests/Settings/Unit/MetaField/Surfaces/index.php rename packages/infrastructure/tests/Settings/Unit/MetaField/{ => ValueObjects}/FieldGroupTest.php (99%) rename packages/infrastructure/tests/Settings/Unit/MetaField/{ => ValueObjects}/MetaBoxPlacementTest.php (99%) rename packages/infrastructure/tests/Settings/Unit/MetaField/{ => ValueObjects}/TermFieldGroupTest.php (99%) rename packages/infrastructure/tests/Settings/Unit/MetaField/{ => ValueObjects}/UserProfileFieldGroupTest.php (98%) delete mode 100644 packages/infrastructure/tests/Settings/Unit/MetaField/index.php rename packages/infrastructure/tests/Settings/Unit/{ => Schema/Aggregation}/SettingsFieldAggregatorTest.php (98%) rename packages/infrastructure/tests/Settings/Unit/{ => Schema/Errors}/FieldProcessingErrorTest.php (96%) rename packages/infrastructure/tests/Settings/Unit/{ => Schema/Field}/FieldProcessorTest.php (99%) rename packages/infrastructure/tests/Settings/Unit/{ => Schema/Options}/OptionsResolverTest.php (96%) rename packages/infrastructure/tests/Settings/Unit/{ => Schema}/SchemaFunctionsTest.php (99%) rename packages/infrastructure/tests/Settings/Unit/{ => Schema/ValueObjects}/CustomFieldTypeTest.php (98%) rename packages/infrastructure/tests/Settings/Unit/{ => Schema/ValueObjects}/SettingsFieldTest.php (98%) rename packages/infrastructure/tests/Settings/Unit/{ => Schema/ValueObjects}/SettingsPageTest.php (97%) rename packages/infrastructure/tests/Settings/Unit/{ => Schema/ValueObjects}/SettingsSectionTest.php (97%) delete mode 100644 packages/infrastructure/tests/Settings/Unit/index.php delete mode 100644 packages/infrastructure/tests/Storage/Integration/ObjectMeta/index.php delete mode 100644 packages/infrastructure/tests/Storage/Integration/index.php delete mode 100644 packages/infrastructure/tests/Storage/Unit/index.php delete mode 100644 packages/infrastructure/tests/Utilities/Integration/AdminNotices/index.php delete mode 100644 packages/infrastructure/tests/Utilities/Integration/Caching/index.php delete mode 100644 packages/infrastructure/tests/Utilities/Integration/Conditionals/Context/index.php delete mode 100644 packages/infrastructure/tests/Utilities/Integration/Conditionals/Dependencies/index.php delete mode 100644 packages/infrastructure/tests/Utilities/Integration/Conditionals/index.php delete mode 100644 packages/infrastructure/tests/Utilities/Integration/Hooks/Handlers/index.php delete mode 100644 packages/infrastructure/tests/Utilities/Integration/Hooks/index.php delete mode 100644 packages/infrastructure/tests/Utilities/Integration/Permissions/index.php delete mode 100644 packages/infrastructure/tests/Utilities/Integration/Scheduling/Backends/index.php delete mode 100644 packages/infrastructure/tests/Utilities/Integration/Scheduling/index.php delete mode 100644 packages/infrastructure/tests/Utilities/Unit/AdminNotices/ValueObjects/index.php delete mode 100644 packages/infrastructure/tests/Utilities/Unit/AdminNotices/index.php delete mode 100644 packages/infrastructure/tests/Utilities/Unit/Caching/index.php delete mode 100644 packages/infrastructure/tests/Utilities/Unit/Conditionals/Context/index.php delete mode 100644 packages/infrastructure/tests/Utilities/Unit/Conditionals/Dependencies/index.php delete mode 100644 packages/infrastructure/tests/Utilities/Unit/Conditionals/index.php delete mode 100644 packages/infrastructure/tests/Utilities/Unit/Helpers/index.php delete mode 100644 packages/infrastructure/tests/Utilities/Unit/Hooks/Handlers/index.php delete mode 100644 packages/infrastructure/tests/Utilities/Unit/Hooks/index.php delete mode 100644 packages/infrastructure/tests/Utilities/Unit/Logging/index.php delete mode 100644 packages/infrastructure/tests/Utilities/Unit/Scheduling/Backends/index.php delete mode 100644 packages/infrastructure/tests/Utilities/Unit/Scheduling/Errors/index.php delete mode 100644 packages/infrastructure/tests/Utilities/Unit/Scheduling/index.php delete mode 100644 packages/woocommerce/tests/Fixtures/InMemoryObjectMetaRepository.php rename packages/woocommerce/tests/Integration/{ => Backend}/DescriptorBackedWooCommerceSettingsPageTest.php (99%) rename packages/woocommerce/tests/Integration/{ => Backend}/WooCommerceSettingsBackendTest.php (99%) delete mode 100644 packages/woocommerce/tests/Integration/Conditionals/Dependencies/index.php delete mode 100644 packages/woocommerce/tests/Integration/Conditionals/index.php delete mode 100644 packages/woocommerce/tests/Integration/Fixtures/index.php delete mode 100644 packages/woocommerce/tests/Integration/Logging/index.php delete mode 100644 packages/woocommerce/tests/Integration/OrderData/index.php rename packages/woocommerce/tests/Integration/{ => ProductData}/ProductDataFieldRendererTest.php (95%) rename packages/woocommerce/tests/Integration/{ => ProductData}/ProductDataFieldSurfaceTest.php (76%) delete mode 100644 packages/woocommerce/tests/Integration/index.php create mode 100644 packages/woocommerce/tests/Support/RequiresWooCommerce.php rename packages/woocommerce/tests/Unit/{ => Backend}/WooCommerceSettingsBackendTest.php (97%) rename packages/woocommerce/tests/Unit/{ => Backend}/WooCommerceSettingsBuilderTest.php (99%) delete mode 100644 packages/woocommerce/tests/Unit/Conditionals/Dependencies/index.php delete mode 100644 packages/woocommerce/tests/Unit/Conditionals/index.php rename packages/woocommerce/tests/Unit/{ => ProductData}/ProductDataFieldRendererTest.php (99%) rename packages/woocommerce/tests/Unit/{ => ProductData}/ProductDataTabTest.php (98%) delete mode 100644 packages/woocommerce/tests/Unit/index.php diff --git a/packages/bootstrap/tests/Integration/CheckRequirementsTest.php b/packages/bootstrap/tests/Integration/CheckRequirementsTest.php index a6ede4d..7e1b0e4 100644 --- a/packages/bootstrap/tests/Integration/CheckRequirementsTest.php +++ b/packages/bootstrap/tests/Integration/CheckRequirementsTest.php @@ -4,6 +4,7 @@ use DeepWebSolutions\Framework\Bootstrap\Tests\Support\WritesPluginFixtures; use PHPUnit\Framework\Attributes\CoversFunction; +use PHPUnit\Framework\Attributes\UsesFunction; use PHPUnit\Framework\TestCase; use function DeepWebSolutions\Framework\Bootstrap\Environment\is_php_compatible; @@ -14,6 +15,9 @@ use const DeepWebSolutions\Framework\Bootstrap\Requirements\FRAMEWORK_MIN_WP; #[CoversFunction( 'DeepWebSolutions\Framework\Bootstrap\Requirements\check_requirements' )] +#[UsesFunction( 'DeepWebSolutions\Framework\Bootstrap\Environment\is_php_compatible' )] +#[UsesFunction( 'DeepWebSolutions\Framework\Bootstrap\Environment\is_wp_compatible' )] +#[UsesFunction( 'DeepWebSolutions\Framework\Bootstrap\Plugin\get_plugin_metadata' )] final class CheckRequirementsTest extends TestCase { use WritesPluginFixtures; diff --git a/packages/bootstrap/tests/Integration/OutputRequirementsErrorTest.php b/packages/bootstrap/tests/Integration/OutputRequirementsErrorTest.php index 236b062..7db8427 100644 --- a/packages/bootstrap/tests/Integration/OutputRequirementsErrorTest.php +++ b/packages/bootstrap/tests/Integration/OutputRequirementsErrorTest.php @@ -5,11 +5,13 @@ use DeepWebSolutions\Framework\Bootstrap\Tests\Support\RendersAdminNotices; use DeepWebSolutions\Framework\Bootstrap\Tests\Support\WritesPluginFixtures; use PHPUnit\Framework\Attributes\CoversFunction; +use PHPUnit\Framework\Attributes\UsesFunction; use PHPUnit\Framework\TestCase; use function DeepWebSolutions\Framework\Bootstrap\Notice\output_requirements_error; #[CoversFunction( 'DeepWebSolutions\Framework\Bootstrap\Notice\output_requirements_error' )] +#[UsesFunction( 'DeepWebSolutions\Framework\Bootstrap\Plugin\get_plugin_metadata' )] final class OutputRequirementsErrorTest extends TestCase { use RendersAdminNotices; use WritesPluginFixtures; diff --git a/packages/core/tests/Integration/PluginKernelTest.php b/packages/core/tests/Integration/PluginKernelTest.php index 59dbbab..0a0489b 100644 --- a/packages/core/tests/Integration/PluginKernelTest.php +++ b/packages/core/tests/Integration/PluginKernelTest.php @@ -9,15 +9,24 @@ use DeepWebSolutions\Framework\Core\Lifecycle\Initializable\InitializableInterface; use DeepWebSolutions\Framework\Core\PluginInterface; use DeepWebSolutions\Framework\Core\PluginKernel; +use DeepWebSolutions\Framework\Core\Tests\Support\NormalizesHookTables; use DeepWebSolutions\Framework\Core\ValueObjects\BootStatus; +use DeepWebSolutions\Framework\Core\ValueObjects\PluginBootReport; use DeepWebSolutions\Framework\Core\ValueObjects\PluginHeader; use DeepWebSolutions\Framework\Shared\Version\Version; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; #[CoversClass( PluginKernel::class )] +#[UsesClass( BootStatus::class )] +#[UsesClass( PluginBootReport::class )] +#[UsesClass( PluginHeader::class )] +#[UsesClass( Version::class )] final class PluginKernelTest extends TestCase { + use NormalizesHookTables; + private const VERSION_OPTION = 'dws_test_kernel_version'; protected function tear_down_option(): void { @@ -25,11 +34,13 @@ protected function tear_down_option(): void { } protected function setUp(): void { + parent::setUp(); $this->tear_down_option(); } protected function tearDown(): void { $this->tear_down_option(); + parent::tearDown(); } public function test_register_lifecycle_hooks_invokes_installer_with_network_flag(): void { @@ -211,22 +222,6 @@ private function make_option_installer( string $current ): PluginKernelOptionIns return new PluginKernelOptionInstaller( $current, self::VERSION_OPTION ); } - /** - * The live hook table reduced to tag => callbacks, tag-order-insensitive, so a rolled-back - * table can be compared byte-for-byte against the pre-window state. - * - * @return array>> - */ - private function normalized_hook_table(): array { - $table = array(); - foreach ( $GLOBALS['wp_filter'] as $tag => $hook ) { - $table[ $tag ] = $hook->callbacks; - } - \ksort( $table ); - - return $table; - } - /** * @param list> $features */ diff --git a/packages/core/tests/Support/NormalizesHookTables.php b/packages/core/tests/Support/NormalizesHookTables.php new file mode 100644 index 0000000..122b055 --- /dev/null +++ b/packages/core/tests/Support/NormalizesHookTables.php @@ -0,0 +1,21 @@ + callbacks, tag-order-insensitive, so a rolled-back + * table can be compared byte-for-byte against the pre-window state. + * + * @return array>> + */ + private function normalized_hook_table(): array { + $table = array(); + foreach ( $GLOBALS['wp_filter'] ?? array() as $tag => $hook ) { + $table[ $tag ] = $hook->callbacks; + } + \ksort( $table ); + + return $table; + } +} diff --git a/packages/core/tests/Unit/PluginKernelTest.php b/packages/core/tests/Unit/PluginKernelTest.php index 845a3c8..f0c51d2 100644 --- a/packages/core/tests/Unit/PluginKernelTest.php +++ b/packages/core/tests/Unit/PluginKernelTest.php @@ -13,6 +13,7 @@ use DeepWebSolutions\Framework\Core\PluginInterface; use DeepWebSolutions\Framework\Core\PluginKernel; use DeepWebSolutions\Framework\Core\Tests\Support\FakeWordPressHook; +use DeepWebSolutions\Framework\Core\Tests\Support\NormalizesHookTables; use DeepWebSolutions\Framework\Core\ValueObjects\BootStatus; use DeepWebSolutions\Framework\Core\ValueObjects\PluginBootReport; use DeepWebSolutions\Framework\Core\ValueObjects\PluginHeader; @@ -29,6 +30,8 @@ #[UsesClass( PluginBootReport::class )] #[UsesClass( Version::class )] final class PluginKernelTest extends TestCase { + use NormalizesHookTables; + public function test_initializes_all_components_before_registering_any_hooks(): void { $log = new PluginKernelTestLog(); @@ -918,22 +921,6 @@ private function make_container( array $services, array $throwing = array() ): P return new PluginKernelTestContainer( $services, $throwing ); } - /** - * The live hook table reduced to tag => callbacks, tag-order-insensitive, so a - * rolled-back table can be compared byte-for-byte against the pre-window state. - * - * @return array>> - */ - private function normalized_hook_table(): array { - $table = array(); - foreach ( $GLOBALS['wp_filter'] ?? array() as $tag => $hook ) { - $table[ $tag ] = $hook->callbacks; - } - \ksort( $table ); - - return $table; - } - private function make_component( string $name, PluginKernelTestLog $log, bool $enabled = true ): object { return new class( $name, $log, $enabled ) implements InitializableInterface, HookableInterface, EnabledInterface { public function __construct( diff --git a/packages/infrastructure/tests/Settings/Integration/WordPressSettingsBackendTest.php b/packages/infrastructure/tests/Settings/Integration/Backend/WordPressSettingsBackendTest.php similarity index 99% rename from packages/infrastructure/tests/Settings/Integration/WordPressSettingsBackendTest.php rename to packages/infrastructure/tests/Settings/Integration/Backend/WordPressSettingsBackendTest.php index ed6535e..f0fe7aa 100644 --- a/packages/infrastructure/tests/Settings/Integration/WordPressSettingsBackendTest.php +++ b/packages/infrastructure/tests/Settings/Integration/Backend/WordPressSettingsBackendTest.php @@ -1,6 +1,6 @@ - */ - private array $saved_hooks = array(); + private int $post_id = 0; protected function setUp(): void { parent::setUp(); @@ -50,12 +48,6 @@ protected function setUp(): void { $_POST = array(); $GLOBALS['wp_meta_boxes'] = array(); - global $wp_filter; - foreach ( self::ISOLATED_HOOKS as $hook ) { - $this->saved_hooks[ $hook ] = $wp_filter[ $hook ] ?? null; - unset( $wp_filter[ $hook ] ); - } - $post_id = \wp_insert_post( array( 'post_title' => 'Probe', @@ -71,15 +63,6 @@ protected function tearDown(): void { $_POST = array(); $GLOBALS['wp_meta_boxes'] = array(); - global $wp_filter; - foreach ( $this->saved_hooks as $hook => $saved ) { - if ( null !== $saved ) { - $wp_filter[ $hook ] = $saved; - } else { - unset( $wp_filter[ $hook ] ); - } - } - parent::tearDown(); } @@ -112,9 +95,9 @@ public function test_the_box_row_binds_the_label_to_the_control_id(): void { } public function test_saving_persists_with_capability_and_a_valid_nonce(): void { - $store = new PostMetaFieldSurface(); - $group = $this->group(); - $store->register( $group, $this->placement() ); + $surface = new PostMetaFieldSurface(); + $group = $this->group(); + $surface->register( $group, $this->placement() ); $_POST = array( $this->nonce_name( $group ) => $this->nonce( $group ), @@ -126,9 +109,9 @@ public function test_saving_persists_with_capability_and_a_valid_nonce(): void { } public function test_crud_addresses_the_same_meta_key_the_form_save_writes(): void { - $store = new PostMetaFieldSurface(); - $group = $this->group(); - $store->register( $group, $this->placement() ); + $surface = new PostMetaFieldSurface(); + $group = $this->group(); + $surface->register( $group, $this->placement() ); $_POST = array( $this->nonce_name( $group ) => $this->nonce( $group ), @@ -136,22 +119,22 @@ public function test_crud_addresses_the_same_meta_key_the_form_save_writes(): vo ); \do_action( 'save_post_post', $this->post_id ); - self::assertTrue( $store->has( $group, $this->post_id, 'note' ) ); - self::assertSame( 'hi', $store->get( $group, $this->post_id, 'note' ) ); + self::assertTrue( $surface->has( $group, $this->post_id, 'note' ) ); + self::assertSame( 'hi', $surface->get( $group, $this->post_id, 'note' ) ); - $store->set( $group, $this->post_id, 'note', 'bye' ); + $surface->set( $group, $this->post_id, 'note', 'bye' ); self::assertSame( 'bye', \get_post_meta( $this->post_id, 'note', true ) ); - self::assertTrue( $store->delete( $group, $this->post_id, 'note' ) ); + self::assertTrue( $surface->delete( $group, $this->post_id, 'note' ) ); self::assertFalse( \metadata_exists( 'post', $this->post_id, 'note' ) ); - self::assertSame( array( 'note' ), $store->meta_keys( $group ) ); + self::assertSame( array( 'note' ), $surface->meta_keys( $group ) ); } public function test_saving_applies_the_builtin_default_sanitizer(): void { - $store = new PostMetaFieldSurface(); - $group = $this->group(); - $raw = 'x'; - $store->register( $group, $this->placement() ); + $surface = new PostMetaFieldSurface(); + $group = $this->group(); + $raw = 'x'; + $surface->register( $group, $this->placement() ); $_POST = array( $this->nonce_name( $group ) => $this->nonce( $group ), @@ -163,11 +146,11 @@ public function test_saving_applies_the_builtin_default_sanitizer(): void { } public function test_saving_preserves_an_existing_value_when_a_present_submission_is_invalid(): void { - $store = new PostMetaFieldSurface(); - $group = $this->group_with( + $surface = new PostMetaFieldSurface(); + $group = $this->group_with( new SettingsField( id: 'color', type: 'select', label: 'Color', options: array( 'red' => 'Red' ) ), ); - $store->register( $group, $this->placement() ); + $surface->register( $group, $this->placement() ); $this->repo()->set( $this->post_id, 'color', 'red' ); $_POST = array( @@ -215,10 +198,10 @@ public function test_saving_is_skipped_for_a_user_without_the_edit_capability(): } public function test_a_configured_box_capability_overrides_the_default(): void { - $store = new PostMetaFieldSurface(); + $surface = new PostMetaFieldSurface(); $placement = new MetaBoxPlacement( screen: 'post', context: 'side', priority: 'default', capability: 'dws_nonexistent_cap' ); $group = $this->group(); - $store->register( $group, $placement ); + $surface->register( $group, $placement ); // The administrator passes the default edit_post but lacks the configured capability, so the save is refused. $_POST = array( diff --git a/packages/infrastructure/tests/Settings/Integration/MetaField/Surfaces/TermFieldSurfaceTest.php b/packages/infrastructure/tests/Settings/Integration/MetaField/Surfaces/TermFieldSurfaceTest.php index 88200b4..90db1d7 100644 --- a/packages/infrastructure/tests/Settings/Integration/MetaField/Surfaces/TermFieldSurfaceTest.php +++ b/packages/infrastructure/tests/Settings/Integration/MetaField/Surfaces/TermFieldSurfaceTest.php @@ -11,6 +11,7 @@ use DeepWebSolutions\Framework\Settings\Schema\Field\FieldType; use DeepWebSolutions\Framework\Settings\Schema\Options\OptionsResolver; use DeepWebSolutions\Framework\Settings\Schema\ValueObjects\SettingsField; +use DeepWebSolutions\Framework\Settings\Tests\Support\IsolatesHooks; use DeepWebSolutions\Framework\Storage\ObjectMeta\MetadataRepository; use DeepWebSolutions\Framework\Storage\ObjectMeta\MetaType; use PHPUnit\Framework\Attributes\CoversClass; @@ -29,17 +30,14 @@ #[UsesClass( OptionsResolver::class )] #[UsesClass( FieldType::class )] final class TermFieldSurfaceTest extends TestCase { - private const GROUP_ID = 'dws_termmeta'; - private const NONCE_NAME = 'dws_object_field_dws_termmeta_nonce'; - private const NONCE_ACTION = 'dws_object_field_dws_termmeta'; - private const ISOLATED_HOOKS = array( 'category_add_form_fields', 'category_edit_form_fields', 'created_category', 'edited_category' ); + use IsolatesHooks; - private int $term_id = 0; + private const GROUP_ID = 'dws_termmeta'; + private const NONCE_NAME = 'dws_object_field_dws_termmeta_nonce'; + private const NONCE_ACTION = 'dws_object_field_dws_termmeta'; + protected const ISOLATED_HOOKS = array( 'category_add_form_fields', 'category_edit_form_fields', 'created_category', 'edited_category' ); - /** - * @var array - */ - private array $saved_hooks = array(); + private int $term_id = 0; protected function setUp(): void { parent::setUp(); @@ -47,12 +45,6 @@ protected function setUp(): void { \wp_set_current_user( 1 ); $_POST = array(); - global $wp_filter; - foreach ( self::ISOLATED_HOOKS as $hook ) { - $this->saved_hooks[ $hook ] = $wp_filter[ $hook ] ?? null; - unset( $wp_filter[ $hook ] ); - } - $term = \wp_insert_term( 'DWS Probe ' . \uniqid(), 'category' ); \assert( \is_array( $term ) ); $this->term_id = (int) $term['term_id']; @@ -62,15 +54,6 @@ protected function tearDown(): void { \wp_delete_term( $this->term_id, 'category' ); $_POST = array(); - global $wp_filter; - foreach ( $this->saved_hooks as $hook => $saved ) { - if ( null !== $saved ) { - $wp_filter[ $hook ] = $saved; - } else { - unset( $wp_filter[ $hook ] ); - } - } - parent::tearDown(); } @@ -143,10 +126,10 @@ public function test_saving_persists_with_capability_and_a_valid_nonce(): void { } public function test_crud_addresses_the_same_meta_key_the_form_save_writes(): void { - $store = new TermFieldSurface(); + $surface = new TermFieldSurface(); $term_group = $this->term_group(); $group = $term_group->group; - $store->register( $term_group ); + $surface->register( $term_group ); $_POST = array( self::NONCE_NAME => $this->nonce(), @@ -154,15 +137,15 @@ public function test_crud_addresses_the_same_meta_key_the_form_save_writes(): vo ); \do_action( 'edited_category', $this->term_id ); - self::assertTrue( $store->has( $group, $this->term_id, 'color' ) ); - self::assertSame( 'blue', $store->get( $group, $this->term_id, 'color' ) ); + self::assertTrue( $surface->has( $group, $this->term_id, 'color' ) ); + self::assertSame( 'blue', $surface->get( $group, $this->term_id, 'color' ) ); - $store->set( $group, $this->term_id, 'color', 'red' ); + $surface->set( $group, $this->term_id, 'color', 'red' ); self::assertSame( 'red', \get_term_meta( $this->term_id, 'color', true ) ); - self::assertTrue( $store->delete( $group, $this->term_id, 'color' ) ); + self::assertTrue( $surface->delete( $group, $this->term_id, 'color' ) ); self::assertFalse( \metadata_exists( 'term', $this->term_id, 'color' ) ); - self::assertSame( array( 'color' ), $store->meta_keys( $group ) ); + self::assertSame( array( 'color' ), $surface->meta_keys( $group ) ); } public function test_saving_a_created_term_persists_with_capability_and_a_valid_add_nonce(): void { diff --git a/packages/infrastructure/tests/Settings/Integration/MetaField/Surfaces/UserProfileFieldSurfaceTest.php b/packages/infrastructure/tests/Settings/Integration/MetaField/Surfaces/UserProfileFieldSurfaceTest.php index 0c2e1c1..dd415ab 100644 --- a/packages/infrastructure/tests/Settings/Integration/MetaField/Surfaces/UserProfileFieldSurfaceTest.php +++ b/packages/infrastructure/tests/Settings/Integration/MetaField/Surfaces/UserProfileFieldSurfaceTest.php @@ -11,6 +11,7 @@ use DeepWebSolutions\Framework\Settings\Schema\Field\FieldType; use DeepWebSolutions\Framework\Settings\Schema\Options\OptionsResolver; use DeepWebSolutions\Framework\Settings\Schema\ValueObjects\SettingsField; +use DeepWebSolutions\Framework\Settings\Tests\Support\IsolatesHooks; use DeepWebSolutions\Framework\Storage\ObjectMeta\MetadataRepository; use DeepWebSolutions\Framework\Storage\ObjectMeta\MetaType; use PHPUnit\Framework\Attributes\CoversClass; @@ -29,10 +30,12 @@ #[UsesClass( OptionsResolver::class )] #[UsesClass( FieldType::class )] final class UserProfileFieldSurfaceTest extends TestCase { - private const GROUP_ID = 'dws_prefs'; - private const NONCE_NAME = 'dws_object_field_dws_prefs_nonce'; - private const NONCE_ACTION = 'dws_object_field_dws_prefs'; - private const ISOLATED_HOOKS = array( + use IsolatesHooks; + + private const GROUP_ID = 'dws_prefs'; + private const NONCE_NAME = 'dws_object_field_dws_prefs_nonce'; + private const NONCE_ACTION = 'dws_object_field_dws_prefs'; + protected const ISOLATED_HOOKS = array( 'show_user_profile', 'edit_user_profile', 'personal_options_update', @@ -41,11 +44,6 @@ final class UserProfileFieldSurfaceTest extends TestCase { private int $user_id = 0; - /** - * @var array - */ - private array $saved_hooks = array(); - protected function setUp(): void { parent::setUp(); @@ -54,12 +52,6 @@ protected function setUp(): void { \wp_set_current_user( 1 ); $_POST = array(); - global $wp_filter; - foreach ( self::ISOLATED_HOOKS as $hook ) { - $this->saved_hooks[ $hook ] = $wp_filter[ $hook ] ?? null; - unset( $wp_filter[ $hook ] ); - } - $user_id = \wp_insert_user( array( 'user_login' => 'dws_target_' . \uniqid(), @@ -75,15 +67,6 @@ protected function tearDown(): void { \wp_delete_user( $this->user_id ); $_POST = array(); - global $wp_filter; - foreach ( $this->saved_hooks as $hook => $saved ) { - if ( null !== $saved ) { - $wp_filter[ $hook ] = $saved; - } else { - unset( $wp_filter[ $hook ] ); - } - } - parent::tearDown(); } @@ -124,10 +107,10 @@ public function test_saving_persists_with_capability_and_a_valid_nonce(): void { } public function test_crud_addresses_the_same_meta_key_the_form_save_writes(): void { - $store = new UserProfileFieldSurface(); + $surface = new UserProfileFieldSurface(); $profile = $this->text_profile(); $group = $profile->group; - $store->register( $profile ); + $surface->register( $profile ); $_POST = array( self::NONCE_NAME => $this->nonce(), @@ -135,15 +118,15 @@ public function test_crud_addresses_the_same_meta_key_the_form_save_writes(): vo ); \do_action( 'edit_user_profile_update', $this->user_id ); - self::assertTrue( $store->has( $group, $this->user_id, 'pref' ) ); - self::assertSame( 'weekly', $store->get( $group, $this->user_id, 'pref' ) ); + self::assertTrue( $surface->has( $group, $this->user_id, 'pref' ) ); + self::assertSame( 'weekly', $surface->get( $group, $this->user_id, 'pref' ) ); - $store->set( $group, $this->user_id, 'pref', 'daily' ); + $surface->set( $group, $this->user_id, 'pref', 'daily' ); self::assertSame( 'daily', \get_user_meta( $this->user_id, 'pref', true ) ); - self::assertTrue( $store->delete( $group, $this->user_id, 'pref' ) ); + self::assertTrue( $surface->delete( $group, $this->user_id, 'pref' ) ); self::assertFalse( \metadata_exists( 'user', $this->user_id, 'pref' ) ); - self::assertSame( array( 'pref' ), $store->meta_keys( $group ) ); + self::assertSame( array( 'pref' ), $surface->meta_keys( $group ) ); } public function test_saving_applies_the_builtin_default_sanitizer(): void { diff --git a/packages/infrastructure/tests/Settings/Integration/MetaField/Surfaces/index.php b/packages/infrastructure/tests/Settings/Integration/MetaField/Surfaces/index.php deleted file mode 100644 index f767346..0000000 --- a/packages/infrastructure/tests/Settings/Integration/MetaField/Surfaces/index.php +++ /dev/null @@ -1 +0,0 @@ -ID; - } - - $id = \wp_insert_user( - array( - 'user_login' => $login, - 'user_pass' => 'password', - 'role' => $role, - ), - ); - self::assertIsInt( $id ); - - return $id; - } } diff --git a/packages/infrastructure/tests/Settings/Integration/index.php b/packages/infrastructure/tests/Settings/Integration/index.php deleted file mode 100644 index f767346..0000000 --- a/packages/infrastructure/tests/Settings/Integration/index.php +++ /dev/null @@ -1 +0,0 @@ -ID; + } + + $id = \wp_insert_user( + array( + 'user_login' => $login, + 'user_pass' => 'password', + 'role' => $role, + ), + ); + self::assertIsInt( $id ); + + return $id; + } + + protected function make_admin( string $login ): int { + return $this->make_user( $login, 'administrator' ); + } +} diff --git a/packages/infrastructure/tests/Settings/Support/IsolatesHooks.php b/packages/infrastructure/tests/Settings/Support/IsolatesHooks.php new file mode 100644 index 0000000..12b9672 --- /dev/null +++ b/packages/infrastructure/tests/Settings/Support/IsolatesHooks.php @@ -0,0 +1,41 @@ + + */ + private array $saved_hooks = array(); + + #[Before] + protected function snapshot_isolated_hooks(): void { + global $wp_filter; + foreach ( static::ISOLATED_HOOKS as $hook ) { + $this->saved_hooks[ $hook ] = $wp_filter[ $hook ] ?? null; + unset( $wp_filter[ $hook ] ); + } + } + + #[After] + protected function restore_isolated_hooks(): void { + global $wp_filter; + foreach ( $this->saved_hooks as $hook => $saved ) { + if ( null !== $saved ) { + $wp_filter[ $hook ] = $saved; + } else { + unset( $wp_filter[ $hook ] ); + } + } + + $this->saved_hooks = array(); + } +} diff --git a/packages/infrastructure/tests/Settings/Unit/WordPressSettingsBackendTest.php b/packages/infrastructure/tests/Settings/Unit/Backend/WordPressSettingsBackendTest.php similarity index 97% rename from packages/infrastructure/tests/Settings/Unit/WordPressSettingsBackendTest.php rename to packages/infrastructure/tests/Settings/Unit/Backend/WordPressSettingsBackendTest.php index eb63bf8..b83c958 100644 --- a/packages/infrastructure/tests/Settings/Unit/WordPressSettingsBackendTest.php +++ b/packages/infrastructure/tests/Settings/Unit/Backend/WordPressSettingsBackendTest.php @@ -1,6 +1,6 @@ repository = new InMemoryObjectMetaRepository(); + $this->surface = $this->make_surface( $this->repository ); + } + + abstract protected function make_surface( ObjectMetaRepositoryInterface $repository ): PostMetaFieldSurface|TermFieldSurface|UserProfileFieldSurface; + + public function test_a_value_round_trips_under_the_resolved_storage_key(): void { + $group = $this->group( new SettingsField( id: 'note', type: 'text', label: 'Note' ) ); + + $this->surface->set( $group, 7, 'note', 'hello' ); + + self::assertSame( 'hello', $this->surface->get( $group, 7, 'note' ) ); + self::assertSame( 'hello', $this->repository->get( 7, 'note' ) ); + self::assertTrue( $this->surface->has( $group, 7, 'note' ) ); + self::assertFalse( $this->surface->has( $group, 8, 'note' ) ); + } + + public function test_a_meta_key_override_is_the_byte_exact_storage_key(): void { + $group = $this->group( new SettingsField( id: 'note', type: 'text', label: 'Note', meta_key: '_dws_note' ) ); + + $this->surface->set( $group, 7, 'note', 'hello' ); + + self::assertSame( 'hello', $this->repository->get( 7, '_dws_note' ) ); + self::assertFalse( $this->repository->has( 7, 'note' ) ); + } + + public function test_get_returns_the_caller_fallback_never_the_field_default_when_nothing_is_stored(): void { + $group = $this->group( new SettingsField( id: 'note', type: 'text', label: 'Note', default_value: 'declared-default' ) ); + + self::assertNull( $this->surface->get( $group, 7, 'note' ) ); + self::assertSame( 'fallback', $this->surface->get( $group, 7, 'note', 'fallback' ) ); + } + + public function test_set_stores_a_checkbox_in_its_canonical_yes_no_form(): void { + $group = $this->group( new SettingsField( id: 'flag', type: 'checkbox', label: 'Flag' ) ); + + $this->surface->set( $group, 7, 'flag', true ); + self::assertSame( 'yes', $this->repository->get( 7, 'flag' ) ); + + $this->surface->set( $group, 7, 'flag', false ); + self::assertSame( 'no', $this->repository->get( 7, 'flag' ) ); + self::assertTrue( $this->surface->has( $group, 7, 'flag' ) ); + } + + public function test_set_revokes_the_key_for_a_value_a_form_save_would_not_store(): void { + $group = $this->group( new SettingsField( id: 'note', type: 'text', label: 'Note' ) ); + + $this->surface->set( $group, 7, 'note', 'hello' ); + $this->surface->set( $group, 7, 'note', '' ); + + self::assertFalse( $this->surface->has( $group, 7, 'note' ) ); + } + + public function test_delete_removes_a_stored_value_and_reports_a_missing_one(): void { + $group = $this->group( new SettingsField( id: 'note', type: 'text', label: 'Note' ) ); + + $this->surface->set( $group, 7, 'note', 'hello' ); + + self::assertTrue( $this->surface->delete( $group, 7, 'note' ) ); + self::assertFalse( $this->surface->has( $group, 7, 'note' ) ); + self::assertFalse( $this->surface->delete( $group, 7, 'note' ) ); + } + + public function test_a_field_the_group_does_not_declare_is_rejected(): void { + $this->expectException( InvalidSettingsFieldException::class ); + + $this->surface->get( $this->group( new SettingsField( id: 'note', type: 'text', label: 'Note' ) ), 7, 'missing' ); + } + + public function test_meta_keys_enumerates_the_resolved_storage_keys(): void { + $group = $this->group( + new SettingsField( id: 'note', type: 'text', label: 'Note' ), + new SettingsField( id: 'color', type: 'text', label: 'Color', meta_key: '_dws_color' ), + ); + + self::assertSame( array( 'note', '_dws_color' ), $this->surface->meta_keys( $group ) ); + } + + protected function group( SettingsField ...$fields ): FieldGroup { + $fields = \array_values( $fields ); + + return new FieldGroup( + id: 'dws_group', + title: 'Group', + fields_provider: static fn ( int $object_id ): array => $fields, + ); + } +} diff --git a/packages/infrastructure/tests/Settings/Unit/MetaField/Surfaces/PostMetaFieldSurfaceTest.php b/packages/infrastructure/tests/Settings/Unit/MetaField/Surfaces/PostMetaFieldSurfaceTest.php index 5f610ce..60d7ad0 100644 --- a/packages/infrastructure/tests/Settings/Unit/MetaField/Surfaces/PostMetaFieldSurfaceTest.php +++ b/packages/infrastructure/tests/Settings/Unit/MetaField/Surfaces/PostMetaFieldSurfaceTest.php @@ -6,17 +6,14 @@ use DeepWebSolutions\Framework\Settings\MetaField\Surfaces\PostMetaFieldSurface; use DeepWebSolutions\Framework\Settings\MetaField\ValueObjects\FieldGroup; use DeepWebSolutions\Framework\Settings\Schema\Exceptions\DuplicateSettingsFieldException; -use DeepWebSolutions\Framework\Settings\Schema\Exceptions\InvalidSettingsFieldException; use DeepWebSolutions\Framework\Settings\Schema\Field\FieldProcessor; use DeepWebSolutions\Framework\Settings\Schema\Field\FieldRenderer; use DeepWebSolutions\Framework\Settings\Schema\Options\OptionsResolver; use DeepWebSolutions\Framework\Settings\Schema\ValueObjects\SettingsField; -use DeepWebSolutions\Framework\Settings\Tests\Fixtures\InMemoryObjectMetaRepository; use DeepWebSolutions\Framework\Storage\ObjectMeta\ObjectMetaRepositoryInterface; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\Attributes\UsesFunction; -use PHPUnit\Framework\TestCase; #[CoversClass( PostMetaFieldSurface::class )] #[UsesClass( ObjectFieldForm::class )] @@ -29,91 +26,15 @@ #[UsesFunction( 'DeepWebSolutions\Framework\Settings\Schema\is_checkbox_checked' )] #[UsesFunction( 'DeepWebSolutions\Framework\Settings\Schema\normalize_checkbox_value' )] #[UsesFunction( 'DeepWebSolutions\Framework\Settings\Schema\wordpress_field_type_sanitizers' )] -final class PostMetaFieldSurfaceTest extends TestCase { - private ObjectMetaRepositoryInterface $repository; - private PostMetaFieldSurface $store; - - protected function setUp(): void { - parent::setUp(); - - $this->repository = new InMemoryObjectMetaRepository(); - $this->store = new PostMetaFieldSurface( repository: $this->repository ); - } - - public function test_a_value_round_trips_under_the_field_id_when_no_meta_key_override_is_set(): void { - $group = $this->group( new SettingsField( id: 'note', type: 'text', label: 'Note' ) ); - - $this->store->set( $group, 7, 'note', 'hello' ); - - self::assertSame( 'hello', $this->store->get( $group, 7, 'note' ) ); - self::assertSame( 'hello', $this->repository->get( 7, 'note' ) ); - } - - public function test_a_meta_key_override_is_the_byte_exact_storage_key(): void { - $group = $this->group( new SettingsField( id: 'note', type: 'text', label: 'Note', meta_key: '_dws_note' ) ); - - $this->store->set( $group, 7, 'note', 'hello' ); - - self::assertSame( 'hello', $this->repository->get( 7, '_dws_note' ) ); - self::assertFalse( $this->repository->has( 7, 'note' ) ); - } - - public function test_get_returns_the_caller_fallback_never_the_field_default_when_nothing_is_stored(): void { - $group = $this->group( new SettingsField( id: 'note', type: 'text', label: 'Note', default_value: 'declared-default' ) ); - - self::assertNull( $this->store->get( $group, 7, 'note' ) ); - self::assertSame( 'fallback', $this->store->get( $group, 7, 'note', 'fallback' ) ); - } - - public function test_a_write_targets_only_the_addressed_object(): void { - $group = $this->group( new SettingsField( id: 'note', type: 'text', label: 'Note' ) ); - - $this->store->set( $group, 7, 'note', 'seven' ); - - self::assertTrue( $this->store->has( $group, 7, 'note' ) ); - self::assertFalse( $this->store->has( $group, 8, 'note' ) ); - } - - public function test_set_stores_a_checkbox_in_its_canonical_yes_no_form(): void { - $group = $this->group( new SettingsField( id: 'flag', type: 'checkbox', label: 'Flag' ) ); - - $this->store->set( $group, 7, 'flag', true ); - self::assertSame( 'yes', $this->repository->get( 7, 'flag' ) ); - - $this->store->set( $group, 7, 'flag', false ); - self::assertSame( 'no', $this->repository->get( 7, 'flag' ) ); - self::assertTrue( $this->store->has( $group, 7, 'flag' ) ); - } - - public function test_set_revokes_the_key_for_a_value_a_form_save_would_not_store(): void { - $group = $this->group( new SettingsField( id: 'note', type: 'text', label: 'Note' ) ); - - $this->store->set( $group, 7, 'note', 'hello' ); - $this->store->set( $group, 7, 'note', '' ); - - self::assertFalse( $this->store->has( $group, 7, 'note' ) ); - } - - public function test_delete_removes_a_stored_value_and_reports_a_missing_one(): void { - $group = $this->group( new SettingsField( id: 'note', type: 'text', label: 'Note' ) ); - - $this->store->set( $group, 7, 'note', 'hello' ); - - self::assertTrue( $this->store->delete( $group, 7, 'note' ) ); - self::assertFalse( $this->store->has( $group, 7, 'note' ) ); - self::assertFalse( $this->store->delete( $group, 7, 'note' ) ); - } - - public function test_a_field_the_group_does_not_declare_is_rejected(): void { - $this->expectException( InvalidSettingsFieldException::class ); - - $this->store->get( $this->group( new SettingsField( id: 'note', type: 'text', label: 'Note' ) ), 7, 'missing' ); +final class PostMetaFieldSurfaceTest extends ObjectFieldSurfaceContractTestCase { + protected function make_surface( ObjectMetaRepositoryInterface $repository ): PostMetaFieldSurface { + return new PostMetaFieldSurface( repository: $repository ); } public function test_two_fields_sharing_an_effective_storage_key_are_rejected(): void { $this->expectException( DuplicateSettingsFieldException::class ); - $this->store->get( + $this->surface->get( $this->group( new SettingsField( id: 'note', type: 'text', label: 'A' ), new SettingsField( id: 'alias', type: 'text', label: 'B', meta_key: 'note' ), @@ -132,7 +53,7 @@ public function test_the_key_resolution_follows_a_per_object_fields_provider(): ), ); - $this->store->set( $group, 7, 'note', 'hello' ); + $this->surface->set( $group, 7, 'note', 'hello' ); self::assertSame( 'hello', $this->repository->get( 7, '_dws_note_7' ) ); } @@ -149,16 +70,6 @@ public function test_meta_keys_enumerates_the_resolved_storage_keys_for_the_obje : array(), ); - self::assertSame( array( 'note', '_dws_color' ), $this->store->meta_keys( $group ) ); - } - - private function group( SettingsField ...$fields ): FieldGroup { - $fields = \array_values( $fields ); - - return new FieldGroup( - id: 'dws_group', - title: 'Group', - fields_provider: static fn ( int $object_id ): array => $fields, - ); + self::assertSame( array( 'note', '_dws_color' ), $this->surface->meta_keys( $group ) ); } } diff --git a/packages/infrastructure/tests/Settings/Unit/MetaField/Surfaces/TermFieldSurfaceTest.php b/packages/infrastructure/tests/Settings/Unit/MetaField/Surfaces/TermFieldSurfaceTest.php index ccf3c2e..04f96a9 100644 --- a/packages/infrastructure/tests/Settings/Unit/MetaField/Surfaces/TermFieldSurfaceTest.php +++ b/packages/infrastructure/tests/Settings/Unit/MetaField/Surfaces/TermFieldSurfaceTest.php @@ -5,17 +5,14 @@ use DeepWebSolutions\Framework\Settings\MetaField\ObjectFieldForm; use DeepWebSolutions\Framework\Settings\MetaField\Surfaces\TermFieldSurface; use DeepWebSolutions\Framework\Settings\MetaField\ValueObjects\FieldGroup; -use DeepWebSolutions\Framework\Settings\Schema\Exceptions\InvalidSettingsFieldException; use DeepWebSolutions\Framework\Settings\Schema\Field\FieldProcessor; use DeepWebSolutions\Framework\Settings\Schema\Field\FieldRenderer; use DeepWebSolutions\Framework\Settings\Schema\Options\OptionsResolver; use DeepWebSolutions\Framework\Settings\Schema\ValueObjects\SettingsField; -use DeepWebSolutions\Framework\Settings\Tests\Fixtures\InMemoryObjectMetaRepository; use DeepWebSolutions\Framework\Storage\ObjectMeta\ObjectMetaRepositoryInterface; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\Attributes\UsesFunction; -use PHPUnit\Framework\TestCase; #[CoversClass( TermFieldSurface::class )] #[UsesClass( ObjectFieldForm::class )] @@ -28,94 +25,8 @@ #[UsesFunction( 'DeepWebSolutions\Framework\Settings\Schema\is_checkbox_checked' )] #[UsesFunction( 'DeepWebSolutions\Framework\Settings\Schema\normalize_checkbox_value' )] #[UsesFunction( 'DeepWebSolutions\Framework\Settings\Schema\wordpress_field_type_sanitizers' )] -final class TermFieldSurfaceTest extends TestCase { - private ObjectMetaRepositoryInterface $repository; - private TermFieldSurface $store; - - protected function setUp(): void { - parent::setUp(); - - $this->repository = new InMemoryObjectMetaRepository(); - $this->store = new TermFieldSurface( repository: $this->repository ); - } - - public function test_a_value_round_trips_under_the_resolved_storage_key(): void { - $group = $this->group( new SettingsField( id: 'note', type: 'text', label: 'Note' ) ); - - $this->store->set( $group, 5, 'note', 'hello' ); - - self::assertSame( 'hello', $this->store->get( $group, 5, 'note' ) ); - self::assertSame( 'hello', $this->repository->get( 5, 'note' ) ); - self::assertFalse( $this->store->has( $group, 6, 'note' ) ); - } - - public function test_a_meta_key_override_is_the_byte_exact_storage_key(): void { - $group = $this->group( new SettingsField( id: 'note', type: 'text', label: 'Note', meta_key: '_dws_note' ) ); - - $this->store->set( $group, 5, 'note', 'hello' ); - - self::assertSame( 'hello', $this->repository->get( 5, '_dws_note' ) ); - self::assertFalse( $this->repository->has( 5, 'note' ) ); - } - - public function test_get_returns_the_caller_fallback_never_the_field_default_when_nothing_is_stored(): void { - $group = $this->group( new SettingsField( id: 'note', type: 'text', label: 'Note', default_value: 'declared-default' ) ); - - self::assertSame( 'fallback', $this->store->get( $group, 5, 'note', 'fallback' ) ); - } - - public function test_set_stores_a_checkbox_in_its_canonical_yes_no_form(): void { - $group = $this->group( new SettingsField( id: 'flag', type: 'checkbox', label: 'Flag' ) ); - - $this->store->set( $group, 5, 'flag', true ); - self::assertSame( 'yes', $this->repository->get( 5, 'flag' ) ); - - $this->store->set( $group, 5, 'flag', false ); - self::assertSame( 'no', $this->repository->get( 5, 'flag' ) ); - self::assertTrue( $this->store->has( $group, 5, 'flag' ) ); - } - - public function test_set_revokes_the_key_for_a_value_a_form_save_would_not_store(): void { - $group = $this->group( new SettingsField( id: 'note', type: 'text', label: 'Note' ) ); - - $this->store->set( $group, 5, 'note', 'hello' ); - $this->store->set( $group, 5, 'note', '' ); - - self::assertFalse( $this->store->has( $group, 5, 'note' ) ); - } - - public function test_delete_removes_a_stored_value_and_reports_a_missing_one(): void { - $group = $this->group( new SettingsField( id: 'note', type: 'text', label: 'Note' ) ); - - $this->store->set( $group, 5, 'note', 'hello' ); - - self::assertTrue( $this->store->delete( $group, 5, 'note' ) ); - self::assertFalse( $this->store->has( $group, 5, 'note' ) ); - self::assertFalse( $this->store->delete( $group, 5, 'note' ) ); - } - - public function test_a_field_the_group_does_not_declare_is_rejected(): void { - $this->expectException( InvalidSettingsFieldException::class ); - - $this->store->has( $this->group( new SettingsField( id: 'note', type: 'text', label: 'Note' ) ), 5, 'missing' ); - } - - public function test_meta_keys_enumerates_the_resolved_storage_keys(): void { - $group = $this->group( - new SettingsField( id: 'note', type: 'text', label: 'Note' ), - new SettingsField( id: 'color', type: 'text', label: 'Color', meta_key: '_dws_color' ), - ); - - self::assertSame( array( 'note', '_dws_color' ), $this->store->meta_keys( $group ) ); - } - - private function group( SettingsField ...$fields ): FieldGroup { - $fields = \array_values( $fields ); - - return new FieldGroup( - id: 'dws_term_group', - title: 'Group', - fields_provider: static fn ( int $object_id ): array => $fields, - ); +final class TermFieldSurfaceTest extends ObjectFieldSurfaceContractTestCase { + protected function make_surface( ObjectMetaRepositoryInterface $repository ): TermFieldSurface { + return new TermFieldSurface( repository: $repository ); } } diff --git a/packages/infrastructure/tests/Settings/Unit/MetaField/Surfaces/UserProfileFieldSurfaceTest.php b/packages/infrastructure/tests/Settings/Unit/MetaField/Surfaces/UserProfileFieldSurfaceTest.php index 5afbc73..4698380 100644 --- a/packages/infrastructure/tests/Settings/Unit/MetaField/Surfaces/UserProfileFieldSurfaceTest.php +++ b/packages/infrastructure/tests/Settings/Unit/MetaField/Surfaces/UserProfileFieldSurfaceTest.php @@ -5,17 +5,14 @@ use DeepWebSolutions\Framework\Settings\MetaField\ObjectFieldForm; use DeepWebSolutions\Framework\Settings\MetaField\Surfaces\UserProfileFieldSurface; use DeepWebSolutions\Framework\Settings\MetaField\ValueObjects\FieldGroup; -use DeepWebSolutions\Framework\Settings\Schema\Exceptions\InvalidSettingsFieldException; use DeepWebSolutions\Framework\Settings\Schema\Field\FieldProcessor; use DeepWebSolutions\Framework\Settings\Schema\Field\FieldRenderer; use DeepWebSolutions\Framework\Settings\Schema\Options\OptionsResolver; use DeepWebSolutions\Framework\Settings\Schema\ValueObjects\SettingsField; -use DeepWebSolutions\Framework\Settings\Tests\Fixtures\InMemoryObjectMetaRepository; use DeepWebSolutions\Framework\Storage\ObjectMeta\ObjectMetaRepositoryInterface; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\Attributes\UsesFunction; -use PHPUnit\Framework\TestCase; #[CoversClass( UserProfileFieldSurface::class )] #[UsesClass( ObjectFieldForm::class )] @@ -28,94 +25,8 @@ #[UsesFunction( 'DeepWebSolutions\Framework\Settings\Schema\is_checkbox_checked' )] #[UsesFunction( 'DeepWebSolutions\Framework\Settings\Schema\normalize_checkbox_value' )] #[UsesFunction( 'DeepWebSolutions\Framework\Settings\Schema\wordpress_field_type_sanitizers' )] -final class UserProfileFieldSurfaceTest extends TestCase { - private ObjectMetaRepositoryInterface $repository; - private UserProfileFieldSurface $store; - - protected function setUp(): void { - parent::setUp(); - - $this->repository = new InMemoryObjectMetaRepository(); - $this->store = new UserProfileFieldSurface( repository: $this->repository ); - } - - public function test_a_value_round_trips_under_the_resolved_storage_key(): void { - $group = $this->group( new SettingsField( id: 'phone', type: 'text', label: 'Phone' ) ); - - $this->store->set( $group, 3, 'phone', '555' ); - - self::assertSame( '555', $this->store->get( $group, 3, 'phone' ) ); - self::assertSame( '555', $this->repository->get( 3, 'phone' ) ); - self::assertFalse( $this->store->has( $group, 4, 'phone' ) ); - } - - public function test_a_meta_key_override_is_the_byte_exact_storage_key(): void { - $group = $this->group( new SettingsField( id: 'phone', type: 'text', label: 'Phone', meta_key: '_dws_phone' ) ); - - $this->store->set( $group, 3, 'phone', '555' ); - - self::assertSame( '555', $this->repository->get( 3, '_dws_phone' ) ); - self::assertFalse( $this->repository->has( 3, 'phone' ) ); - } - - public function test_get_returns_the_caller_fallback_never_the_field_default_when_nothing_is_stored(): void { - $group = $this->group( new SettingsField( id: 'phone', type: 'text', label: 'Phone', default_value: 'declared-default' ) ); - - self::assertSame( 'fallback', $this->store->get( $group, 3, 'phone', 'fallback' ) ); - } - - public function test_set_stores_a_checkbox_in_its_canonical_yes_no_form(): void { - $group = $this->group( new SettingsField( id: 'flag', type: 'checkbox', label: 'Flag' ) ); - - $this->store->set( $group, 3, 'flag', true ); - self::assertSame( 'yes', $this->repository->get( 3, 'flag' ) ); - - $this->store->set( $group, 3, 'flag', false ); - self::assertSame( 'no', $this->repository->get( 3, 'flag' ) ); - self::assertTrue( $this->store->has( $group, 3, 'flag' ) ); - } - - public function test_set_revokes_the_key_for_a_value_a_form_save_would_not_store(): void { - $group = $this->group( new SettingsField( id: 'phone', type: 'text', label: 'Phone' ) ); - - $this->store->set( $group, 3, 'phone', '555' ); - $this->store->set( $group, 3, 'phone', '' ); - - self::assertFalse( $this->store->has( $group, 3, 'phone' ) ); - } - - public function test_delete_removes_a_stored_value_and_reports_a_missing_one(): void { - $group = $this->group( new SettingsField( id: 'phone', type: 'text', label: 'Phone' ) ); - - $this->store->set( $group, 3, 'phone', '555' ); - - self::assertTrue( $this->store->delete( $group, 3, 'phone' ) ); - self::assertFalse( $this->store->has( $group, 3, 'phone' ) ); - self::assertFalse( $this->store->delete( $group, 3, 'phone' ) ); - } - - public function test_a_field_the_group_does_not_declare_is_rejected(): void { - $this->expectException( InvalidSettingsFieldException::class ); - - $this->store->delete( $this->group( new SettingsField( id: 'phone', type: 'text', label: 'Phone' ) ), 3, 'missing' ); - } - - public function test_meta_keys_enumerates_the_resolved_storage_keys(): void { - $group = $this->group( - new SettingsField( id: 'phone', type: 'text', label: 'Phone' ), - new SettingsField( id: 'badge', type: 'text', label: 'Badge', meta_key: '_dws_badge' ), - ); - - self::assertSame( array( 'phone', '_dws_badge' ), $this->store->meta_keys( $group ) ); - } - - private function group( SettingsField ...$fields ): FieldGroup { - $fields = \array_values( $fields ); - - return new FieldGroup( - id: 'dws_profile_group', - title: 'Group', - fields_provider: static fn ( int $object_id ): array => $fields, - ); +final class UserProfileFieldSurfaceTest extends ObjectFieldSurfaceContractTestCase { + protected function make_surface( ObjectMetaRepositoryInterface $repository ): UserProfileFieldSurface { + return new UserProfileFieldSurface( repository: $repository ); } } diff --git a/packages/infrastructure/tests/Settings/Unit/MetaField/Surfaces/index.php b/packages/infrastructure/tests/Settings/Unit/MetaField/Surfaces/index.php deleted file mode 100644 index f767346..0000000 --- a/packages/infrastructure/tests/Settings/Unit/MetaField/Surfaces/index.php +++ /dev/null @@ -1 +0,0 @@ -get( 'k', 'sentinel' ) ); } - public function test_has_and_delete(): void { + public function test_has_reports_a_stored_key_and_not_a_missing_one(): void { $store = new OptionsStore( self::OPTION_KEY ); $store->set( 'k', 'v' ); self::assertTrue( $store->has( 'k' ) ); + self::assertFalse( $store->has( 'missing' ) ); + } + + public function test_delete_removes_a_stored_key_and_reports_a_missing_one(): void { + $store = new OptionsStore( self::OPTION_KEY ); + $store->set( 'k', 'v' ); + self::assertTrue( $store->delete( 'k' ) ); self::assertFalse( $store->has( 'k' ) ); self::assertFalse( $store->delete( 'k' ) ); } - public function test_get_all_and_clear(): void { + public function test_get_all_returns_every_stored_entry(): void { $store = new OptionsStore( self::OPTION_KEY ); $store->set( 'a', 1 ); $store->set( 'b', 2 ); @@ -63,8 +70,15 @@ public function test_get_all_and_clear(): void { ), $store->get_all(), ); + } + + public function test_clear_empties_the_store(): void { + $store = new OptionsStore( self::OPTION_KEY ); + $store->set( 'a', 1 ); + $store->set( 'b', 2 ); $store->clear(); + self::assertSame( array(), $store->get_all() ); } diff --git a/packages/infrastructure/tests/Storage/Integration/UserMetaStoreTest.php b/packages/infrastructure/tests/Storage/Integration/UserMetaStoreTest.php index 10166fe..a19d3a7 100644 --- a/packages/infrastructure/tests/Storage/Integration/UserMetaStoreTest.php +++ b/packages/infrastructure/tests/Storage/Integration/UserMetaStoreTest.php @@ -2,12 +2,15 @@ namespace DeepWebSolutions\Framework\Storage\Tests\Integration; +use DeepWebSolutions\Framework\Settings\Tests\Support\CreatesUsers; use DeepWebSolutions\Framework\Storage\UserMetaStore; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; #[CoversClass( UserMetaStore::class )] final class UserMetaStoreTest extends TestCase { + use CreatesUsers; + private const META_KEY = 'dws_test_user_meta_store'; private int $user_a; @@ -133,23 +136,6 @@ public function test_preserves_backslashes_in_stored_string_values(): void { self::assertSame( 'C:\\Users\\dev\\file.txt', $store->get( 'path' ) ); } - private function make_user( string $login ): int { - $existing = \get_user_by( 'login', $login ); - if ( $existing instanceof \WP_User ) { - return $existing->ID; - } - - $id = \wp_insert_user( - array( - 'user_login' => $login, - 'user_pass' => 'password', - 'role' => 'subscriber', - ), - ); - self::assertIsInt( $id ); - return $id; - } - private function delete_user( int $id ): void { if ( ! \function_exists( 'wp_delete_user' ) ) { require_once \ABSPATH . 'wp-admin/includes/user.php'; diff --git a/packages/infrastructure/tests/Storage/Integration/index.php b/packages/infrastructure/tests/Storage/Integration/index.php deleted file mode 100644 index f767346..0000000 --- a/packages/infrastructure/tests/Storage/Integration/index.php +++ /dev/null @@ -1 +0,0 @@ -original_user = \get_current_user_id(); - $this->admin = $this->make_admin(); + $this->admin = $this->make_admin( 'dws_reference_admin' ); \wp_set_current_user( $this->admin ); } @@ -171,23 +174,6 @@ private function capture_render( AdminNoticesService $service ): string { return (string) \ob_get_clean(); } - private function make_admin(): int { - $existing = \get_user_by( 'login', 'dws_reference_admin' ); - if ( $existing instanceof \WP_User ) { - return $existing->ID; - } - - $id = \wp_insert_user( - array( - 'user_login' => 'dws_reference_admin', - 'user_pass' => 'password', - 'role' => 'administrator', - ), - ); - self::assertIsInt( $id ); - return $id; - } - private function delete_user( int $id ): void { if ( ! \function_exists( 'wp_delete_user' ) ) { require_once \ABSPATH . 'wp-admin/includes/user.php'; diff --git a/packages/infrastructure/tests/Utilities/Integration/AdminNotices/AdminNoticesServiceTest.php b/packages/infrastructure/tests/Utilities/Integration/AdminNotices/AdminNoticesServiceTest.php index a44fe29..fb9326d 100644 --- a/packages/infrastructure/tests/Utilities/Integration/AdminNotices/AdminNoticesServiceTest.php +++ b/packages/infrastructure/tests/Utilities/Integration/AdminNotices/AdminNoticesServiceTest.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\Framework\Utilities\Tests\Integration\AdminNotices; +use DeepWebSolutions\Framework\Settings\Tests\Support\CreatesUsers; use DeepWebSolutions\Framework\Utilities\AdminNotices\AdminNoticesService; use DeepWebSolutions\Framework\Utilities\AdminNotices\DismissedNoticesTracker; use DeepWebSolutions\Framework\Utilities\AdminNotices\NoticeStore; @@ -23,6 +24,8 @@ #[UsesClass( OptionsStore::class )] #[UsesClass( UserMetaStore::class )] final class AdminNoticesServiceTest extends TestCase { + use CreatesUsers; + private const NOTICE_KEY = 'dws_test_service_notices'; private const DISMISS_KEY = 'dws_test_service_dismissed'; private const DISMISS_ACTION = 'dws_test_dismiss_notice'; @@ -468,27 +471,6 @@ private function capture_render( AdminNoticesService $service ): string { return (string) \ob_get_clean(); } - private function make_admin( string $login ): int { - return $this->make_user( $login, 'administrator' ); - } - - private function make_user( string $login, string $role ): int { - $existing = \get_user_by( 'login', $login ); - if ( $existing instanceof \WP_User ) { - return $existing->ID; - } - - $id = \wp_insert_user( - array( - 'user_login' => $login, - 'user_pass' => 'password', - 'role' => $role, - ), - ); - self::assertIsInt( $id ); - return $id; - } - private function delete_user( int $id ): void { if ( ! \function_exists( 'wp_delete_user' ) ) { require_once \ABSPATH . 'wp-admin/includes/user.php'; diff --git a/packages/infrastructure/tests/Utilities/Integration/AdminNotices/DependencyAdminNoticeRendererTest.php b/packages/infrastructure/tests/Utilities/Integration/AdminNotices/DependencyAdminNoticeRendererTest.php index 3f2c57f..8b5da41 100644 --- a/packages/infrastructure/tests/Utilities/Integration/AdminNotices/DependencyAdminNoticeRendererTest.php +++ b/packages/infrastructure/tests/Utilities/Integration/AdminNotices/DependencyAdminNoticeRendererTest.php @@ -3,6 +3,7 @@ namespace DeepWebSolutions\Framework\Utilities\Tests\Integration\AdminNotices; use DeepWebSolutions\Framework\Core\Conditional\ConditionalInterface; +use DeepWebSolutions\Framework\Settings\Tests\Support\CreatesUsers; use DeepWebSolutions\Framework\Utilities\AdminNotices\AdminNoticesService; use DeepWebSolutions\Framework\Utilities\AdminNotices\DependencyAdminNoticeRenderer; use DeepWebSolutions\Framework\Utilities\AdminNotices\DismissedNoticesTracker; @@ -30,6 +31,8 @@ #[UsesClass( UserMetaStore::class )] #[UsesClass( WPPluginActiveConditional::class )] final class DependencyAdminNoticeRendererTest extends TestCase { + use CreatesUsers; + private const DISMISS_KEY = 'dws_test_dep_dismissed'; private const DISMISS_ACTION = 'dws_test_dep_dismiss'; private const PERSIST_KEY = 'dws_test_dep_persistent'; @@ -253,23 +256,6 @@ private function capture_render( AdminNoticesService $service ): string { return (string) \ob_get_clean(); } - private function make_user( string $login, string $role ): int { - $existing = \get_user_by( 'login', $login ); - if ( $existing instanceof \WP_User ) { - return $existing->ID; - } - - $id = \wp_insert_user( - array( - 'user_login' => $login, - 'user_pass' => 'password', - 'role' => $role, - ), - ); - self::assertIsInt( $id ); - return $id; - } - private function delete_user( int $id ): void { if ( ! \function_exists( 'wp_delete_user' ) ) { require_once \ABSPATH . 'wp-admin/includes/user.php'; diff --git a/packages/infrastructure/tests/Utilities/Integration/AdminNotices/DismissedNoticesTrackerTest.php b/packages/infrastructure/tests/Utilities/Integration/AdminNotices/DismissedNoticesTrackerTest.php index 38a3da0..94981d8 100644 --- a/packages/infrastructure/tests/Utilities/Integration/AdminNotices/DismissedNoticesTrackerTest.php +++ b/packages/infrastructure/tests/Utilities/Integration/AdminNotices/DismissedNoticesTrackerTest.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\Framework\Utilities\Tests\Integration\AdminNotices; +use DeepWebSolutions\Framework\Settings\Tests\Support\CreatesUsers; use DeepWebSolutions\Framework\Utilities\AdminNotices\DismissedNoticesTracker; use DeepWebSolutions\Framework\Storage\UserMetaStore; use PHPUnit\Framework\Attributes\CoversClass; @@ -11,6 +12,8 @@ #[CoversClass( DismissedNoticesTracker::class )] #[UsesClass( UserMetaStore::class )] final class DismissedNoticesTrackerTest extends TestCase { + use CreatesUsers; + private const META_KEY = 'dws_test_dismissed_notices'; private int $user_a; @@ -64,23 +67,6 @@ public function test_dismissals_are_isolated_per_user(): void { self::assertFalse( $for_b->is_dismissed( 'dep_woocommerce' ) ); } - private function make_user( string $login ): int { - $existing = \get_user_by( 'login', $login ); - if ( $existing instanceof \WP_User ) { - return $existing->ID; - } - - $id = \wp_insert_user( - array( - 'user_login' => $login, - 'user_pass' => 'password', - 'role' => 'subscriber', - ), - ); - self::assertIsInt( $id ); - return $id; - } - private function delete_user( int $id ): void { if ( ! \function_exists( 'wp_delete_user' ) ) { require_once \ABSPATH . 'wp-admin/includes/user.php'; diff --git a/packages/infrastructure/tests/Utilities/Integration/AdminNotices/index.php b/packages/infrastructure/tests/Utilities/Integration/AdminNotices/index.php deleted file mode 100644 index f767346..0000000 --- a/packages/infrastructure/tests/Utilities/Integration/AdminNotices/index.php +++ /dev/null @@ -1 +0,0 @@ -get( 'absent' ) ); } - #[DataProvider( 'falsey_values' )] - public function test_get_reads_a_cached_falsey_value_as_a_hit_not_a_miss( mixed $value ): void { + /** + * @return array + */ + public static function falsy_values(): array { + return array( + 'false' => array( false ), + 'zero' => array( 0 ), + 'empty-string' => array( '' ), + 'null' => array( null ), + ); + } + + #[DataProvider( 'falsy_values' )] + public function test_get_reads_a_cached_falsy_value_as_a_hit_not_a_miss( mixed $value ): void { $cache = new ObjectCache( self::GROUP ); $cache->set( 'flag', $value ); @@ -46,16 +58,6 @@ public function test_get_reads_a_cached_falsey_value_as_a_hit_not_a_miss( mixed self::assertSame( 'default', $cache->get( 'absent', 'default' ) ); } - /** - * @return iterable - */ - public static function falsey_values(): iterable { - yield 'false' => array( false ); - yield 'null' => array( null ); - yield 'zero' => array( 0 ); - yield 'empty string' => array( '' ); - } - public function test_get_multiple_cannot_distinguish_a_stored_false_from_a_miss(): void { $cache = new ObjectCache( self::GROUP ); $cache->set( 'stored_false', false ); @@ -89,7 +91,7 @@ public function test_remember_returns_the_cached_value_without_recomputing(): vo self::assertSame( 1, $calls ); } - public function test_remember_caches_a_falsey_value_without_recomputing(): void { + public function test_remember_caches_a_falsy_value_without_recomputing(): void { $cache = new ObjectCache( self::GROUP ); $calls = 0; $compute = function () use ( &$calls ): bool { diff --git a/packages/infrastructure/tests/Utilities/Integration/Caching/index.php b/packages/infrastructure/tests/Utilities/Integration/Caching/index.php deleted file mode 100644 index f767346..0000000 --- a/packages/infrastructure/tests/Utilities/Integration/Caching/index.php +++ /dev/null @@ -1 +0,0 @@ -createMock( LoggerInterface::class ); - $logger->expects( self::once() )->method( 'error' ); + $logger = new ActionSchedulerRecordingLogger(); (void) ( new ActionSchedulerBackend( $logger ) )->schedule_recurring( 'dws_hook', 300 ); + + self::assertCount( 1, $logger->records ); + self::assertSame( LogLevel::ERROR, $logger->records[0]['level'] ); } public function test_is_ready_reports_the_injected_probe_result(): void { @@ -71,3 +74,28 @@ public function test_is_ready_reports_the_injected_probe_result(): void { self::assertFalse( ( new ActionSchedulerBackend( null, static fn (): bool => false ) )->is_ready() ); } } + +final class ActionSchedulerRecordingLogger extends AbstractLogger { + /** + * Logged records. + * + * @var list}> + */ + public array $records = array(); + + /** + * {@inheritDoc} + * + * @param mixed $level Log level. + * @param string|\Stringable $message Log message. + * @param array $context Log context. + */ + #[\Override] + public function log( $level, string|\Stringable $message, array $context = array() ): void { + $this->records[] = array( + 'level' => $level, + 'message' => $message, + 'context' => $context, + ); + } +} diff --git a/packages/infrastructure/tests/Utilities/Unit/Scheduling/Backends/index.php b/packages/infrastructure/tests/Utilities/Unit/Scheduling/Backends/index.php deleted file mode 100644 index f767346..0000000 --- a/packages/infrastructure/tests/Utilities/Unit/Scheduling/Backends/index.php +++ /dev/null @@ -1 +0,0 @@ -addToAssertionCount( 1 ); - } - } + #[DataProvider( 'invalid_prerelease_and_build_versions' )] + public function test_from_string_rejects_empty_prerelease_and_build_identifiers( string $version ): void { + $this->expectException( InvalidVersionException::class ); + Version::from_string( $version ); + } + + /** + * @return array + */ + public static function invalid_prerelease_and_build_versions(): array { + return array( + 'empty prerelease' => array( '1.0.0-' ), + 'dot-only prerelease' => array( '1.0.0-.' ), + 'empty prerelease segment' => array( '1.0.0-alpha..1' ), + 'empty build' => array( '1.0.0+' ), + 'dot-only build' => array( '1.0.0+.' ), + ); } public function test_from_parts_composes_only_supplied_components(): void { @@ -66,15 +75,24 @@ public function test_from_parts_throws_on_patch_without_minor(): void { Version::from_parts( 2, null, 0 ); } - public function test_from_parts_throws_on_invalid_composed_parts(): void { - foreach ( array( array( -2 ), array( 2, 0, 0, 'beta..1' ), array( 2, 0, 0, null, '.' ) ) as $parts ) { - try { - Version::from_parts( ...$parts ); - self::fail( 'Expected InvalidVersionException for parts: ' . \json_encode( $parts ) ); - } catch ( InvalidVersionException ) { - $this->addToAssertionCount( 1 ); - } - } + /** + * @param array{0: int, 1?: int|null, 2?: int|null, 3?: string|null, 4?: string|null} $parts + */ + #[DataProvider( 'invalid_version_parts' )] + public function test_from_parts_throws_on_invalid_composed_parts( array $parts ): void { + $this->expectException( InvalidVersionException::class ); + Version::from_parts( ...$parts ); + } + + /** + * @return array + */ + public static function invalid_version_parts(): array { + return array( + 'negative major' => array( array( -2 ) ), + 'empty prerelease segment' => array( array( 2, 0, 0, 'beta..1' ) ), + 'dot-only build' => array( array( 2, 0, 0, null, '.' ) ), + ); } public function test_is_greater_than(): void { diff --git a/packages/woocommerce/tests/Fixtures/InMemoryObjectMetaRepository.php b/packages/woocommerce/tests/Fixtures/InMemoryObjectMetaRepository.php deleted file mode 100644 index a6381c7..0000000 --- a/packages/woocommerce/tests/Fixtures/InMemoryObjectMetaRepository.php +++ /dev/null @@ -1,48 +0,0 @@ -> - */ - private array $data = array(); - - public function get( int $object_id, string $meta_key, mixed $default_value = null ): mixed { - return \array_key_exists( $meta_key, $this->data[ $object_id ] ?? array() ) - ? $this->data[ $object_id ][ $meta_key ] - : $default_value; - } - - public function set( int $object_id, string $meta_key, mixed $value ): void { - $this->data[ $object_id ][ $meta_key ] = $value; - } - - public function has( int $object_id, string $meta_key ): bool { - return \array_key_exists( $meta_key, $this->data[ $object_id ] ?? array() ); - } - - public function delete( int $object_id, string $meta_key ): bool { - if ( ! $this->has( $object_id, $meta_key ) ) { - return false; - } - unset( $this->data[ $object_id ][ $meta_key ] ); - - return true; - } - - public function apply( int $object_id, array $sets, array $deletes ): void { - foreach ( $sets as $meta_key => $value ) { - $this->set( $object_id, (string) $meta_key, $value ); - } - foreach ( $deletes as $meta_key ) { - $this->delete( $object_id, $meta_key ); - } - } -} diff --git a/packages/woocommerce/tests/Integration/DescriptorBackedWooCommerceSettingsPageTest.php b/packages/woocommerce/tests/Integration/Backend/DescriptorBackedWooCommerceSettingsPageTest.php similarity index 99% rename from packages/woocommerce/tests/Integration/DescriptorBackedWooCommerceSettingsPageTest.php rename to packages/woocommerce/tests/Integration/Backend/DescriptorBackedWooCommerceSettingsPageTest.php index d666679..2067dea 100644 --- a/packages/woocommerce/tests/Integration/DescriptorBackedWooCommerceSettingsPageTest.php +++ b/packages/woocommerce/tests/Integration/Backend/DescriptorBackedWooCommerceSettingsPageTest.php @@ -1,6 +1,6 @@ - */ - private array $saved_hooks = array(); - protected function setUp(): void { parent::setUp(); - if ( ! \function_exists( 'wc_create_order' ) ) { - self::markTestSkipped( 'WooCommerce is not active.' ); - } - require_once ABSPATH . 'wp-admin/includes/template.php'; require_once ABSPATH . 'wp-admin/includes/class-wp-screen.php'; require_once ABSPATH . 'wp-admin/includes/screen.php'; @@ -63,12 +59,6 @@ protected function setUp(): void { \wp_set_current_user( 1 ); - global $wp_filter; - foreach ( self::ISOLATED_HOOKS as $hook ) { - $this->saved_hooks[ $hook ] = $wp_filter[ $hook ] ?? null; - unset( $wp_filter[ $hook ] ); - } - $GLOBALS['wp_meta_boxes'] = array(); $_POST = array(); @@ -88,15 +78,6 @@ protected function tearDown(): void { $_POST = array(); $GLOBALS['current_screen'] = null; - global $wp_filter; - foreach ( $this->saved_hooks as $hook => $saved ) { - if ( null !== $saved ) { - $wp_filter[ $hook ] = $saved; - } else { - unset( $wp_filter[ $hook ] ); - } - } - parent::tearDown(); } @@ -176,8 +157,8 @@ public function test_a_bespoke_render_and_save_group_emits_the_nonce_and_saves() $saved_for = $object_id; }, ); - $store = new OrderFieldSurface(); - $store->register( $group, $this->placement() ); + $surface = new OrderFieldSurface(); + $surface->register( $group, $this->placement() ); \do_action( "add_meta_boxes_$screen", \wc_get_order( $this->order_id ) ); $html = $this->render_box( $screen ); @@ -190,9 +171,9 @@ public function test_a_bespoke_render_and_save_group_emits_the_nonce_and_saves() } public function test_a_truthy_submission_is_stored_and_a_falsy_one_deletes_the_meta(): void { - $repo = new OrderMetaRepository(); - $store = new OrderFieldSurface(); - $store->register( $this->group(), $this->placement() ); + $repo = new OrderMetaRepository(); + $surface = new OrderFieldSurface(); + $surface->register( $this->group(), $this->placement() ); $_POST = array( $this->nonce_name() => $this->nonce(), @@ -207,9 +188,9 @@ public function test_a_truthy_submission_is_stored_and_a_falsy_one_deletes_the_m } public function test_crud_addresses_the_same_meta_key_the_form_save_writes(): void { - $store = new OrderFieldSurface(); - $group = $this->group_with( new SettingsField( id: 'note', type: 'text', label: 'Note' ) ); - $store->register( $group, $this->placement() ); + $surface = new OrderFieldSurface(); + $group = $this->group_with( new SettingsField( id: 'note', type: 'text', label: 'Note' ) ); + $surface->register( $group, $this->placement() ); $_POST = array( $this->nonce_name() => $this->nonce(), @@ -217,23 +198,23 @@ public function test_crud_addresses_the_same_meta_key_the_form_save_writes(): vo ); \do_action( 'woocommerce_process_shop_order_meta', $this->order_id ); - self::assertTrue( $store->has( $group, $this->order_id, 'note' ) ); - self::assertSame( 'hi', $store->get( $group, $this->order_id, 'note' ) ); + self::assertTrue( $surface->has( $group, $this->order_id, 'note' ) ); + self::assertSame( 'hi', $surface->get( $group, $this->order_id, 'note' ) ); - $store->set( $group, $this->order_id, 'note', 'bye' ); + $surface->set( $group, $this->order_id, 'note', 'bye' ); $order = \wc_get_order( $this->order_id ); \assert( $order instanceof \WC_Abstract_Order ); self::assertSame( 'bye', $order->get_meta( 'note', true ) ); - self::assertTrue( $store->delete( $group, $this->order_id, 'note' ) ); + self::assertTrue( $surface->delete( $group, $this->order_id, 'note' ) ); self::assertFalse( ( new OrderMetaRepository() )->has( $this->order_id, 'note' ) ); - self::assertSame( array( 'note' ), $store->meta_keys( $group ) ); + self::assertSame( array( 'note' ), $surface->meta_keys( $group ) ); } public function test_a_zero_value_is_stored_not_revoked(): void { - $repo = new OrderMetaRepository(); - $store = new OrderFieldSurface(); - $store->register( $this->group_with( new SettingsField( id: 'note', type: 'text', label: 'Note' ) ), $this->placement() ); + $repo = new OrderMetaRepository(); + $surface = new OrderFieldSurface(); + $surface->register( $this->group_with( new SettingsField( id: 'note', type: 'text', label: 'Note' ) ), $this->placement() ); $_POST = array( $this->nonce_name() => $this->nonce(), @@ -246,10 +227,10 @@ public function test_a_zero_value_is_stored_not_revoked(): void { } public function test_save_applies_the_builtin_default_sanitizer(): void { - $repo = new OrderMetaRepository(); - $store = new OrderFieldSurface(); - $raw = 'x'; - $store->register( $this->group_with( new SettingsField( id: 'note', type: 'text', label: 'Note' ) ), $this->placement() ); + $repo = new OrderMetaRepository(); + $surface = new OrderFieldSurface(); + $raw = 'x'; + $surface->register( $this->group_with( new SettingsField( id: 'note', type: 'text', label: 'Note' ) ), $this->placement() ); $_POST = array( $this->nonce_name() => $this->nonce(), @@ -261,9 +242,9 @@ public function test_save_applies_the_builtin_default_sanitizer(): void { } public function test_save_preserves_an_existing_value_when_a_present_submission_is_invalid(): void { - $repo = new OrderMetaRepository(); - $store = new OrderFieldSurface(); - $store->register( + $repo = new OrderMetaRepository(); + $surface = new OrderFieldSurface(); + $surface->register( $this->group_with( new SettingsField( id: 'status', type: 'select', label: 'Status', options: array( 'locked' => 'Locked' ) ), ), @@ -281,9 +262,9 @@ public function test_save_preserves_an_existing_value_when_a_present_submission_ } public function test_save_is_skipped_without_a_valid_nonce(): void { - $repo = new OrderMetaRepository(); - $store = new OrderFieldSurface(); - $store->register( $this->group(), $this->placement() ); + $repo = new OrderMetaRepository(); + $surface = new OrderFieldSurface(); + $surface->register( $this->group(), $this->placement() ); $_POST = array( self::GROUP_ID => array( 'unlocked' => '1' ) ); \do_action( 'woocommerce_process_shop_order_meta', $this->order_id ); @@ -304,9 +285,9 @@ public function test_register_also_registers_on_the_restricted_hpos_screen(): vo } public function test_a_field_meta_key_overrides_the_id_for_storage(): void { - $repo = new OrderMetaRepository(); - $store = new OrderFieldSurface(); - $store->register( + $repo = new OrderMetaRepository(); + $surface = new OrderFieldSurface(); + $surface->register( $this->group_with( new SettingsField( id: 'unlocked', type: 'checkbox', label: 'Unlocked', meta_key: '_lpm_unlocked' ) ), $this->placement(), ); @@ -332,9 +313,9 @@ public function test_save_is_skipped_for_a_user_without_the_order_capability(): \assert( \is_int( $subscriber ) ); \wp_set_current_user( $subscriber ); - $repo = new OrderMetaRepository(); - $store = new OrderFieldSurface(); - $store->register( $this->group(), $this->placement() ); + $repo = new OrderMetaRepository(); + $surface = new OrderFieldSurface(); + $surface->register( $this->group(), $this->placement() ); $_POST = array( $this->nonce_name() => $this->nonce(), @@ -349,8 +330,8 @@ public function test_save_is_skipped_for_a_user_without_the_order_capability(): public function test_a_configured_box_capability_overrides_the_default(): void { $repo = new OrderMetaRepository(); $placement = new MetaBoxPlacement( screen: 'shop_order', context: 'side', priority: 'default', capability: 'dws_nonexistent_cap' ); - $store = new OrderFieldSurface(); - $store->register( $this->group(), $placement ); + $surface = new OrderFieldSurface(); + $surface->register( $this->group(), $placement ); // The administrator passes the default order-edit gate but lacks the configured capability, so the save is refused. $_POST = array( @@ -385,9 +366,9 @@ public function test_the_box_is_not_added_for_a_user_who_cannot_edit_the_order() } public function test_a_multi_field_save_persists_the_order_once(): void { - $repo = new OrderMetaRepository(); - $store = new OrderFieldSurface(); - $store->register( + $repo = new OrderMetaRepository(); + $surface = new OrderFieldSurface(); + $surface->register( new FieldGroup( id: self::GROUP_ID, title: 'Multi', @@ -422,8 +403,8 @@ static function () use ( &$saves ): void { } public function test_a_no_op_save_does_not_persist_the_order(): void { - $store = new OrderFieldSurface(); - $store->register( $this->group(), $this->placement() ); + $surface = new OrderFieldSurface(); + $surface->register( $this->group(), $this->placement() ); $saves = 0; \add_action( @@ -441,8 +422,8 @@ static function () use ( &$saves ): void { } public function test_a_duplicate_field_id_in_a_group_is_rejected(): void { - $store = new OrderFieldSurface(); - $store->register( + $surface = new OrderFieldSurface(); + $surface->register( new FieldGroup( id: self::GROUP_ID, title: 'Dup', @@ -504,11 +485,11 @@ public function test_a_custom_field_type_renders_and_saves_through_the_order_sur ); $group = $this->group_with( new SettingsField( id: 'home_page', type: 'single_select_page', label: 'Home Page' ) ); $repo = new OrderMetaRepository(); - $store = new OrderFieldSurface( + $surface = new OrderFieldSurface( renderer: new FieldRenderer( custom_types: $custom_types ), processor: new FieldProcessor( custom_types: $custom_types ), ); - $store->register( $group, $this->placement() ); + $surface->register( $group, $this->placement() ); \do_action( "add_meta_boxes_$screen", \wc_get_order( $this->order_id ) ); $html = $this->render_box( $screen ); @@ -528,9 +509,9 @@ public function test_clearing_a_field_with_a_default_revokes_it_without_restorin $screen = $this->order_screen(); \set_current_screen( $screen ); - $repo = new OrderMetaRepository(); - $store = new OrderFieldSurface(); - $store->register( + $repo = new OrderMetaRepository(); + $surface = new OrderFieldSurface(); + $surface->register( $this->group_with( new SettingsField( id: 'note', type: 'text', label: 'Note', default_value: 'preset' ) ), $this->placement(), ); diff --git a/packages/woocommerce/tests/Integration/OrderData/OrderMetaRepositoryTest.php b/packages/woocommerce/tests/Integration/OrderData/OrderMetaRepositoryTest.php index 8bb10d6..42cf6d1 100644 --- a/packages/woocommerce/tests/Integration/OrderData/OrderMetaRepositoryTest.php +++ b/packages/woocommerce/tests/Integration/OrderData/OrderMetaRepositoryTest.php @@ -2,35 +2,25 @@ namespace DeepWebSolutions\Framework\WooCommerce\Tests\Integration\OrderData; +use DeepWebSolutions\Framework\Settings\Tests\Support\IsolatesHooks; use DeepWebSolutions\Framework\Storage\ObjectMeta\ObjectMetaRepositoryInterface; use DeepWebSolutions\Framework\WooCommerce\OrderData\OrderMetaRepository; +use DeepWebSolutions\Framework\WooCommerce\Tests\Support\RequiresWooCommerce; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; #[CoversClass( OrderMetaRepository::class )] final class OrderMetaRepositoryTest extends TestCase { - private const ISOLATED_HOOKS = array( 'woocommerce_after_order_object_save' ); + use IsolatesHooks; + use RequiresWooCommerce; - private int $order_id = 0; + protected const ISOLATED_HOOKS = array( 'woocommerce_after_order_object_save' ); - /** - * @var array - */ - private array $saved_hooks = array(); + private int $order_id = 0; protected function setUp(): void { parent::setUp(); - if ( ! \function_exists( 'wc_create_order' ) ) { - self::markTestSkipped( 'WooCommerce is not active.' ); - } - - global $wp_filter; - foreach ( self::ISOLATED_HOOKS as $hook ) { - $this->saved_hooks[ $hook ] = $wp_filter[ $hook ] ?? null; - unset( $wp_filter[ $hook ] ); - } - $order = \wc_create_order(); \assert( $order instanceof \WC_Order ); $this->order_id = $order->get_id(); @@ -42,15 +32,6 @@ protected function tearDown(): void { $order->delete( true ); } - global $wp_filter; - foreach ( $this->saved_hooks as $hook => $saved ) { - if ( null !== $saved ) { - $wp_filter[ $hook ] = $saved; - } else { - unset( $wp_filter[ $hook ] ); - } - } - parent::tearDown(); } diff --git a/packages/woocommerce/tests/Integration/OrderData/index.php b/packages/woocommerce/tests/Integration/OrderData/index.php deleted file mode 100644 index f767346..0000000 --- a/packages/woocommerce/tests/Integration/OrderData/index.php +++ /dev/null @@ -1 +0,0 @@ -set_name( 'Probe' ); $this->product_id = $product->save(); diff --git a/packages/woocommerce/tests/Integration/ProductDataFieldSurfaceTest.php b/packages/woocommerce/tests/Integration/ProductData/ProductDataFieldSurfaceTest.php similarity index 76% rename from packages/woocommerce/tests/Integration/ProductDataFieldSurfaceTest.php rename to packages/woocommerce/tests/Integration/ProductData/ProductDataFieldSurfaceTest.php index 20bf8c1..fab176e 100644 --- a/packages/woocommerce/tests/Integration/ProductDataFieldSurfaceTest.php +++ b/packages/woocommerce/tests/Integration/ProductData/ProductDataFieldSurfaceTest.php @@ -1,22 +1,29 @@ - */ - private array $saved_hooks = array(); - protected function setUp(): void { parent::setUp(); - if ( ! \function_exists( 'wc_get_product' ) ) { - self::markTestSkipped( 'WooCommerce is not active.' ); - } - if ( ! \function_exists( 'woocommerce_wp_text_input' ) ) { - require_once WP_PLUGIN_DIR . '/woocommerce/includes/admin/wc-meta-box-functions.php'; - } - \wp_set_current_user( 1 ); - // Isolate this store's hooks — including the two global default-injection filters — so a registered - // tab cannot leak its callbacks into other tests; restore the originals in tearDown. - global $wp_filter; - foreach ( self::ISOLATED_HOOKS as $hook ) { - $this->saved_hooks[ $hook ] = $wp_filter[ $hook ] ?? null; - unset( $wp_filter[ $hook ] ); - } - $_POST = array(); $product = new \WC_Product_Simple(); @@ -68,24 +55,13 @@ protected function tearDown(): void { $GLOBALS['thepostid'] = null; $GLOBALS['post'] = null; - global $wp_filter; - foreach ( $this->saved_hooks as $hook => $saved ) { - if ( null !== $saved ) { - $wp_filter[ $hook ] = $saved; - } else { - unset( $wp_filter[ $hook ] ); - } - } - parent::tearDown(); } - // region REGISTRATION + GATING - public function test_registers_the_tab_for_a_supported_product(): void { $this->set_current_product( $this->product_id ); - $store = new ProductDataFieldSurface(); - $store->register_tab( $this->tab() ); + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab() ); $tabs = \apply_filters( 'woocommerce_product_data_tabs', array() ); @@ -98,8 +74,8 @@ public function test_registers_the_tab_for_a_supported_product(): void { public function test_does_not_register_the_tab_for_an_unsupported_product(): void { $this->set_current_product( $this->product_id ); - $store = new ProductDataFieldSurface(); - $store->register_tab( $this->tab( supports: static fn ( int $product_id ): bool => false ) ); + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab( supports: static fn ( int $product_id ): bool => false ) ); $tabs = \apply_filters( 'woocommerce_product_data_tabs', array() ); @@ -108,8 +84,8 @@ public function test_does_not_register_the_tab_for_an_unsupported_product(): voi public function test_dynamic_classes_closure_contributes_product_type_classes(): void { $this->set_current_product( $this->product_id ); - $store = new ProductDataFieldSurface(); - $store->register_tab( $this->tab( classes: static fn ( int $product_id ): array => array( 'show_if_simple' ) ) ); + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab( classes: static fn ( int $product_id ): array => array( 'show_if_simple' ) ) ); $tabs = \apply_filters( 'woocommerce_product_data_tabs', array() ); @@ -117,14 +93,10 @@ public function test_dynamic_classes_closure_contributes_product_type_classes(): self::assertContains( 'dws_warranty_tab', $tabs['dws_warranty']['class'] ); } - // endregion - - // region RENDER - public function test_renders_the_panel_with_each_field_control(): void { $this->set_current_product( $this->product_id ); - $store = new ProductDataFieldSurface(); - $store->register_tab( $this->tab() ); + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab() ); \ob_start(); \do_action( 'woocommerce_product_data_panels' ); @@ -137,8 +109,8 @@ public function test_renders_the_panel_with_each_field_control(): void { public function test_does_not_render_the_panel_for_an_unsupported_product(): void { $this->set_current_product( $this->product_id ); - $store = new ProductDataFieldSurface(); - $store->register_tab( $this->tab( supports: static fn ( int $product_id ): bool => false ) ); + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab( supports: static fn ( int $product_id ): bool => false ) ); \ob_start(); \do_action( 'woocommerce_product_data_panels' ); @@ -147,23 +119,19 @@ public function test_does_not_render_the_panel_for_an_unsupported_product(): voi self::assertStringNotContainsString( 'dws_warranty_product_data', $html ); } - // endregion - - // region SAVE - public function test_save_persists_submitted_values(): void { - $store = new ProductDataFieldSurface(); - $store->register_tab( $this->tab() ); + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab() ); $_POST = array( '_dws-wrwc_general_warranty-type' => 'addon' ); \do_action( 'woocommerce_process_product_meta', $this->product_id ); - self::assertSame( 'addon', $store->get( 'general', $this->product_id, 'warranty-type' ) ); + self::assertSame( 'addon', $surface->get( 'general', $this->product_id, 'warranty-type' ) ); } public function test_save_applies_the_field_sanitizer(): void { - $store = new ProductDataFieldSurface(); - $store->register_tab( + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab_with( new SettingsField( id: 'code', type: 'text', label: 'Code', sanitize: static fn ( mixed $v ): string => \strtoupper( (string) $v ) ), ), @@ -172,13 +140,13 @@ public function test_save_applies_the_field_sanitizer(): void { $_POST = array( '_dws-wrwc_general_code' => 'abc' ); \do_action( 'woocommerce_process_product_meta', $this->product_id ); - self::assertSame( 'ABC', $store->get( 'general', $this->product_id, 'code' ) ); + self::assertSame( 'ABC', $surface->get( 'general', $this->product_id, 'code' ) ); } public function test_save_applies_the_builtin_default_sanitizer(): void { - $store = new ProductDataFieldSurface(); - $raw = 'x'; - $store->register_tab( + $surface = new ProductDataFieldSurface(); + $raw = 'x'; + $surface->register_tab( $this->tab_with( new SettingsField( id: 'code', type: 'text', label: 'Code' ), ), @@ -187,12 +155,12 @@ public function test_save_applies_the_builtin_default_sanitizer(): void { $_POST = array( '_dws-wrwc_general_code' => $raw ); \do_action( 'woocommerce_process_product_meta', $this->product_id ); - self::assertSame( \sanitize_text_field( $raw ), $store->get( 'general', $this->product_id, 'code' ) ); + self::assertSame( \sanitize_text_field( $raw ), $surface->get( 'general', $this->product_id, 'code' ) ); } public function test_save_preserves_an_existing_value_when_a_present_submission_is_invalid(): void { - $store = new ProductDataFieldSurface(); - $store->register_tab( + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab_with( new SettingsField( id: 'warranty-type', @@ -205,17 +173,17 @@ public function test_save_preserves_an_existing_value_when_a_present_submission_ ), ), ); - $store->set( 'general', $this->product_id, 'warranty-type', 'global' ); + $surface->set( 'general', $this->product_id, 'warranty-type', 'global' ); $_POST = array( '_dws-wrwc_general_warranty-type' => 'tampered' ); \do_action( 'woocommerce_process_product_meta', $this->product_id ); - self::assertSame( 'global', $store->get( 'general', $this->product_id, 'warranty-type' ) ); + self::assertSame( 'global', $surface->get( 'general', $this->product_id, 'warranty-type' ) ); } public function test_save_is_skipped_for_an_unsupported_product(): void { - $store = new ProductDataFieldSurface(); - $store->register_tab( $this->tab( supports: static fn ( int $product_id ): bool => false ) ); + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab( supports: static fn ( int $product_id ): bool => false ) ); $_POST = array( '_dws-wrwc_general_warranty-type' => 'addon' ); \do_action( 'woocommerce_process_product_meta', $this->product_id ); @@ -224,8 +192,8 @@ public function test_save_is_skipped_for_an_unsupported_product(): void { } public function test_save_persists_a_multiselect_selection(): void { - $store = new ProductDataFieldSurface(); - $store->register_tab( + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab_with( new SettingsField( id: 'locations', @@ -243,12 +211,12 @@ public function test_save_persists_a_multiselect_selection(): void { $_POST = array( '_dws-wrwc_general_locations' => array( 'cart', 'email' ) ); \do_action( 'woocommerce_process_product_meta', $this->product_id ); - self::assertEqualsCanonicalizing( array( 'cart', 'email' ), $store->get( 'general', $this->product_id, 'locations' ) ); + self::assertEqualsCanonicalizing( array( 'cart', 'email' ), $surface->get( 'general', $this->product_id, 'locations' ) ); } public function test_save_preserves_a_checkbox_when_validation_rejects_the_submission(): void { - $store = new ProductDataFieldSurface(); - $store->register_tab( + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab_with( // A validator that rejects 'yes' makes the checked submission invalid. new SettingsField( id: 'flag', type: 'checkbox', label: 'Flag', validate: static fn ( mixed $v ): bool => 'yes' !== $v ), @@ -256,17 +224,17 @@ public function test_save_preserves_a_checkbox_when_validation_rejects_the_submi ); // Prior value differs from the rejected submission so accept-and-store would land 'yes', not the // preserved 'no' — the assertion fails unless the rejection-preserve branch actually fires. - $store->set( 'general', $this->product_id, 'flag', false ); + $surface->set( 'general', $this->product_id, 'flag', false ); $_POST = array( '_dws-wrwc_general_flag' => 'yes' ); \do_action( 'woocommerce_process_product_meta', $this->product_id ); - self::assertSame( 'no', $store->get( 'general', $this->product_id, 'flag' ) ); + self::assertSame( 'no', $surface->get( 'general', $this->product_id, 'flag' ) ); } public function test_save_runs_sanitize_and_validate_on_a_custom_field(): void { - $store = new ProductDataFieldSurface(); - $store->register_tab( + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab_with( new SettingsField( id: 'span', @@ -285,15 +253,15 @@ public function test_save_runs_sanitize_and_validate_on_a_custom_field(): void { // Sanitize trims to 'reject'; the validator rejects it, so the field clears to the sanitized empty // (sanitize of an absent submission) rather than the descriptor default. - self::assertSame( '', $store->get( 'general', $this->product_id, 'span' ) ); + self::assertSame( '', $surface->get( 'general', $this->product_id, 'span' ) ); } public function test_a_custom_field_without_a_renderer_is_rejected_at_registration(): void { - $store = new ProductDataFieldSurface(); + $surface = new ProductDataFieldSurface(); $this->expectException( InvalidProductDataTabException::class ); - $store->register_tab( + $surface->register_tab( $this->tab_with( new SettingsField( id: 'span', type: 'dws_custom', label: 'Span', sanitize: static fn ( mixed $v ): string => (string) $v ), ), @@ -301,11 +269,11 @@ public function test_a_custom_field_without_a_renderer_is_rejected_at_registrati } public function test_a_custom_field_without_a_sanitize_is_rejected_at_registration(): void { - $store = new ProductDataFieldSurface(); + $surface = new ProductDataFieldSurface(); $this->expectException( InvalidProductDataTabException::class ); - $store->register_tab( + $surface->register_tab( $this->tab_with( new SettingsField( id: 'span', type: 'dws_custom', label: 'Span' ), array( 'dws_custom' => $this->noop_renderer() ), @@ -314,8 +282,8 @@ public function test_a_custom_field_without_a_sanitize_is_rejected_at_registrati } public function test_an_absent_custom_field_stores_the_sanitized_empty_not_a_null_or_default(): void { - $store = new ProductDataFieldSurface(); - $store->register_tab( + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab_with( new SettingsField( id: 'span', @@ -333,13 +301,13 @@ public function test_an_absent_custom_field_stores_the_sanitized_empty_not_a_nul $_POST = array(); \do_action( 'woocommerce_process_product_meta', $this->product_id ); - self::assertSame( 'sanitized:', $store->get( 'general', $this->product_id, 'span' ) ); + self::assertSame( 'sanitized:', $surface->get( 'general', $this->product_id, 'span' ) ); } public function test_a_non_scalar_custom_field_submission_is_coerced_before_sanitize(): void { - $seen = null; - $store = new ProductDataFieldSurface(); - $store->register_tab( + $seen = null; + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab_with( new SettingsField( id: 'span', @@ -359,12 +327,12 @@ public function test_a_non_scalar_custom_field_submission_is_coerced_before_sani \do_action( 'woocommerce_process_product_meta', $this->product_id ); self::assertSame( '', $seen ); - self::assertSame( 'sanitized:', $store->get( 'general', $this->product_id, 'span' ) ); + self::assertSame( 'sanitized:', $surface->get( 'general', $this->product_id, 'span' ) ); } public function test_the_before_save_hook_strips_an_injected_default(): void { - $store = new ProductDataFieldSurface(); - $store->register_tab( $this->tab() ); + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab() ); // A fresh read injects the field defaults into the product's meta. \clean_post_cache( $this->product_id ); @@ -380,8 +348,8 @@ public function test_the_before_save_hook_strips_an_injected_default(): void { } public function test_the_before_save_hook_keeps_a_set_value(): void { - $store = new ProductDataFieldSurface(); - $store->register_tab( $this->tab() ); + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab() ); \clean_post_cache( $this->product_id ); $product = \wc_get_product( $this->product_id ); @@ -394,20 +362,16 @@ public function test_the_before_save_hook_keeps_a_set_value(): void { self::assertSame( 'addon', $product->get_meta( '_dws-wrwc_general_warranty-type', true ) ); } - // endregion - - // region DEFAULT INJECTION - public function test_a_new_product_reads_the_default_through_get_post_meta(): void { - $store = new ProductDataFieldSurface(); - $store->register_tab( $this->tab() ); + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab() ); self::assertSame( 'global', \get_post_meta( $this->product_id, '_dws-wrwc_general_warranty-type', true ) ); } public function test_a_new_product_reads_one_list_default_row_through_non_single_get_post_meta(): void { - $store = new ProductDataFieldSurface(); - $store->register_tab( + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab_with( new SettingsField( id: 'locations', @@ -427,8 +391,8 @@ public function test_a_new_product_reads_one_list_default_row_through_non_single } public function test_a_new_product_reads_the_default_through_the_wc_product(): void { - $store = new ProductDataFieldSurface(); - $store->register_tab( $this->tab() ); + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab() ); \clean_post_cache( $this->product_id ); $fresh = \wc_get_product( $this->product_id ); @@ -439,8 +403,8 @@ public function test_a_new_product_reads_the_default_through_the_wc_product(): v public function test_a_pre_existing_product_renders_the_default_without_a_stored_row(): void { // The product was created and saved in setUp before the tab existed — the regression-prone case. - $store = new ProductDataFieldSurface(); - $store->register_tab( $this->tab() ); + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab() ); // Both read paths return the descriptor default… self::assertSame( 'global', \get_post_meta( $this->product_id, '_dws-wrwc_general_warranty-type', true ) ); @@ -454,15 +418,15 @@ public function test_a_pre_existing_product_renders_the_default_without_a_stored } public function test_default_injection_is_scoped_to_supported_products(): void { - $store = new ProductDataFieldSurface(); - $store->register_tab( $this->tab( supports: static fn ( int $product_id ): bool => false ) ); + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab( supports: static fn ( int $product_id ): bool => false ) ); self::assertSame( '', \get_post_meta( $this->product_id, '_dws-wrwc_general_warranty-type', true ) ); } public function test_after_save_the_real_value_replaces_the_default(): void { - $store = new ProductDataFieldSurface(); - $store->register_tab( $this->tab() ); + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab() ); $_POST = array( '_dws-wrwc_general_warranty-type' => 'addon' ); \do_action( 'woocommerce_process_product_meta', $this->product_id ); @@ -472,10 +436,10 @@ public function test_after_save_the_real_value_replaces_the_default(): void { } public function test_default_injection_does_not_leak_into_non_products(): void { - $store = new ProductDataFieldSurface(); + $surface = new ProductDataFieldSurface(); // A permissive gate must still be floored by product-existence: the global default filters must not // inject a product field's default into an unrelated post that happens to read the same meta key. - $store->register_tab( $this->tab( supports: static fn ( int $product_id ): bool => true ) ); + $surface->register_tab( $this->tab( supports: static fn ( int $product_id ): bool => true ) ); $post_id = \wp_insert_post( array( @@ -493,8 +457,8 @@ public function test_default_injection_does_not_leak_into_non_products(): void { public function test_bulk_default_injection_skips_the_consumer_gate_when_no_owned_key_is_missing(): void { $gate_calls = 0; - $store = new ProductDataFieldSurface(); - $store->register_tab( + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab( supports: static function ( int $product_id ) use ( &$gate_calls ): bool { ++$gate_calls; @@ -515,14 +479,10 @@ public function test_bulk_default_injection_skips_the_consumer_gate_when_no_owne self::assertSame( 0, $gate_calls ); } - // endregion - - // region CAPABILITY - public function test_a_field_the_user_cannot_edit_is_not_rendered(): void { $this->set_current_product( $this->product_id ); - $store = new ProductDataFieldSurface(); - $store->register_tab( + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab_with( new SettingsField( id: 'secret', type: 'text', label: 'Secret', capability: 'dws_protected_cap' ), ), @@ -536,8 +496,8 @@ public function test_a_field_the_user_cannot_edit_is_not_rendered(): void { } public function test_a_field_the_user_cannot_edit_is_not_saved(): void { - $store = new ProductDataFieldSurface(); - $store->register_tab( + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab_with( new SettingsField( id: 'secret', type: 'text', label: 'Secret', capability: 'dws_protected_cap' ), ), @@ -550,8 +510,8 @@ public function test_a_field_the_user_cannot_edit_is_not_saved(): void { } public function test_save_does_not_freeze_an_injected_default_for_a_field_the_user_cannot_edit(): void { - $store = new ProductDataFieldSurface(); - $store->register_tab( + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab_with( new SettingsField( id: 'secret', type: 'text', label: 'Secret', default_value: 'fallback', capability: 'dws_protected_cap' ), ), @@ -568,14 +528,10 @@ public function test_save_does_not_freeze_an_injected_default_for_a_field_the_us self::assertFalse( \metadata_exists( 'post', $this->product_id, '_dws-wrwc_general_secret' ) ); } - // endregion - - // region CUSTOM FIELD SEAM - public function test_a_custom_field_type_renders_via_its_renderer_and_saves_via_sanitize(): void { $this->set_current_product( $this->product_id ); - $store = new ProductDataFieldSurface(); - $store->register_tab( + $surface = new ProductDataFieldSurface(); + $surface->register_tab( new ProductDataTab( slug: 'dws_warranty', label: 'Warranty', @@ -609,12 +565,12 @@ public function test_a_custom_field_type_renders_via_its_renderer_and_saves_via_ $_POST = array( '_dws-wrwc_general_span' => '12' ); \do_action( 'woocommerce_process_product_meta', $this->product_id ); - self::assertSame( array( 'raw' => '12' ), $store->get( 'general', $this->product_id, 'span' ) ); + self::assertSame( array( 'raw' => '12' ), $surface->get( 'general', $this->product_id, 'span' ) ); } public function test_a_renderer_registered_custom_type_counts_as_wired_and_renders_through_the_bridge(): void { $this->set_current_product( $this->product_id ); - $store = new ProductDataFieldSurface( + $surface = new ProductDataFieldSurface( new ProductDataFieldRenderer( custom_types: array( 'dws_rds' => new CustomFieldType( @@ -626,7 +582,7 @@ public function test_a_renderer_registered_custom_type_counts_as_wired_and_rende ); // No tab-level renderer: the renderer-registered CustomFieldType satisfies the render requirement. - $store->register_tab( + $surface->register_tab( $this->tab_with( new SettingsField( id: 'span', type: 'dws_rds', label: 'Span', sanitize: static fn ( mixed $v ): string => (string) $v ), ), @@ -642,7 +598,7 @@ public function test_a_renderer_registered_custom_type_counts_as_wired_and_rende public function test_a_tab_level_custom_renderer_wins_over_a_renderer_registered_custom_type(): void { $this->set_current_product( $this->product_id ); - $store = new ProductDataFieldSurface( + $surface = new ProductDataFieldSurface( new ProductDataFieldRenderer( custom_types: array( 'dws_rds' => new CustomFieldType( 'dws_rds', static fn (): string => '' ), @@ -650,7 +606,7 @@ public function test_a_tab_level_custom_renderer_wins_over_a_renderer_registered ), ); - $store->register_tab( + $surface->register_tab( $this->tab_with( new SettingsField( id: 'span', type: 'dws_rds', label: 'Span', sanitize: static fn ( mixed $v ): string => (string) $v ), array( @@ -670,7 +626,7 @@ public function test_a_tab_level_custom_renderer_wins_over_a_renderer_registered } public function test_a_renderer_registered_custom_type_still_requires_a_sanitize_callback(): void { - $store = new ProductDataFieldSurface( + $surface = new ProductDataFieldSurface( new ProductDataFieldRenderer( custom_types: array( 'dws_rds' => new CustomFieldType( 'dws_rds', static fn (): string => '' ), @@ -681,74 +637,70 @@ public function test_a_renderer_registered_custom_type_still_requires_a_sanitize $this->expectException( InvalidProductDataTabException::class ); // The custom-type registry is render-only, so a covered type without a field sanitize still fails. - $store->register_tab( + $surface->register_tab( $this->tab_with( new SettingsField( id: 'span', type: 'dws_rds', label: 'Span' ), ), ); } - // endregion - - // region CRUD + UNINSTALL SURFACE - public function test_crud_round_trips_by_section_and_field(): void { - $store = new ProductDataFieldSurface(); - $store->register_tab( $this->tab() ); + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab() ); - self::assertFalse( $store->has( 'general', $this->product_id, 'code' ) ); - self::assertFalse( $store->delete( 'general', $this->product_id, 'code' ) ); + self::assertFalse( $surface->has( 'general', $this->product_id, 'code' ) ); + self::assertFalse( $surface->delete( 'general', $this->product_id, 'code' ) ); - $store->set( 'general', $this->product_id, 'code', 'X1' ); - self::assertTrue( $store->has( 'general', $this->product_id, 'code' ) ); - self::assertSame( 'X1', $store->get( 'general', $this->product_id, 'code' ) ); + $surface->set( 'general', $this->product_id, 'code', 'X1' ); + self::assertTrue( $surface->has( 'general', $this->product_id, 'code' ) ); + self::assertSame( 'X1', $surface->get( 'general', $this->product_id, 'code' ) ); - self::assertTrue( $store->delete( 'general', $this->product_id, 'code' ) ); - self::assertFalse( $store->has( 'general', $this->product_id, 'code' ) ); + self::assertTrue( $surface->delete( 'general', $this->product_id, 'code' ) ); + self::assertFalse( $surface->has( 'general', $this->product_id, 'code' ) ); } public function test_set_normalizes_a_checkbox_value_to_yes_no(): void { - $store = new ProductDataFieldSurface(); - $store->register_tab( $this->tab_with( new SettingsField( id: 'flag', type: 'checkbox', label: 'Flag' ) ) ); + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab_with( new SettingsField( id: 'flag', type: 'checkbox', label: 'Flag' ) ) ); // A boolean written through CRUD must persist as WooCommerce's 'yes', matching the form-save path. - $store->set( 'general', $this->product_id, 'flag', true ); + $surface->set( 'general', $this->product_id, 'flag', true ); - self::assertSame( 'yes', $store->get( 'general', $this->product_id, 'flag' ) ); + self::assertSame( 'yes', $surface->get( 'general', $this->product_id, 'flag' ) ); } public function test_set_persists_a_value_equal_to_the_default(): void { - $store = new ProductDataFieldSurface(); - $store->register_tab( $this->tab() ); + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab() ); // Setting a field to a value that equals its default must persist a real row — matching the form save's // store-all — rather than be mistaken for an injected default and stripped by the pre-save hook. - $store->set( 'general', $this->product_id, 'warranty-type', 'global' ); + $surface->set( 'general', $this->product_id, 'warranty-type', 'global' ); - self::assertTrue( $store->has( 'general', $this->product_id, 'warranty-type' ) ); - self::assertSame( 'global', $store->get( 'general', $this->product_id, 'warranty-type' ) ); + self::assertTrue( $surface->has( 'general', $this->product_id, 'warranty-type' ) ); + self::assertSame( 'global', $surface->get( 'general', $this->product_id, 'warranty-type' ) ); } public function test_get_returns_the_descriptor_default_for_an_unstored_supported_field(): void { - $store = new ProductDataFieldSurface(); - $store->register_tab( $this->tab() ); + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab() ); // get() reads the descriptor default while nothing is stored, agreeing with the injected read paths and // with has() reporting no real value yet. - self::assertFalse( $store->has( 'general', $this->product_id, 'warranty-type' ) ); - self::assertSame( 'global', $store->get( 'general', $this->product_id, 'warranty-type' ) ); + self::assertFalse( $surface->has( 'general', $this->product_id, 'warranty-type' ) ); + self::assertSame( 'global', $surface->get( 'general', $this->product_id, 'warranty-type' ) ); } public function test_get_returns_the_caller_fallback_for_an_unsupported_product(): void { - $store = new ProductDataFieldSurface(); - $store->register_tab( $this->tab( supports: static fn ( int $product_id ): bool => false ) ); + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab( supports: static fn ( int $product_id ): bool => false ) ); - self::assertSame( 'na', $store->get( 'general', $this->product_id, 'warranty-type', 'na' ) ); + self::assertSame( 'na', $surface->get( 'general', $this->product_id, 'warranty-type', 'na' ) ); } public function test_meta_key_derivation_and_override(): void { - $store = new ProductDataFieldSurface(); - $store->register_tab( + $surface = new ProductDataFieldSurface(); + $surface->register_tab( new ProductDataTab( slug: 'dws_warranty', label: 'Warranty', @@ -768,24 +720,24 @@ public function test_meta_key_derivation_and_override(): void { self::assertEqualsCanonicalizing( array( '_dws-wrwc_general_derived', '_legacy_v1_key' ), - $store->meta_keys(), + $surface->meta_keys(), ); // The CRUD addressing resolves to those exact keys: a write by section/field id lands on the derived // key for a plain field and on the byte-exact override for a legacy one. - $store->set( 'general', $this->product_id, 'derived', 'd-value' ); - $store->set( 'general', $this->product_id, 'explicit', 'e-value' ); + $surface->set( 'general', $this->product_id, 'derived', 'd-value' ); + $surface->set( 'general', $this->product_id, 'explicit', 'e-value' ); self::assertSame( 'd-value', \get_post_meta( $this->product_id, '_dws-wrwc_general_derived', true ) ); self::assertSame( 'e-value', \get_post_meta( $this->product_id, '_legacy_v1_key', true ) ); } public function test_a_duplicate_meta_key_is_rejected(): void { - $store = new ProductDataFieldSurface(); + $surface = new ProductDataFieldSurface(); $this->expectException( DuplicateSettingsFieldException::class ); - $store->register_tab( + $surface->register_tab( new ProductDataTab( slug: 'dws_warranty', label: 'Warranty', @@ -799,18 +751,14 @@ public function test_a_duplicate_meta_key_is_rejected(): void { } public function test_crud_on_an_unregistered_field_throws(): void { - $store = new ProductDataFieldSurface(); - $store->register_tab( $this->tab() ); + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab() ); $this->expectException( InvalidSettingsFieldException::class ); - $store->get( 'general', $this->product_id, 'nope' ); + $surface->get( 'general', $this->product_id, 'nope' ); } - // endregion - - // region HELPERS - private function set_current_product( int $product_id ): void { $GLOBALS['thepostid'] = $product_id; $GLOBALS['post'] = \get_post( $product_id ); @@ -864,6 +812,4 @@ private function tab_with( SettingsField $field, array $custom_renderers = array private function noop_renderer(): \Closure { return static function ( SettingsField $field, mixed $value, string $meta_key ): void {}; } - - // endregion } diff --git a/packages/woocommerce/tests/Integration/index.php b/packages/woocommerce/tests/Integration/index.php deleted file mode 100644 index f767346..0000000 --- a/packages/woocommerce/tests/Integration/index.php +++ /dev/null @@ -1 +0,0 @@ -repository = new InMemoryObjectMetaRepository(); - $this->store = new OrderFieldSurface( repository: $this->repository ); + $this->surface = new OrderFieldSurface( repository: $this->repository ); } public function test_a_value_round_trips_under_the_resolved_storage_key(): void { $group = $this->group( new SettingsField( id: 'note', type: 'text', label: 'Note' ) ); - $this->store->set( $group, 11, 'note', 'hello' ); + $this->surface->set( $group, 11, 'note', 'hello' ); - self::assertSame( 'hello', $this->store->get( $group, 11, 'note' ) ); + self::assertSame( 'hello', $this->surface->get( $group, 11, 'note' ) ); self::assertSame( 'hello', $this->repository->get( 11, 'note' ) ); - self::assertFalse( $this->store->has( $group, 12, 'note' ) ); + self::assertFalse( $this->surface->has( $group, 12, 'note' ) ); } public function test_a_meta_key_override_is_the_byte_exact_storage_key(): void { $group = $this->group( new SettingsField( id: 'note', type: 'text', label: 'Note', meta_key: '_dws_note' ) ); - $this->store->set( $group, 11, 'note', 'hello' ); + $this->surface->set( $group, 11, 'note', 'hello' ); self::assertSame( 'hello', $this->repository->get( 11, '_dws_note' ) ); self::assertFalse( $this->repository->has( 11, 'note' ) ); @@ -61,43 +61,43 @@ public function test_a_meta_key_override_is_the_byte_exact_storage_key(): void { public function test_get_returns_the_caller_fallback_never_the_field_default_when_nothing_is_stored(): void { $group = $this->group( new SettingsField( id: 'note', type: 'text', label: 'Note', default_value: 'declared-default' ) ); - self::assertSame( 'fallback', $this->store->get( $group, 11, 'note', 'fallback' ) ); + self::assertSame( 'fallback', $this->surface->get( $group, 11, 'note', 'fallback' ) ); } public function test_set_stores_a_checkbox_in_its_canonical_yes_no_form(): void { $group = $this->group( new SettingsField( id: 'flag', type: 'checkbox', label: 'Flag' ) ); - $this->store->set( $group, 11, 'flag', true ); + $this->surface->set( $group, 11, 'flag', true ); self::assertSame( 'yes', $this->repository->get( 11, 'flag' ) ); - $this->store->set( $group, 11, 'flag', false ); + $this->surface->set( $group, 11, 'flag', false ); self::assertSame( 'no', $this->repository->get( 11, 'flag' ) ); - self::assertTrue( $this->store->has( $group, 11, 'flag' ) ); + self::assertTrue( $this->surface->has( $group, 11, 'flag' ) ); } public function test_set_revokes_the_key_for_a_value_a_form_save_would_not_store(): void { $group = $this->group( new SettingsField( id: 'note', type: 'text', label: 'Note' ) ); - $this->store->set( $group, 11, 'note', 'hello' ); - $this->store->set( $group, 11, 'note', '' ); + $this->surface->set( $group, 11, 'note', 'hello' ); + $this->surface->set( $group, 11, 'note', '' ); - self::assertFalse( $this->store->has( $group, 11, 'note' ) ); + self::assertFalse( $this->surface->has( $group, 11, 'note' ) ); } public function test_delete_removes_a_stored_value_and_reports_a_missing_one(): void { $group = $this->group( new SettingsField( id: 'note', type: 'text', label: 'Note' ) ); - $this->store->set( $group, 11, 'note', 'hello' ); + $this->surface->set( $group, 11, 'note', 'hello' ); - self::assertTrue( $this->store->delete( $group, 11, 'note' ) ); - self::assertFalse( $this->store->has( $group, 11, 'note' ) ); - self::assertFalse( $this->store->delete( $group, 11, 'note' ) ); + self::assertTrue( $this->surface->delete( $group, 11, 'note' ) ); + self::assertFalse( $this->surface->has( $group, 11, 'note' ) ); + self::assertFalse( $this->surface->delete( $group, 11, 'note' ) ); } public function test_a_field_the_group_does_not_declare_is_rejected(): void { $this->expectException( InvalidSettingsFieldException::class ); - $this->store->get( $this->group( new SettingsField( id: 'note', type: 'text', label: 'Note' ) ), 11, 'missing' ); + $this->surface->get( $this->group( new SettingsField( id: 'note', type: 'text', label: 'Note' ) ), 11, 'missing' ); } public function test_meta_keys_enumerates_the_resolved_storage_keys(): void { @@ -106,7 +106,7 @@ public function test_meta_keys_enumerates_the_resolved_storage_keys(): void { new SettingsField( id: 'ref', type: 'text', label: 'Ref', meta_key: '_dws_ref' ), ); - self::assertSame( array( 'note', '_dws_ref' ), $this->store->meta_keys( $group ) ); + self::assertSame( array( 'note', '_dws_ref' ), $this->surface->meta_keys( $group ) ); } private function group( SettingsField ...$fields ): FieldGroup { diff --git a/packages/woocommerce/tests/Unit/ProductDataFieldRendererTest.php b/packages/woocommerce/tests/Unit/ProductData/ProductDataFieldRendererTest.php similarity index 99% rename from packages/woocommerce/tests/Unit/ProductDataFieldRendererTest.php rename to packages/woocommerce/tests/Unit/ProductData/ProductDataFieldRendererTest.php index 5fab835..a941680 100644 --- a/packages/woocommerce/tests/Unit/ProductDataFieldRendererTest.php +++ b/packages/woocommerce/tests/Unit/ProductData/ProductDataFieldRendererTest.php @@ -1,6 +1,6 @@ Date: Wed, 8 Jul 2026 01:40:24 +0200 Subject: [PATCH 07/10] fix(infrastructure): validate the renderer's store at wiring time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-container integration run surfaced two branch regressions the local Unit suite cannot see. DependencyAdminNoticeRenderer queues into a named store from an admin hook, so the unknown-store throw introduced with the failure-channel unification would have fataled every admin request on a mis-wired consumer — the constructor now validates the store name (UnknownNoticeStoreException at wiring time), and the stale _doing_it_wrong test expects the ctor throw. The settings-backend test still counted one autoload filter per section; the page-wide filter registers once, so the expected delta is one. Integration suite in wp-env: 560 tests, 1298 assertions, green. Assisted-by: Claude Code:claude-fable-5 --- .../DependencyAdminNoticeRenderer.php | 12 ++++++- .../Backend/WordPressSettingsBackendTest.php | 4 +-- .../DependencyAdminNoticeRendererTest.php | 32 ++++++------------- 3 files changed, 23 insertions(+), 25 deletions(-) diff --git a/packages/infrastructure/src/Utilities/AdminNotices/DependencyAdminNoticeRenderer.php b/packages/infrastructure/src/Utilities/AdminNotices/DependencyAdminNoticeRenderer.php index 1edd052..50c07cf 100644 --- a/packages/infrastructure/src/Utilities/AdminNotices/DependencyAdminNoticeRenderer.php +++ b/packages/infrastructure/src/Utilities/AdminNotices/DependencyAdminNoticeRenderer.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\Framework\Utilities\AdminNotices; +use DeepWebSolutions\Framework\Utilities\AdminNotices\Exceptions\UnknownNoticeStoreException; use DeepWebSolutions\Framework\Utilities\AdminNotices\ValueObjects\AdminNotice; use DeepWebSolutions\Framework\Utilities\AdminNotices\ValueObjects\DependencyRequirement; @@ -45,6 +46,8 @@ * @param string|null $source Plugin or feature display name woven into the notice text; null uses a generic subject. * @param string $store Name of the service store to queue into. Defaults to AdminNoticesService::DEFAULT_STORE. * @param string $capability Capability required to see the notices. Defaults to DEFAULT_CAPABILITY. + * + * @throws UnknownNoticeStoreException When no store is registered on the service under $store. */ public function __construct( protected AdminNoticesService $service, @@ -52,7 +55,14 @@ public function __construct( protected ?string $source = null, protected string $store = AdminNoticesService::DEFAULT_STORE, protected string $capability = self::DEFAULT_CAPABILITY, - ) {} + ) { + // Validated at wiring time: render() runs inside an admin hook on every request, where an + // unknown-store throw would fatal the whole admin instead of failing the one mis-wired consumer. + if ( ! isset( $service->stores[ $store ] ) ) { + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- framework-internal exception; never reaches an HTML output context unescaped. + throw new UnknownNoticeStoreException( "No notice store is registered under name '$store'." ); + } + } // endregion diff --git a/packages/infrastructure/tests/Settings/Integration/Backend/WordPressSettingsBackendTest.php b/packages/infrastructure/tests/Settings/Integration/Backend/WordPressSettingsBackendTest.php index f0fe7aa..9c23391 100644 --- a/packages/infrastructure/tests/Settings/Integration/Backend/WordPressSettingsBackendTest.php +++ b/packages/infrastructure/tests/Settings/Integration/Backend/WordPressSettingsBackendTest.php @@ -125,8 +125,8 @@ public function test_settings_registration_runs_once_when_both_registration_hook $registered_once = $count_autoload_filters(); \do_action( 'rest_api_init' ); - // admin_init performs the registration (one autoload filter per section); rest_api_init adds none. - self::assertSame( $baseline + 2, $registered_once ); + // admin_init performs the registration (a single page-wide autoload filter); rest_api_init adds none. + self::assertSame( $baseline + 1, $registered_once ); self::assertSame( $registered_once, $count_autoload_filters() ); } diff --git a/packages/infrastructure/tests/Utilities/Integration/AdminNotices/DependencyAdminNoticeRendererTest.php b/packages/infrastructure/tests/Utilities/Integration/AdminNotices/DependencyAdminNoticeRendererTest.php index 8b5da41..b1ef846 100644 --- a/packages/infrastructure/tests/Utilities/Integration/AdminNotices/DependencyAdminNoticeRendererTest.php +++ b/packages/infrastructure/tests/Utilities/Integration/AdminNotices/DependencyAdminNoticeRendererTest.php @@ -7,6 +7,7 @@ use DeepWebSolutions\Framework\Utilities\AdminNotices\AdminNoticesService; use DeepWebSolutions\Framework\Utilities\AdminNotices\DependencyAdminNoticeRenderer; use DeepWebSolutions\Framework\Utilities\AdminNotices\DismissedNoticesTracker; +use DeepWebSolutions\Framework\Utilities\AdminNotices\Exceptions\UnknownNoticeStoreException; use DeepWebSolutions\Framework\Utilities\AdminNotices\NoticeStore; use DeepWebSolutions\Framework\Utilities\AdminNotices\ValueObjects\AdminNotice; use DeepWebSolutions\Framework\Utilities\AdminNotices\ValueObjects\DependencyRequirement; @@ -26,6 +27,7 @@ #[UsesClass( AdminNotice::class )] #[UsesClass( DependencyRequirement::class )] #[UsesClass( NoticeType::class )] +#[UsesClass( UnknownNoticeStoreException::class )] #[UsesClass( MemoryStore::class )] #[UsesClass( OptionsStore::class )] #[UsesClass( UserMetaStore::class )] @@ -156,28 +158,14 @@ public function test_a_throwing_conditional_is_treated_as_unmet(): void { self::assertTrue( $service->stores['memory']->has( 'dep_flaky_dependency' ) ); } - public function test_an_unknown_store_triggers_doing_it_wrong(): void { - $fired = 0; - $spy = static function () use ( &$fired ) { - ++$fired; - }; - \add_filter( 'doing_it_wrong_trigger_error', '__return_false' ); - \add_action( 'doing_it_wrong_run', $spy ); - - try { - $service = new AdminNoticesService(); - ( new DependencyAdminNoticeRenderer( - $service, - array( new DependencyRequirement( $this->conditional( false ), 'WooCommerce' ) ), - store: 'nope', - ) )->render(); - - self::assertGreaterThan( 0, $fired ); - self::assertSame( array(), $service->stores['memory']->get_all() ); - } finally { - \remove_action( 'doing_it_wrong_run', $spy ); - \remove_filter( 'doing_it_wrong_trigger_error', '__return_false' ); - } + public function test_an_unknown_store_throws_at_construction(): void { + $this->expectException( UnknownNoticeStoreException::class ); + + new DependencyAdminNoticeRenderer( + new AdminNoticesService(), + array( new DependencyRequirement( $this->conditional( false ), 'WooCommerce' ) ), + store: 'nope', + ); } public function test_renders_for_a_capable_user_and_hides_from_others(): void { From b69767c25a4079a41cf269580242c070d44bfe01 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Wed, 8 Jul 2026 01:52:15 +0200 Subject: [PATCH 08/10] docs(framework): align public docs, CI refs, and add usage recipes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All seven reusable-workflow calls pin one wordpress-configs SHA (7c1ef45c) — the split pin from the mutation fix is gone, and bumping all refs together is the rule. The three missing src index.php guards land; infection source dirs reorder to package-then-path alphabetical; the consumer-smoke scoper configs agree on the bare patchers deref; workflow comments trim to one-sentence WHYs and every job carries a name. Package docs reach the family bar: shared's README names the live packages; infrastructure gains the monorepo boilerplate, a Lineage section, and a subsystem-level 2.0.0 changelog (13 entries replacing the lone merge bullet); woocommerce's composer description and keywords match its actual surface; CONTRIBUTING and the changelog boilerplates use the real packages::changelog:* script names. Four copy-paste recipes document the first consumer tasks against the as-built API: a settings page, a recurring job, a persistent dismissible notice, and a product-data tab. Assisted-by: Claude Code:claude-fable-5 --- .github/workflows/audit.yml | 2 +- .github/workflows/codeql.yml | 3 +- .github/workflows/php-syntax.yml | 5 +- .github/workflows/quality.yml | 3 +- .github/workflows/split-packages.yml | 8 +- .github/workflows/tests.yml | 10 +- .github/workflows/workflow-checks.yml | 3 +- CONTRIBUTING.md | 12 +- deptrac.yaml | 8 +- infection.integration.json | 2 +- infection.json | 2 +- packages/bootstrap/CHANGELOG.md | 2 +- packages/core/CHANGELOG.md | 2 +- packages/infrastructure/CHANGELOG.md | 18 ++- packages/infrastructure/README.md | 114 +++++++++++++++++- .../src/Settings/Schema/Errors/index.php | 1 + packages/infrastructure/src/index.php | 1 + packages/shared/CHANGELOG.md | 2 +- packages/shared/README.md | 6 +- packages/woocommerce/CHANGELOG.md | 2 +- packages/woocommerce/README.md | 37 ++++++ packages/woocommerce/composer.json | 4 +- .../woocommerce/src/ProductData/index.php | 1 + tests/Fixtures/consumer-smoke/scoper.inc.php | 3 +- 24 files changed, 203 insertions(+), 48 deletions(-) create mode 100644 packages/infrastructure/src/Settings/Schema/Errors/index.php create mode 100644 packages/infrastructure/src/index.php create mode 100644 packages/woocommerce/src/ProductData/index.php diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 6d4eadd..924eb05 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -16,6 +16,6 @@ jobs: audit: name: Audit # npm gate audits production deps only — the dev-tooling graph (wp-scripts/playwright trees) is advisory via dependabot alerts. - uses: ahegyes/wordpress-configs/.github/workflows/reusable-supply-chain-audit.yml@714fc2a66d6ae5011999beb18a4b01fb1992f4bd + uses: ahegyes/wordpress-configs/.github/workflows/reusable-supply-chain-audit.yml@7c1ef45c6be4cffdea69b05e855ec0c03e51166c with: npm-audit-flags: '--omit=dev --audit-level=high' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e0b5a19..9145ab3 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -16,8 +16,9 @@ permissions: {} jobs: analyze: + name: CodeQL permissions: actions: read contents: read security-events: write - uses: ahegyes/wordpress-configs/.github/workflows/reusable-codeql.yml@714fc2a66d6ae5011999beb18a4b01fb1992f4bd + uses: ahegyes/wordpress-configs/.github/workflows/reusable-codeql.yml@7c1ef45c6be4cffdea69b05e855ec0c03e51166c diff --git a/.github/workflows/php-syntax.yml b/.github/workflows/php-syntax.yml index 976b6da..9395ef7 100644 --- a/.github/workflows/php-syntax.yml +++ b/.github/workflows/php-syntax.yml @@ -1,9 +1,8 @@ name: PHP Syntax # Parse-lints the pre-autoload bootstrap package across its full PHP floor so a -# modern-syntax token can't slip in and parse-fatal on a legacy runtime — the -# one place that would defeat the package's graceful-degradation job. index.php -# guards are excluded on purpose: they may fatal on a stray direct hit. +# modern-syntax token can't slip in and parse-fatal on a legacy runtime (index.php +# guards excluded — their strict_types declare doesn't parse below PHP 7). on: push: diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index cdfa727..1a37bdd 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -10,6 +10,7 @@ permissions: jobs: lint-php: - uses: ahegyes/wordpress-configs/.github/workflows/reusable-php-lint.yml@714fc2a66d6ae5011999beb18a4b01fb1992f4bd + name: Lint PHP + uses: ahegyes/wordpress-configs/.github/workflows/reusable-php-lint.yml@7c1ef45c6be4cffdea69b05e855ec0c03e51166c with: scripts: '["lint:php:phpcs", "lint:php:phpstan", "lint:php:deptrac", "lint:php:composer-require-checker", "changelog:validate"]' diff --git a/.github/workflows/split-packages.yml b/.github/workflows/split-packages.yml index 8ea6f7f..09e1117 100644 --- a/.github/workflows/split-packages.yml +++ b/.github/workflows/split-packages.yml @@ -27,16 +27,12 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 - # Don't install the credential helper that auths as github-actions[bot]. - # We push to the split repos via PAT embedded in the URL — the helper - # would otherwise hijack the auth and fail with 403. + # The default credential helper would hijack the PAT-in-URL push auth and fail with 403. persist-credentials: false - name: Install splitsh-lite run: | - # v1.0.1 (2017) is the latest splitsh-lite release with a published - # linux/amd64 binary; v2.0.0 was tagged in 2023 with no assets attached. - # The tool's `--prefix` behavior hasn't materially changed since v1. + # v1.0.1 is the latest splitsh-lite release with a published linux/amd64 binary (v2.0.0 was tagged with no assets). curl -fsSL -o splitsh.tar.gz https://github.com/splitsh/lite/releases/download/v1.0.1/lite_linux_amd64.tar.gz # sha256 computed from the v1.0.1 release asset (upstream publishes no checksums — trust-on-first-use pin). echo "2539301ce5e21d0ca44b689d0dd2c1b20d9f9e996c1fe6c462afb8af4e7141cc splitsh.tar.gz" | sha256sum -c - diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a8d7a20..b285b07 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,7 +12,7 @@ permissions: jobs: unit: name: Unit - uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@714fc2a66d6ae5011999beb18a4b01fb1992f4bd + uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@7c1ef45c6be4cffdea69b05e855ec0c03e51166c with: php-version: '8.5' composer-script: 'test:unit' @@ -28,14 +28,12 @@ jobs: - label: 'WP 7.0 (floor)' php-version: '8.5' wp-env-core: 'WordPress/WordPress#tags/7.0' - # Graceful-failure path: WP 6.9.4 is below the framework's 7.0 floor. - # Exercises check_requirements returning WP_Error with - # plugin_wp_incompatible. Validates that the framework correctly - # blocks plugin init on an incompatible WP runtime. + # Graceful-failure path: WP 6.9.4 is below the framework's 7.0 floor, + # so check_requirements must block init with plugin_wp_incompatible. - label: 'WP 6.9.4 (below floor)' php-version: '8.5' wp-env-core: 'WordPress/WordPress#tags/6.9.4' - uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@714fc2a66d6ae5011999beb18a4b01fb1992f4bd + uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@7c1ef45c6be4cffdea69b05e855ec0c03e51166c with: php-version: ${{ matrix.php-version }} wp-env-core: ${{ matrix.wp-env-core }} diff --git a/.github/workflows/workflow-checks.yml b/.github/workflows/workflow-checks.yml index 52a54bb..4150d2a 100644 --- a/.github/workflows/workflow-checks.yml +++ b/.github/workflows/workflow-checks.yml @@ -20,8 +20,9 @@ permissions: {} jobs: checks: + name: Workflow Checks permissions: contents: read security-events: write actions: read - uses: ahegyes/wordpress-configs/.github/workflows/reusable-workflow-checks.yml@714fc2a66d6ae5011999beb18a4b01fb1992f4bd + uses: ahegyes/wordpress-configs/.github/workflows/reusable-workflow-checks.yml@7c1ef45c6be4cffdea69b05e855ec0c03e51166c diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1ac56dc..b495f6d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,11 +7,11 @@ Each package maintains its own `CHANGELOG.md` in Keep-a-Changelog format. To avo For a PR touching a specific package, use the matching script: ```bash -composer changelog:add:bootstrap # wp-framework-bootstrap -composer changelog:add:core # wp-framework-core -composer changelog:add:infrastructure # wp-framework-infrastructure -composer changelog:add:shared # wp-framework-shared -composer changelog:add:woocommerce # wp-framework-woocommerce +composer packages:bootstrap:changelog:add # wp-framework-bootstrap +composer packages:core:changelog:add # wp-framework-core +composer packages:infrastructure:changelog:add # wp-framework-infrastructure +composer packages:shared:changelog:add # wp-framework-shared +composer packages:woocommerce:changelog:add # wp-framework-woocommerce ``` The interactive prompt asks for `Significance` (patch/minor/major) and `Type` (added/changed/deprecated/removed/fixed/security). Commit the fragment file with the rest of the PR. CI validates every fragment via `composer changelog:validate`. @@ -21,7 +21,7 @@ The interactive prompt asks for `Significance` (patch/minor/major) and `Type` (a Per package: ```bash -composer changelog:write:bootstrap # or :core / :infrastructure / :shared / :woocommerce +composer packages:bootstrap:changelog:write # swap "bootstrap" for core / infrastructure / shared / woocommerce ``` Aggregates `packages//changelog/*` → new version block in `packages//CHANGELOG.md`, computes the next semver from fragment significance levels, deletes the fragments. Commit the diff. The split-packages workflow propagates the package (with its updated CHANGELOG.md) into the per-package consumer-facing repo on `push` to `trunk`. diff --git a/deptrac.yaml b/deptrac.yaml index 30709df..9e1dc8b 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -59,12 +59,8 @@ deptrac: - type: classLike value: '^DeepWebSolutions\\Framework\\WooCommerce\\.*$' - # Allowed direct dependencies per layer. - # Shared is the runtime-WP-aware shared kernel; zero internal framework deps; no WP-specific abstractions; may be required directly by any layer above Bootstrap (Common Closure Principle). - # Storage is a second zero-dep namespace surface (KV storage backends + object-meta repositories; WP-runtime-coupled but no internal framework deps) — required directly by Utilities, Settings, and WooCommerce. - # The Settings_* sublayers union to the Settings namespace surface: Schema is the internal sink; Backend and MetaField sit on it and never on each other; the surface as a whole depends only on Storage + Shared. - # Bootstrap stays minimal (PHP 5.6+, pre-autoload) — cannot depend on Shared (PHP 8.5+). - # Only Core may depend on Bootstrap; no other layer has Bootstrap access, direct or indirect (allowances are not transitive). + # Allowed direct dependencies per layer; allowances are not transitive, so no layer reaches Bootstrap through Core. + # Bootstrap (PHP 5.6+, pre-autoload) cannot depend on the PHP 8.5+ Shared kernel. ruleset: Bootstrap: ~ Shared: ~ diff --git a/infection.integration.json b/infection.integration.json index 85677e9..ddb57e5 100644 --- a/infection.integration.json +++ b/infection.integration.json @@ -6,9 +6,9 @@ "packages/bootstrap/src", "packages/core/src", "packages/infrastructure/src/Settings", - "packages/shared/src", "packages/infrastructure/src/Storage", "packages/infrastructure/src/Utilities", + "packages/shared/src", "packages/woocommerce/src" ], "excludes": [ diff --git a/infection.json b/infection.json index 6813239..96d3877 100644 --- a/infection.json +++ b/infection.json @@ -6,9 +6,9 @@ "packages/bootstrap/src", "packages/core/src", "packages/infrastructure/src/Settings", - "packages/shared/src", "packages/infrastructure/src/Storage", "packages/infrastructure/src/Utilities", + "packages/shared/src", "packages/woocommerce/src" ], "excludes": [ diff --git a/packages/bootstrap/CHANGELOG.md b/packages/bootstrap/CHANGELOG.md index b4256b1..a1aecbe 100644 --- a/packages/bootstrap/CHANGELOG.md +++ b/packages/bootstrap/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to `ahegyes/wp-framework-bootstrap` are documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -Pending entries live in [`changelog/`](./changelog) — add via `composer changelog:add:bootstrap` from the monorepo root. Aggregate into a release with `composer changelog:write:bootstrap`. +Pending entries live in [`changelog/`](./changelog) — add via `composer packages:bootstrap:changelog:add` from the monorepo root. Aggregate into a release with `composer packages:bootstrap:changelog:write`. ## 2.0.0 - unreleased diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 241f7bc..ac6e3cd 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to `ahegyes/wp-framework-core` are documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -Pending entries live in [`changelog/`](./changelog) — add via `composer changelog:add:core` from the monorepo root. Aggregate into a release with `composer changelog:write:core`. +Pending entries live in [`changelog/`](./changelog) — add via `composer packages:core:changelog:add` from the monorepo root. Aggregate into a release with `composer packages:core:changelog:write`. ## 2.0.0 - unreleased diff --git a/packages/infrastructure/CHANGELOG.md b/packages/infrastructure/CHANGELOG.md index 7305edd..1c07f20 100644 --- a/packages/infrastructure/CHANGELOG.md +++ b/packages/infrastructure/CHANGELOG.md @@ -2,10 +2,26 @@ All notable changes to `ahegyes/wp-framework-infrastructure` are documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -Pending entries live in [`changelog/`](./changelog) — add via `composer changelog:add:infrastructure` from the monorepo root. Aggregate into a release with `composer changelog:write:infrastructure`. +Pending entries live in [`changelog/`](./changelog) — add via `composer packages:infrastructure:changelog:add` from the monorepo root. Aggregate into a release with `composer packages:infrastructure:changelog:write`. ## 2.0.0 - unreleased +### Added + +- **Key-value stores** — `KeyValueStoreInterface` with memory, options, and user-meta backends; null-correct reads and grouped persistent rows. +- **Object-meta repositories** — settings-free per-object persistence contract with a WordPress-metadata implementation. +- **Declarative settings schema** — page/section/field descriptors with a typed field taxonomy, options resolution, rendering, and sanitize/validate processing. +- **WordPress settings backend** — registers descriptor-backed options pages with grouped per-section storage and section-level REST exposure. +- **Meta-field surfaces** — a shared object-field form engine with post-meta, term, and user-profile surfaces plus descriptor-addressed CRUD. +- **Hooks service** — callable-native hook registration through direct, buffered, and scoped handlers, plus a deprecated-hook dispatcher. +- **Admin notices** — a queue-and-render service with persistent stores, per-user dismissal tracking, and dependency-requirement notices. +- **Caching** — transient and object-cache wrappers with per-plugin key prefixes and versioned-group invalidation. +- **Conditionals** — dependency and context predicates (plugin/PHP/WP versions, extensions, admin/AJAX/CLI/capability) for kernel feature gating. +- **Scheduling** — an ordered-backend scheduler facade over WP-Cron and Action Scheduler with `Result`-carried failures. +- **Permissions** — a capability registrar that grants on install, reconciles on update, and revokes on uninstall. +- **Logging** — composite and redacting PSR-3 decorators plus an admin-notice logger. +- **Helpers** — cross-plugin stateless array and asset utilities. + ### Changed - Consolidates `ahegyes/wp-framework-storage`, `ahegyes/wp-framework-settings`, and `ahegyes/wp-framework-utilities`; PHP namespaces are unchanged. diff --git a/packages/infrastructure/README.md b/packages/infrastructure/README.md index 4939172..46c4515 100644 --- a/packages/infrastructure/README.md +++ b/packages/infrastructure/README.md @@ -1,20 +1,124 @@ # wp-framework-infrastructure -Persistence, declarative settings, and runtime services for full DWS WordPress framework plugins. +Persistence, declarative settings, and runtime services for full DWS WordPress framework plugins. The PHP namespaces are unchanged from the retired storage, settings, and utilities packages, and Composer `replace` entries bridge existing requirements. + +Part of the [DWS WordPress framework](https://github.com/ahegyes/wordpress-framework) — see the monorepo for architecture, contributing, and the rest of the package set. This package provides the standard framework tier: - `DeepWebSolutions\Framework\Storage\` key-value stores and object-meta repositories. - `DeepWebSolutions\Framework\Settings\` descriptors, WordPress options backend, object-field forms, and REST-aware schema surfaces. - `DeepWebSolutions\Framework\Utilities\` hooks, admin notices, caching, conditionals, scheduling, permissions, logging, and helpers. -The PHP namespaces are unchanged from the retired storage, settings, and utilities packages. Composer `replace` entries bridge existing requirements during the next consumer re-lock. - ## Installation ```bash composer require ahegyes/wp-framework-infrastructure ``` -## Package Merge +## Usage + +### Register a settings page + +Declare the page as descriptors and hand it to a `WordPressSettingsBackend` from a Hookable component: + +```php +use DeepWebSolutions\Framework\Core\Lifecycle\Hookable\HookableInterface; +use DeepWebSolutions\Framework\Settings\Backend\WordPressSettingsBackend; +use DeepWebSolutions\Framework\Settings\Schema\Field\FieldType; +use DeepWebSolutions\Framework\Settings\Schema\ValueObjects\SettingsField; +use DeepWebSolutions\Framework\Settings\Schema\ValueObjects\SettingsPage; +use DeepWebSolutions\Framework\Settings\Schema\ValueObjects\SettingsSection; + +final class Settings implements HookableInterface { + public function __construct( protected WordPressSettingsBackend $backend ) {} + + public function register_hooks(): void { + $this->backend->register_page( + new SettingsPage( + slug: 'my_plugin', + page_title: 'My Plugin', + menu_title: 'My Plugin', + capability: 'manage_options', + sections: array( + new SettingsSection( + id: 'general', + title: 'General', + fields: array( + new SettingsField( id: 'api_key', type: FieldType::Text->value, label: 'API key', default_value: '' ), + ), + ), + ), + ), + ); + } +} +``` + +A field's `default_value` fills the control until the first save — it is render-time only. Reads fall back to the default you pass at read time: `$backend->get( 'api_key', 'fallback' )`. + +### Schedule a recurring job + +```php +use DeepWebSolutions\Framework\Utilities\Scheduling\Backends\ActionSchedulerBackend; +use DeepWebSolutions\Framework\Utilities\Scheduling\Backends\WPCronBackend; +use DeepWebSolutions\Framework\Utilities\Scheduling\Scheduler; + +// An omitted backend list defaults to WP-Cron alone; list Action Scheduler first to prefer it. +$scheduler = new Scheduler( array( new ActionSchedulerBackend(), new WPCronBackend() ) ); + +// Every request — e.g. from a component's register_hooks() — so each backend is wired. +$scheduler->register_hooks(); + +// schedule_recurring() is #[\NoDiscard]: a dropped Failure is a job that silently never runs. +if ( ! $scheduler->is_scheduled( 'my_plugin_sync' ) ) { + $result = $scheduler->schedule_recurring( 'my_plugin_sync', HOUR_IN_SECONDS ); + if ( $result->is_failure() ) { + // Branch on the SchedulingError payload — the job was NOT scheduled. + } +} + +// On deactivation, clear the job; unschedule() also returns a Result. +if ( $scheduler->unschedule( 'my_plugin_sync' )->is_failure() ) { + // Log it — the job may still fire. +} +``` + +### Show a persistent dismissible admin notice + +```php +use DeepWebSolutions\Framework\Storage\OptionsStore; +use DeepWebSolutions\Framework\Storage\UserMetaStore; +use DeepWebSolutions\Framework\Utilities\AdminNotices\AdminNoticesService; +use DeepWebSolutions\Framework\Utilities\AdminNotices\DismissedNoticesTracker; +use DeepWebSolutions\Framework\Utilities\AdminNotices\NoticeStore; +use DeepWebSolutions\Framework\Utilities\AdminNotices\NoticeType; +use DeepWebSolutions\Framework\Utilities\AdminNotices\ValueObjects\AdminNotice; + +// Sticky dismissal needs all three: a persistent store, a dismissal tracker, and a dismiss action. +$notices = new AdminNoticesService( + stores: array( 'options' => new NoticeStore( new OptionsStore( 'my_plugin_notices' ) ) ), + dismissals: new DismissedNoticesTracker( new UserMetaStore( 'my_plugin_dismissed_notices' ) ), + dismiss_action: 'my_plugin_dismiss_notice', +); +$notices->register_hooks(); // Once, during boot. + +$notices->add_notice( + new AdminNotice( + id: 'migration_failed', + message: 'My Plugin could not complete its data migration.', + type: NoticeType::Error, + persistent: true, // Recurs on every admin request until dismissed. + ), + store: 'options', +); +``` + +An explicit `stores` map replaces the default in-memory store — `add_notice()` to an unregistered store name throws. + +## Lineage + +Successor to: +- [`deep-web-solutions/wp-framework-settings`](https://github.com/deep-web-solutions/wordpress-framework-settings) (archived) +- [`deep-web-solutions/wp-framework-utilities`](https://github.com/deep-web-solutions/wordpress-framework-utilities) (archived) -This package consolidates the standard framework tier. Existing Storage, Settings, and Utilities PHP namespaces are unchanged. +The Storage namespace is new in v2. diff --git a/packages/infrastructure/src/Settings/Schema/Errors/index.php b/packages/infrastructure/src/Settings/Schema/Errors/index.php new file mode 100644 index 0000000..f767346 --- /dev/null +++ b/packages/infrastructure/src/Settings/Schema/Errors/index.php @@ -0,0 +1 @@ +register_tab( + new ProductDataTab( + slug: 'my_plugin', + label: 'My Plugin', + meta_key_prefix: '_my_plugin_', + sections: array( + new SettingsSection( + id: 'general', + title: 'General', + fields: array( + new SettingsField( id: 'lead_time', type: FieldType::Number->value, label: 'Lead time (days)', default_value: 0 ), + ), + ), + ), + ), +); + +// Field-addressed reads and writes outside the product screen: +$days = $surface->get( 'general', $product_id, 'lead_time' ); +``` + +`meta_keys()` enumerates the tab's exact product-meta key set for the consumer installer's uninstall cleanup. + ## Lineage Successor to [`deep-web-solutions/wp-framework-woocommerce`](https://github.com/deep-web-solutions/wordpress-framework-woocommerce) (archived). diff --git a/packages/woocommerce/composer.json b/packages/woocommerce/composer.json index 89343a3..d7affb1 100644 --- a/packages/woocommerce/composer.json +++ b/packages/woocommerce/composer.json @@ -1,6 +1,6 @@ { "name": "ahegyes/wp-framework-woocommerce", - "description": "WooCommerce settings backend and WC-aware helpers for plugins built on the DWS framework.", + "description": "WooCommerce settings backend, product-data and order-data fields, version and database-version conditionals, and a PSR-3 logger for plugins built on the DWS framework.", "type": "library", "license": "GPL-2.0-or-later", "homepage": "https://github.com/ahegyes/wordpress-framework", @@ -10,7 +10,7 @@ "homepage": "https://github.com/ahegyes/wordpress-framework/graphs/contributors" } ], - "keywords": ["wordpress", "wordpress-plugin", "woocommerce", "framework"], + "keywords": ["wordpress", "wordpress-plugin", "framework", "woocommerce", "settings", "product-data", "order-data", "conditionals", "logging"], "support": { "issues": "https://github.com/ahegyes/wordpress-framework/issues", "source": "https://github.com/ahegyes/wordpress-framework" diff --git a/packages/woocommerce/src/ProductData/index.php b/packages/woocommerce/src/ProductData/index.php new file mode 100644 index 0000000..f767346 --- /dev/null +++ b/packages/woocommerce/src/ProductData/index.php @@ -0,0 +1 @@ + __DIR__, 'finders' => array_merge( $wp_framework['finders'], $php_di_partial['finders'] ), 'exclude_files' => $php_di_partial['exclude_files'], - // `patchers` postdates `finders` in the wp-framework partial; tolerate an older installed wordpress-configs. - 'patchers' => $wp_framework['patchers'] ?? array(), + 'patchers' => $wp_framework['patchers'], ) ); From d569296c095f8891c12bc4cb6c35ce831d98c06a Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Wed, 8 Jul 2026 01:57:08 +0200 Subject: [PATCH 09/10] docs(agents): rewrite the decision record to the as-built post-remediation state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stale blocks now describe reality: the i18n block records the five-real-domains + shape-based-patcher contract (the lint guarantee and the scope-time tripwire are two halves of one mechanism — restoring the pre-merge domains would fail WPCS); the local-development section drops the phantom per-package require-dev, names the deptrac cache file, and documents the PHPStan run-from-inside-a-package rule with the 20-error Action Scheduler baseline; the CI block's consumer-hardening claim matches audit.yml; composer-require-checker's WooCommerce-surface allow-list is on the record. Every adjudicated rule from the consistency remediation lands as a decision block: Store-vs-Surface naming (with the industry-survey rationale), the Settings prefix rule, the per-token host-name rule, register_hooks() as the sole wiring verb, the framework-family failure channel (fail at wiring; PSR only where PSR-3 mandates), the service trio's member-identity and readonly rules, the three field-CRUD address tuples with the deferred-unification re-entry trigger, WC-CRUD-only product persistence with the injection-seam trigger, lifecycle exception symmetry, the settled style rules (bool props, VO factories, helper shapes, throw grammar, regionless exceptions, bootstrap's load-bearing @return void, the SettingsField cap), and the test conventions (mirror-src, Support traits, contract case, exhaustive Uses*). Assisted-by: Claude Code:claude-fable-5 --- AGENTS.md | 104 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 77 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index aabc069..c0cc611 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ Historical decision rationale, superseded designs, and per-session audit/remedia ## Status -All five packages are implemented. `shared` (Error / Exception / Result / ValueObject / Version scaffolding + Identifier / Reflection helpers), `core` (kernel + feature / lifecycle / rendering / installer contracts), and `infrastructure` (Storage key-value/object-meta persistence + Settings descriptors/backends + Utilities runtime services) sit on `bootstrap`. `woocommerce` carries the settings backend (`WooCommerceSettingsBackend` + `DescriptorBackedWCSettingsPage` + `WCSettingsBuilder`), the **product-data settings tab** (`ProductData/`), the **order-data field store** (`OrderData/`), WooCommerce version / db-version conditionals (`Conditionals/`), and a `WC_Logger` PSR-3 logger (`Logging/`). +All five packages are implemented. `shared` (Error / Exception / Result / ValueObject / Version scaffolding + Identifier / Reflection helpers), `core` (kernel + feature / lifecycle / rendering / installer contracts), and `infrastructure` (Storage key-value/object-meta persistence + Settings descriptors/backends + Utilities runtime services) sit on `bootstrap`. `woocommerce` carries the settings backend (`WooCommerceSettingsBackend` + `DescriptorBackedWooCommerceSettingsPage` + `WooCommerceSettingsBuilder`), the **product-data settings tab** (`ProductData/`), the **order-data field surface** (`OrderData/`), WooCommerce version / db-version conditionals (`Conditionals/`), and a `WC_Logger` PSR-3 logger (`Logging/`). ## Monorepo structure @@ -73,11 +73,13 @@ composer test:integration # WP integration tests via wp-env composer test:unit:mutation # Infection mutation tests (no Docker; Unit suite only) ``` -`composer lint:php` aggregates PHPCS + PHPStan + **deptrac** (architecture-rule check via `deptrac.yaml`) + composer-require-checker. Cache lives at `tests/.cache/deptrac/`. +`composer lint:php` aggregates PHPCS + PHPStan + **deptrac** (architecture-rule check via `deptrac.yaml`) + composer-require-checker. The deptrac cache is the file `tests/.cache/deptrac`. + +**Run PHPStan from inside a package directory, never from the repo root.** wordpress-configs' `phpstan.dist.neon.php` resolves layout from `getcwd()`; from the monorepo root it matches nothing and falls back to analyzing the whole repo including fixture vendor trees — 1000+ spurious errors, worker OOMs, and a poisoned AST cache in `$TMPDIR/phpstan` that survives `clear-result-cache` (delete the directory if runs start reporting phantom errors). The `phpstan.settings.neon`/`phpstan.storage.neon` in-package runs report a known baseline of 20 Action Scheduler `as_*` errors (those configs do not scan the AS stubs); treat only a delta from 20 as a finding. CI runs per-package and is unaffected. wp-env runs on **port 8801** (per workspace port scheme — see `feedback_wp_env_port_scheme` memory). -Per-package install isn't supported during dev: cross-package requires (e.g., core's `ahegyes/wp-framework-bootstrap`) only resolve via the root's path-repo declarations; per-package `require-dev: ahegyes/wordpress-configs` is fetchable only via the root's VCS repo declaration. +Per-package install isn't supported during dev: cross-package requires (e.g., core's `ahegyes/wp-framework-bootstrap`) only resolve via the root's path-repo declarations, and each package's `phpstan*.neon` includes wordpress-configs' shared config through the root vendor path (`../../vendor/ahegyes/wordpress-configs/…`). The packages themselves declare no `require-dev`. Integration tests run in wp-env's `cli` container; `.wp-env.tests.json` declares `"testsEnvironment": false`. @@ -159,7 +161,7 @@ Reconsider when a concrete consumer wants substitutability — e.g., a logging/m ### Persistent admin notices -`AdminNoticesService` queues notices in memory. Cross-request and sticky-dismissal notices ride `NoticeStore` — a `final readonly` class composing a `KeyValueStoreInterface` backend (the Memory/Options/UserMeta stores), rehydrating only well-formed rows — an `is_persistent` flag on `AdminNotice`, and `DismissedNoticesTracker` dismissal via user_meta keyed by notice id. The service's `$stores` default lives in the constructor parameter itself (an omitted argument yields the single in-memory store; an explicit empty array registers none — there is no nullable-coalesce shape), and the service self-wires via `register_hooks()`: one consumer call binds `render_notices()`/`print_dismiss_script()`/`handle_dismiss()` to `admin_notices`/`admin_footer`/`wp_ajax_{action}` through instance-addressed callables. +`AdminNoticesService` queues notices in memory. Cross-request and sticky-dismissal notices ride `NoticeStore` — a `final readonly` class composing a `KeyValueStoreInterface` backend (the Memory/Options/UserMeta stores), rehydrating only well-formed rows — a `$persistent` flag on `AdminNotice`, and `DismissedNoticesTracker` dismissal via user_meta keyed by notice id. The service's `$stores` default lives in the constructor parameter itself (an omitted argument yields the single in-memory store; an explicit empty array registers none — there is no nullable-coalesce shape), and the service self-wires via `register_hooks()`: one consumer call binds `render_notices()`/`print_dismiss_script()`/`handle_dismiss()` to `admin_notices`/`admin_footer`/`wp_ajax_{action}` through instance-addressed callables. A queue or explicit-store removal naming an unknown store throws `UnknownNoticeStoreException`; `DependencyAdminNoticeRenderer` validates its target store at construction because its render pass runs inside an admin hook, where the throw would otherwise fatal every request. Persistent notices are required for post-redirect-get error UX: queue notice, redirect, render on the next request, and remember dismissal. @@ -268,19 +270,19 @@ The Utilities namespace is a rollup over Hooks, AdminNotices, Caching, Condition - **Storage null-correctness:** all three stores use `array_key_exists()`, not `?? $default` — a key stored as `null` returns `null`, not the default (which would contradict `has()`). - **`UserMetaStore` per-user targeting is concrete-only.** `set/get/has/delete/get_all/clear` take a trailing optional `int $user_id = 0` (0 ⇒ current user; the anonymous guard is `< 1` after resolution). This widens the implementations but is NOT lifted to `KeyValueStoreInterface` (meaningless for Memory/Options) — callers targeting another user typehint the concrete store. - **Grouped persistent stores are read-modify-write.** `OptionsStore` and `UserMetaStore` serialize all entries into one option/meta row; individual key writes load the row, mutate one key, and save the row. Concurrent writes to different keys in the same store are not atomic. -- **`HooksService::__construct( array $initial_handlers = array( new DirectHookHandler() ) )`:** omitted ⇒ one default `DirectHookHandler`; `array()` ⇒ zero handlers. `DirectHookHandler::DEFAULT_ID = 'direct'` single-sources the handler id across HooksService's method defaults. Handlers compose the internal `Hooks/HookRegistry` (parallel action/filter registration records) to replay registration and to back the exhaustive `remove_all_*`. -- **`BufferedHookHandler` removals sync WordPress** (`remove_*` / `remove_all_*` also call WP `remove_action`/`remove_filter`, not just mutate the queue) — only adds are buffered; removes are immediate. **`ScopedHookHandler::register_lifecycle()` is idempotent** — it wires stable `array($buffer, 'flush'/'reset')` callbacks (first-class-callable closures get distinct hashes WP won't de-dup, so repeated registration would stack listeners). +- **`HooksService::__construct( array $initial_handlers = array( new DirectHookHandler() ) )`:** omitted ⇒ one default `DirectHookHandler`; `array()` ⇒ zero handlers. The service is `final readonly` — handlers are fixed at construction (no `register_handler()` mutator, no `get_handler()`; the public `$handlers` map, keyed by each handler's intrinsic `$id`, is the lookup). `DirectHookHandler::DEFAULT_ID = 'direct'` single-sources the handler id across HooksService's method defaults; `BufferedHookHandler::DEFAULT_ID = 'buffered'` mirrors it. Handlers compose the internal `Hooks/HookRegistry` (parallel action/filter registration records) to replay registration and to back the exhaustive `remove_all_*`. +- **`BufferedHookHandler` removals sync WordPress** (`remove_*` / `remove_all_*` also call WP `remove_action`/`remove_filter`, not just mutate the queue) — only adds are buffered; removes are immediate. **`ScopedHookHandler::register_hooks()` is idempotent** — it wires stable `array($buffer, 'flush'/'reset')` callbacks (first-class-callable closures get distinct hashes WP won't de-dup, so repeated registration would stack listeners). - **Version conditionals are total.** `PHPVersionConditional` / `WPVersionConditional` compare the raw runtime version string via `version_compare()` — parsing it into a `Version` VO would throw on a dashless RC or a 4-segment `$wp_version`, and `is_met(): bool` must not throw (it would crash the fail-closed kernel boot). `Version` is kept only for the validated developer-supplied minimum. WPVersion also strips current's pre-release suffix and a trailing `.0` from a 3-part minimum (mirrors `is_wp_version_compatible()`); PHPVersion does not strip (mirrors the total `is_php_version_compatible()`). -- **`PHPIniSizeConditional`** is deliberately size-directive-only: byte-minimum `>=` via `wp_convert_hr_to_bytes()`; `-1` (unlimited) passes; an unknown directive (`ini_get` ⇒ false) is unmet. A blanket byte-`>=` over arbitrary ini directives corrupts boolean/string directives and mis-ranks `-1`, and a generic exact-match probe has no consumer (YAGNI). +- **`PHPIniSizeConditional`** is deliberately size-directive-only: byte-minimum `>=` via `wp_convert_hr_to_bytes()`; `-1` (unlimited) passes; an unknown directive (`ini_get` ⇒ false) is unmet. A blanket byte-`>=` over arbitrary ini directives corrupts boolean/string directives and mis-ranks `-1`, and a generic exact-match probe has no consumer (YAGNI). The ctor validates the minimum as well-formed byte shorthand and the directive as non-empty (`InvalidConditionalConfigurationException`), so a typo'd gate fails at wiring instead of degrading to always-met; `is_met()` stays total. - **AdminNotices `render_one()` keeps passing the raw message to core `wp_admin_notice()`** — core sanitizes the generated markup with `wp_kses_post( wp_get_admin_notice(...) )`. The call is unconditional: `wp_admin_notice()` ships in WP 6.4 and the framework floor is WP 7.0, so there is no fallback path. -- **Scheduling is an ordered-backend-list facade.** `SchedulerBackendInterface` carries `is_ready()` (whether the backend may be consulted for schedule, clear, and query calls); `Scheduler` holds a non-empty `list` in declaration order. Writes target the first ready backend (falling back to the last backend when none is ready); clears consult every ready backend with the first failure in declaration order winning; queries OR/min over ready backends; `register_lifecycle()` runs on ALL backends unconditionally. The consumer states its backends (the `HooksService::$initial_handlers` pattern — there is no factory): an omitted constructor argument yields the always-ready `WPCronBackend` baseline alone, and a consumer preferring Action Scheduler passes `ActionSchedulerBackend` (readiness = injectable probe defaulting to `action_scheduler_is_ready()`) explicitly, first. v1-relapse guard: no `register_backend()` mutator, no caller-side backend IDs, and no `SchedulerInterface` while one facade implementation exists; if the backends ever collapse to one, delete the facade. +- **Scheduling is an ordered-backend-list facade.** `SchedulerBackendInterface` carries `is_ready()` (whether the backend may be consulted for schedule, clear, and query calls); `Scheduler` holds a non-empty `list` in declaration order. Writes target the first ready backend (falling back to the last backend when none is ready); clears consult every ready backend with the first failure in declaration order winning; queries OR/min over ready backends; `register_hooks()` runs on ALL backends unconditionally (forwarded by the facade's own `register_hooks()`). The consumer states its backends (the `HooksService::$initial_handlers` pattern — there is no factory): an omitted constructor argument yields the always-ready `WPCronBackend` baseline alone, and a consumer preferring Action Scheduler passes `ActionSchedulerBackend` (readiness = injectable probe defaulting to `action_scheduler_is_ready()`) explicitly, first. v1-relapse guard: no `register_backend()` mutator, no caller-side backend IDs, and no `SchedulerInterface` while one facade implementation exists; if the backends ever collapse to one, delete the facade. - **deptrac `Core_*` sublayers: SKIPPED.** The committed core is ~3 internal edges across single-interface files in one release granule; deptrac's value is at package boundaries and namespace-surface boundaries (already enforced). Revisit only as `Core_Kernel` (sink) + `Core_Contracts` if doc-as-enforcement is wanted. The Settings namespace surface, by contrast, carries `Settings_Schema` (sink) ← `Settings_Backend` / `Settings_MetaField` sublayers in `deptrac.yaml` — three concept folders with real internal edges worth enforcing. ### Framework i18n: consumer-domain via scope-time textdomain rewrite The framework ships no translation catalogs (i18n-catalogs-drop decision), but user-facing framework strings MUST stay translatable. They are translatable only under the *consumer plugin's* text domain (resolved by the consumer's catalog + WP just-in-time loading) — not a framework-owned domain, and not WP core's `'default'` (which has no entries for custom strings). -The mechanism is a build-time php-scoper patcher (`wordpress-configs` `contrib/wp-framework.inc.php`) that rewrites each framework `wp-framework-` text domain → the consumer's `extra.text-domain` at scope time (v1 did this via `$dws_framework_language_domains`). The `"text-domain"` composer field in the template + every plugin feeds it; framework translatable strings currently span `wp-framework-bootstrap`, `wp-framework-settings`, and `wp-framework-utilities` (including the WPCron synthetic-schedule label), so the patcher is deliberately generic over `wp-framework-*`, not a bootstrap-only find-target. The moved `wp-framework-settings` and `wp-framework-utilities` string literals are safe: scoper discovers vendor packages by glob and rewrites any literal with the `wp-framework-` prefix, independent of the package's current Composer name. Do not "fix" those literals to `wp-framework-infrastructure`. The `consumer-smoke` fixture exercises the rewrite and CI asserts zero residual `wp-framework-*` domains (and unprefixed WC symbols) in the scoped output. +The mechanism is a build-time php-scoper patcher (`wordpress-configs` `contrib/wp-framework.inc.php`) that rewrites each framework `wp-framework-` text domain → the consumer's `extra.text-domain` at scope time (v1 did this via `$dws_framework_language_domains`). The `"text-domain"` composer field in the template + every plugin feeds it. Framework gettext calls use each string's REAL package domain — `wp-framework-bootstrap` and `wp-framework-infrastructure` today (the WPCron synthetic-schedule label included) — and WPCS pins `text_domain` to the five real package names, so a stale or non-literal domain fails lint. The patcher and the lint guarantee are two halves of one contract: WPCS guarantees plain reserved `wp-framework-*` literals, and the patcher rewrites by that reserved SHAPE (`^wp-framework-[a-z0-9_-]+$`), not by installed package basenames, tripping the scope run on any reserved occurrence outside the plain-literal guarantee. The `consumer-smoke` fixture exercises the rewrite and CI asserts zero residual `wp-framework-*` domains (and unprefixed WC symbols) in the scoped output. **The end-to-end runtime path (framework string → rewritten domain → consumer catalog → WP just-in-time loading) stays unproven until a plugin ships** — verify it at the first consumer migration. Do NOT scatter per-package runtime dynamic-domain reads instead — the one scope-time mechanism is the design. @@ -334,7 +336,7 @@ A revived **QR-WC** registers shortcodes via direct `add_shortcode()` — no ser Caching is built ahead under the demand-certain rule. -Final classes, no facade: `TransientCache` (`get`/`set`/`delete`/`remember()`, per-plugin key prefix, versioned-group invalidation) and `ObjectCache` (a wrapper over WP's object cache with false-safe reads + versioned-group invalidation). +Final classes, no facade: `TransientCache` (`get`/`set`/`delete`/`remember()`, per-plugin key prefix, versioned-group invalidation) and `ObjectCache` (a wrapper over WP's object cache with false-safe reads + versioned-group invalidation). Both report `delete(): bool` and version their group through a distinct `*_generation` option (`{prefix}_transient_cache_generation` / `{group}_object_cache_generation` — distinct keys are load-bearing: one prefix string may feed both caches). ### Permissions-as-capabilities: in each plugin's Installer @@ -367,9 +369,9 @@ There is no active `wp-core-calls.json` manifest tooling. The only implementatio The WC half of the settings stack has three classes, mirroring the Settings namespace's pure-core / WP-coupled-shell split: -- **`WCSettingsBuilder`** (pure, WP-free, Infection-covered) — translates a `SettingsPage` descriptor into WooCommerce's settings-array shape: each section → a `title`/`sectionend` group (the page's first editable section renders on WC's default section; each later section is its own native WC sub-tab via `get_own_sections()`), each field → `{id: {slug}_{field}, type, title, default, options?, custom_attributes?}`. Faithful to WC's own render/save expectations (verified against `includes/admin/class-wc-admin-settings.php` + `includes/admin/settings/class-wc-settings-page.php`): a checkbox boolean default → `'yes'`/`'no'` (WC's `checked()` string-compares against `'yes'`); option labels stringified (`is_scalar()?(string):''`, since WC `esc_html()`s them); multiselect defaults stringified (WC's strict `in_array( (string)$key, …, true )`); choice fields always emit an options array (WC iterates it unconditionally); attributes filtered to the `FieldRenderer` allow-list (WC `esc_attr()`s but does not reject `on*` handlers). Field-type tokens pass through unchanged — the framework taxonomy is a verbatim subset of WC's `$types`, so the §8 mapping is identity (no mapper class). -- **`DescriptorBackedWCSettingsPage`** (abstract, `extends \WC_Settings_Page`) — a consumer declares one empty `final` subclass per page; a `static array` map holds descriptors, keyed by the concrete subclass. **A distinct subclass per page is required**: WooCommerce rebuilds settings-page objects each request and recovers them by class name, so the descriptor must be recoverable from a distinct class-string — two plugins ⇒ two subclasses ⇒ no map collision. Allowed abstract base (WC's API forces subclassing — per the "abstract base when pattern intrinsically requires" decision). The constructor sets the WC tab id (`location ?? slug`) and label before `parent::__construct()` wires WC's hooks; an unbound instantiation throws `UnboundSettingsPageException`. Non-default descriptor section ids are keyed and matched through `sanitize_title()`, because WooCommerce round-trips section request values through that normalization before rendering and saving a sub-tab. -- **`WooCommerceSettingsBackend`** (`implements SettingsBackendInterface`) — `__construct( class-string $page_class )` binds the per-page subclass (the locked `register_page(SettingsPage)` signature can't carry it). `register_page()` adds the `woocommerce_get_settings_pages` filter and bridges each field's descriptor `sanitize` onto `woocommerce_admin_settings_sanitize_option_{id}`. `get/set/has/delete` address each field by its own prefixed `wp_options` row (`{slug}_{field}` — WC-native, REST-correct; **not** `OptionsStore`, not a grouped array), `has()` using a sentinel to distinguish a stored value from an absent option; `option_keys( SettingsPage )` — a `SettingsBackendInterface` method — enumerates those per-field rows from the descriptor alone for the consumer's uninstall cleanup, as the WordPress backend enumerates its per-section `{slug}-{section_id}` rows. +- **`WooCommerceSettingsBuilder`** (pure, WP-free, Infection-covered) — translates a `SettingsPage` descriptor into WooCommerce's settings-array shape: each section → a `title`/`sectionend` group (the page's first editable section renders on WC's default section; each later section is its own native WC sub-tab via `get_own_sections()`), each field → `{id: {slug}_{field}, type, title, default, options?, custom_attributes?}`. Faithful to WC's own render/save expectations (verified against `includes/admin/class-wc-admin-settings.php` + `includes/admin/settings/class-wc-settings-page.php`): a checkbox boolean default → `'yes'`/`'no'` (WC's `checked()` string-compares against `'yes'`); option labels stringified (`is_scalar()?(string):''`, since WC `esc_html()`s them); multiselect defaults stringified (WC's strict `in_array( (string)$key, …, true )`); choice fields always emit an options array (WC iterates it unconditionally); attributes filtered to the `FieldRenderer` allow-list (WC `esc_attr()`s but does not reject `on*` handlers). Field-type tokens pass through unchanged — the framework taxonomy is a verbatim subset of WC's `$types`, so the §8 mapping is identity (no mapper class). +- **`DescriptorBackedWooCommerceSettingsPage`** (abstract, `extends \WC_Settings_Page`) — a consumer declares one empty `final` subclass per page; a `static array` map holds descriptors, keyed by the concrete subclass. **A distinct subclass per page is required**: WooCommerce rebuilds settings-page objects each request and recovers them by class name, so the descriptor must be recoverable from a distinct class-string — two plugins ⇒ two subclasses ⇒ no map collision. Allowed abstract base (WC's API forces subclassing — per the "abstract base when pattern intrinsically requires" decision). The constructor sets the WC tab id (`location ?? slug`) and label before `parent::__construct()` wires WC's hooks; an unbound instantiation throws `UnboundSettingsPageException`. Non-default descriptor section ids are keyed and matched through `sanitize_title()`, because WooCommerce round-trips section request values through that normalization before rendering and saving a sub-tab. +- **`WooCommerceSettingsBackend`** (`implements SettingsBackendInterface`) — `__construct( class-string $page_class, ?LoggerInterface $logger = null )` binds the per-page subclass (the locked `register_page(SettingsPage)` signature can't carry it). `register_page()` throws on a sectionless page (`InvalidSettingsPageException` — the authoring-time seam; the per-user capability projection may still legitimately empty a page at render), warns through the optional logger when it runs after `woocommerce_get_settings_pages` already fired (mirroring the WP backend's `admin_menu` diagnostic), adds the `woocommerce_get_settings_pages` filter and bridges each field's descriptor `sanitize` onto `woocommerce_admin_settings_sanitize_option_{id}`. `get/set/has/delete` address each field by its own prefixed `wp_options` row (`{slug}_{field}` — WC-native, REST-correct; **not** `OptionsStore`, not a grouped array), `has()` using a sentinel to distinguish a stored value from an absent option; `option_keys( SettingsPage )` — a `SettingsBackendInterface` method — enumerates those per-field rows from the descriptor alone for the consumer's uninstall cleanup, as the WordPress backend enumerates its per-section `{slug}-{section_id}` rows. **Binding is deferred into the filter callback.** WooCommerce's autoloader does **not** resolve `WC_Settings_Page` (`WC_Autoloader::autoload` maps `wc_settings_page` to `includes/class-wc-settings-page.php`, but the file is at `includes/admin/settings/`); only `WC_Admin_Settings::get_settings_pages()` includes it, just before applying the filter. So `register_page()` must not touch the page subclass at `plugins_loaded` — it would fatal on the missing parent. `bind()` + `new $page_class()` therefore run inside the `woocommerce_get_settings_pages` callback. @@ -379,19 +381,21 @@ The WC half of the settings stack has three classes, mirroring the Settings name **deptrac:** `WooCommerce: [Core, Shared, Storage, Settings_*]` — the Settings sublayers for the contract + descriptors + reused exceptions; Shared for `UnboundSettingsPageException`'s base; Storage only for the `ObjectMeta` repository contract `OrderData/OrderMetaRepository` implements — the settings backend itself stays on WC-native options, not `OptionsStore`. -### WC product-data settings tab: final store + reused descriptors, native render, framework-owned save, dual default-injection +### WC product-data settings tab: final surface + reused descriptors, native render, framework-owned save, dual default-injection `woocommerce/src/ProductData/` (namespace `…\WooCommerce\ProductData`) holds a final engine + descriptors for product-data panels: - **`ProductDataTab`** (`final readonly`) — descriptor: `slug`, `label`, `meta_key_prefix`, `sections` (reusing `Settings\…\SettingsSection`, whose fields are `SettingsField`), `classes` (`list|Closure`), `priority` (65), an optional `supports_product` gate, and a `custom_renderers` type→closure registry. An invalid slug or WordPress-global meta key prefix throws `InvalidProductDataTabException`. -- **`ProductDataFieldRenderer`** (`final`) — pure `args()` maps a `SettingsField` to the `woocommerce_wp_*` arg array; `render()` dispatches to the native control. WC renders its own (the settings `FieldRenderer` docblock says so), so the tab renders with the product panel's markup — NOT `FieldRenderer`. A checkbox value normalizes to WC's `yes`/`no`; multiselect gains `[]`/`multiple`; `on*`/malformed attributes are filtered (the allow-list `WCSettingsBuilder` enforces). -- **`ProductDataFieldStore`** (`final`) — the engine; one store drives one tab. `register_tab()` wires the three product hooks (tabs/panels/`process_product_meta`) plus the two default filters; field-addressed `get/set/has/delete`; `meta_keys()` for uninstall. +- **`ProductDataFieldRenderer`** (`final`) — pure `args()` maps a `SettingsField` to the `woocommerce_wp_*` arg array; `render()` dispatches to the native control. WC renders its own (the settings `FieldRenderer` docblock says so), so the tab renders with the product panel's markup — NOT `FieldRenderer`. A checkbox value normalizes to WC's `yes`/`no`; multiselect gains `[]`/`multiple`; `on*`/malformed attributes are filtered (the allow-list `WooCommerceSettingsBuilder` enforces). +- **`ProductDataFieldSurface`** (`final`) — the engine; one surface drives one tab. `register_tab()` wires the three product hooks (tabs/panels/`process_product_meta`) plus the two default filters; field-addressed `get/set/has/delete` (addressed `( section_id, product_id, field_id )` — descriptor first, like every field surface); `meta_keys()` for uninstall. + +**Persistence is WC CRUD, never raw post meta:** the surface's CRUD verbs and panel render go through `wc_get_product()` + `WC_Data` meta methods (`get_meta`/`update_meta_data`/`delete_meta_data` + `save()`), because product data is not guaranteed to live in post meta. `has()` keeps its real-stored-value semantics by filtering out injected `meta_id=0` rows (the same discrimination the before-save strip uses) — `WC_Data::meta_exists()` would count them. The three default-injection filters are the ONE deliberately postmeta-coupled remnant (they hook `default_post_metadata` + the CPT datastore's read filter); re-entry trigger: a non-postmeta WC product datastore needs a new injection seam only — the CRUD paths are already datastore-agnostic. -**Dual default-injection is the must-not-drop behavior:** the store registers BOTH `default_post_metadata` (covers `get_post_meta`, honors `$single`, and returns one default row for non-single reads) and the dynamic `woocommerce_data_store_wp_post_read_meta` (`meta_type='post'`; splices a synthetic `meta_id=0` row into `WC_Product`'s bulk read), so a product predating a field renders its descriptor default instead of a blank. The value comes from `SettingsField::$default` (a checkbox default normalized to `yes`/`no`); gated on **field membership / missing owned key (cheap) before product support (costly)** and on field existence, not truthiness. **Injection is a read concern, NOT capability-gated**; render + save are. +**Dual default-injection is the must-not-drop behavior:** the surface registers BOTH `default_post_metadata` (covers `get_post_meta`, honors `$single`, and returns one default row for non-single reads) and the dynamic `woocommerce_data_store_wp_post_read_meta` (`meta_type='post'`; splices a synthetic `meta_id=0` row into `WC_Product`'s bulk read), so a product predating a field renders its descriptor default instead of a blank. The value comes from `SettingsField::$default` (a checkbox default normalized to `yes`/`no`); gated on **field membership / missing owned key (cheap) before product support (costly)** and on field existence, not truthiness. **Injection is a read concern, NOT capability-gated**; render + save are. -**Save is framework-owned**, unlike the WC settings page (where `WC_Admin_Settings` saves): WooCommerce verifies the product-edit nonce + `edit_post` before `woocommerce_process_product_meta` fires, so the store re-checks neither — but it DOES gate each field on `SettingsField::$capability`. Taxonomy fields process through the reused `FieldProcessor`; a checkbox stores `yes`/`no` (its submit convention); a non-taxonomy "custom" field renders via `custom_renderers[type]` and saves via its own `sanitize`. Every editable field is written and the product saved once, so a field left at its default holds a real value after the first save. +**Save is framework-owned**, unlike the WC settings page (where `WC_Admin_Settings` saves): WooCommerce verifies the product-edit nonce + `edit_post` before `woocommerce_process_product_meta` fires, so the surface re-checks neither — but it DOES gate each field on `SettingsField::$capability`. Taxonomy fields process through the reused `FieldProcessor`; a checkbox stores `yes`/`no` (its submit convention); a non-taxonomy "custom" field renders via `custom_renderers[type]` — or via a `CustomFieldType` registered on `ProductDataFieldRenderer`'s ctor (render-only bridge, shared with the WP surfaces; the tab-level closure wins for the same type token) — and saves via its own `sanitize`, which stays required for custom types either way. Every editable field is written and the product saved once, so a field left at its default holds a real value after the first save. -**CRUD semantics:** `has()` reports a *real* stored value (`metadata_exists`, excluding the injected default); `get()` returns the effective value (stored, or the injected default while none is stored). Meta key = `{meta_key_prefix}{section_id}_{field_id}`, or a per-field `SettingsField::$meta_key` override for byte-exact legacy keys. A duplicate resolved meta key throws `DuplicateSettingsFieldException`. **Uninstall stays the consumer's `InstallerInterface` concern** — the store owns no `uninstall()`; `meta_keys()` exposes the exact key set. +**CRUD semantics:** `has()` reports a *real* stored value (persisted-id check, excluding the injected default); `get()` returns the effective value (stored, or the injected default while none is stored). Meta key = `{meta_key_prefix}{section_id}_{field_id}`, or a per-field `SettingsField::$meta_key` override for byte-exact legacy keys. A duplicate resolved meta key throws `DuplicateSettingsFieldException`. **Uninstall stays the consumer's `InstallerInterface` concern** — the surface owns no `uninstall()`; `meta_keys()` exposes the exact key set. **Cross-package surface:** `SettingsField` carries an optional `description`, rendered by the WP-backed `FieldRenderer` too (WP options page + object meta box). `desc_tip` stays WC-only (defaulted true in the product renderer). `php-stubs/woocommerce-stubs` is in the woocommerce package's php-scoper `scoping-stubs` (the product-data + WC settings code reference WC symbols a consumer's scoper must leave external). @@ -399,7 +403,7 @@ The WC half of the settings stack has three classes, mirroring the Settings name ### Component grammar — interface placement -One rule replaces three improvised conventions. An interface lives at the ROOT of the concept folder it names; concrete variants live in plural sub-bags beneath it (`Handlers/`, `Stores/`, `Backends/`, `ValueObjects/`, `Exceptions/`). A namespace-root concept keeps its contract flat at that namespace's `src//` root (e.g. Storage: `KeyValueStoreInterface` beside its stores), and any additional concept the namespace carries nests as a concept folder (Storage's `ObjectMeta/`). The locked core entrypoint pair (`PluginInterface` / `PluginKernel`) is the one explicit flat exception; do not generalize it. There is NO generic `Contracts/` subfolder — an interface is the concept's root type, not a nested artifact. Placement test for a new interface: does its concept share the namespace surface with other concepts? → concept-folder root. Is the namespace surface's namesake a single concept (or the locked entrypoint)? → namespace root. A plural bag nests only at ≥2 concretes — a lone concrete stays beside its contract (`WordPressSettingsBackend` in `Backend/`, `MetadataRepository` in `ObjectMeta/`); the three MetaField surface stores nest in `MetaField/Stores/`. Applied: `HookHandlerInterface` sits at `Hooks/HookHandlerInterface`, with its implementations in `Hooks/Handlers/`. (Settings' `Schema` concept is organized into `Field/`, `Options/`, and `Aggregation/` concept folders — the field-type/render/process classes, the options resolver + provider, and the field aggregator + provider respectively — with `ValueObjects/`/`Exceptions/`/`Errors/` as its plural bags.) +One rule replaces three improvised conventions. An interface lives at the ROOT of the concept folder it names; concrete variants live in plural sub-bags beneath it (`Handlers/`, `Surfaces/`, `Backends/`, `ValueObjects/`, `Exceptions/`). A namespace-root concept keeps its contract flat at that namespace's `src//` root (e.g. Storage: `KeyValueStoreInterface` beside its stores), and any additional concept the namespace carries nests as a concept folder (Storage's `ObjectMeta/`). The locked core entrypoint pair (`PluginInterface` / `PluginKernel`) is the one explicit flat exception; do not generalize it. There is NO generic `Contracts/` subfolder — an interface is the concept's root type, not a nested artifact. Placement test for a new interface: does its concept share the namespace surface with other concepts? → concept-folder root. Is the namespace surface's namesake a single concept (or the locked entrypoint)? → namespace root. A plural bag nests only at ≥2 concretes — a lone concrete stays beside its contract (`WordPressSettingsBackend` in `Backend/`, `MetadataRepository` in `ObjectMeta/`); the three MetaField field surfaces nest in `MetaField/Surfaces/`. Applied: `HookHandlerInterface` sits at `Hooks/HookHandlerInterface`, with its implementations in `Hooks/Handlers/`. (Settings' `Schema` concept is organized into `Field/`, `Options/`, and `Aggregation/` concept folders — the field-type/render/process classes, the options resolver + provider, and the field aggregator + provider respectively — with `ValueObjects/`/`Exceptions/`/`Errors/` as its plural bags.) ### Component grammar — value object vs descriptor @@ -435,7 +439,7 @@ Enforcement: each public operation that returns a `Result` carries `#[\NoDiscard Two Infection profiles. The default (`composer test:unit:mutation`, `infection.json`) mutates the Unit-covered code from the strict root PHPUnit config. A second (`composer test:integration:mutation`, `infection.integration.json`) mutates the Integration-covered code (the WC/settings backends, object-field/order/product stores, Storage, Utilities) inside the wp-env `cli` container. -The Integration profile points at a **non-strict** PHPUnit config (`tests/mutation/phpunit.dist.xml`, the coverage-metadata strictness pair off): integration tests boot WP and traverse broad core stacks, so under coverage they "execute undeclared code" en masse; Infection mutates from real line coverage, not Covers/Uses metadata, and a risky-flagged test counted as a kill would inflate MSI. Only the coverage-metadata strictness pair is relaxed (`requireCoverageMetadata` and `beStrictAboutCoverageMetadata` both off) — `failOnRisky` and `failOnWarning` stay true, and the canonical `composer test:integration` (root config) stays strict + fail-on-risky. `Backend/DescriptorBackedWCSettingsPage` is excluded from this profile: it `extends \WC_Settings_Page`, which WC's autoloader does not resolve, so Infection's static analysis (WP not booted) fatals on the unresolved parent; the exclude drops it from mutation scoring only, not its integration tests (it is the sole `extends \WC_*`/`\WP_*` class in src). +The Integration profile points at a **non-strict** PHPUnit config (`tests/mutation/phpunit.dist.xml`, the coverage-metadata strictness pair off): integration tests boot WP and traverse broad core stacks, so under coverage they "execute undeclared code" en masse; Infection mutates from real line coverage, not Covers/Uses metadata, and a risky-flagged test counted as a kill would inflate MSI. Only the coverage-metadata strictness pair is relaxed (`requireCoverageMetadata` and `beStrictAboutCoverageMetadata` both off) — `failOnRisky` and `failOnWarning` stay true, and the canonical `composer test:integration` (root config) stays strict + fail-on-risky. `Backend/DescriptorBackedWooCommerceSettingsPage` is excluded from this profile: it `extends \WC_Settings_Page`, which WC's autoloader does not resolve, so Infection's static analysis (WP not booted) fatals on the unresolved parent; the exclude drops it from mutation scoring only, not its integration tests (it is the sole `extends \WC_*`/`\WP_*` class in src). Per-profile `minCoveredMsi` floors guard regressions. The coverage driver in the `cli` container is the docker-official PHP's pcov (the Alpine system PHP's `php85-pecl-pcov` targets the wrong binary); `test:integration:mutation` preflights for a driver and names the fix. Both profiles run in the weekly tests-mutation workflow; the Integration job rides reusable-phpunit's `wp-env-xdebug: coverage` input (pcov stays the local-dev route per the preflight). @@ -445,15 +449,15 @@ PHPStan analyses against `php-stubs/wordpress-stubs` 7.0.0, pulled via a Compose ### OrderData / ProductData naming: per-domain meta surfaces, kept -The woocommerce package groups its field machinery under `OrderData/` (`OrderFieldStore` + `OrderMetaRepository`) and `ProductData/` (`ProductDataTab` + `ProductDataFieldRenderer` + `ProductDataFieldStore`). The folder names denote the WooCommerce domain object whose meta the folder manages — the order vs. the product — not a layer; they are the concept folders for two distinct meta surfaces with different WC integration points (HPOS order screens vs. product-data panels). Kept as-is: the `Store`/`Repository`/`Tab`/`Renderer` suffixes carry the role within each. +The woocommerce package groups its field machinery under `OrderData/` (`OrderFieldSurface` + `OrderMetaRepository`) and `ProductData/` (`ProductDataTab` + `ProductDataFieldRenderer` + `ProductDataFieldSurface`). The folder names denote the WooCommerce domain object whose meta the folder manages — the order vs. the product — not a layer; they are the concept folders for two distinct meta surfaces with different WC integration points (HPOS order screens vs. product-data panels). The `Surface`/`Repository`/`Tab`/`Renderer` suffixes carry the role within each; `OrderMetaRepository` composes `MetadataRepository( MetaType::Post )` as its non-order fallback rather than inlining it. ### MetaField: object-meta repositories in storage, shared form engine, and surface stores -`infrastructure/src/Storage/ObjectMeta/` holds the object-meta persistence contract: `ObjectMetaRepositoryInterface` — its signatures speak only object id, meta key, and value (plus the batch `apply()`), settings-free and reusable for any per-object persistence — with the WordPress core metadata-backed `MetadataRepository` and the `MetaType` enum beside it. `infrastructure/src/Settings/MetaField/` holds the shared form machinery over that contract: `ObjectFieldForm`, the shared render/save engine for object-field surfaces, and the surface stores in `MetaField/Stores/` — `PostMetaFieldStore`, `TermFieldStore`, and `UserProfileFieldStore` over post, term, and user meta; the woocommerce package provides `OrderData/OrderFieldStore` over order meta (its `OrderMetaRepository` implements the storage contract) and `ProductData/ProductDataFieldStore` for product-data panels. The four group-registering stores also expose descriptor-addressed CRUD — `get`/`set`/`has`/`delete( FieldGroup, object id, field id )` over the same storage keys the form path resolves (single-sourced in `ObjectFieldForm::meta_key_of()`) and its store-or-revoke semantics (checkbox canonicalization, revoke-on-empty); a CRUD write is programmatic — the descriptor's sanitize/validate seam applies to form submissions only, and on the form path the processed value is stored verbatim (validation is the final transformation before persistence) — plus `meta_keys( FieldGroup )` for the consumer's uninstall cleanup, evaluated through the group's fields provider at object id 0; reads never fall back to the field default. +`infrastructure/src/Storage/ObjectMeta/` holds the object-meta persistence contract: `ObjectMetaRepositoryInterface` — its signatures speak only object id, meta key, and value (plus the batch `apply()`), settings-free and reusable for any per-object persistence — with the WordPress core metadata-backed `MetadataRepository` and the `MetaType` enum beside it. `infrastructure/src/Settings/MetaField/` holds the shared form machinery over that contract: `ObjectFieldForm`, the shared render/save/CRUD engine for object-field surfaces, and the field surfaces in `MetaField/Surfaces/` — `PostMetaFieldSurface`, `TermFieldSurface`, and `UserProfileFieldSurface` over post, term, and user meta; the woocommerce package provides `OrderData/OrderFieldSurface` over order meta (its `OrderMetaRepository` implements the storage contract) and `ProductData/ProductDataFieldSurface` for product-data panels. The four group-registering surfaces also expose descriptor-addressed CRUD — `get`/`set`/`has`/`delete( FieldGroup, object id, field id )`, each verb a one-line delegate to `ObjectFieldForm` (the engine owns all four: key resolution via `meta_key_of()`, store-or-revoke `set()`, repository-backed reads) with its store-or-revoke semantics (checkbox canonicalization, revoke-on-empty); a CRUD write is programmatic — the descriptor's sanitize/validate seam applies to form submissions only, and on the form path the processed value is stored verbatim (validation is the final transformation before persistence) — plus `meta_keys( FieldGroup )` for the consumer's uninstall cleanup, evaluated through the group's fields provider at object id 0; reads never fall back to the field default. Meta keying is delegated per field: `FieldGroup` carries no meta-key prefix — a field stores under its `meta_key` override or bare id, so prefixed, collision-safe storage keys in the shared meta table are the consumer's per-field responsibility (the form engine rejects duplicate storage keys only within a group). `ProductDataTab` is the prefixed surface: its `meta_key_prefix` is validated at construction. -Object-field save semantics are revoke-based. An absent or empty submission deletes the meta key; a present-but-invalid submission preserves the prior value. A present checkbox submission is normalized to the canonical `yes`/`no` string by the field processor before its sanitize/validate seam runs; the processed value — a custom sanitizer's output included — is what gets stored. Built-in semantic field types are sanitized by default through `wordpress_field_type_sanitizers()`. `TermFieldStore` covers both native term surfaces: `{taxonomy}_add_form_fields` / `created_{taxonomy}` and `{taxonomy}_edit_form_fields` / `edited_{taxonomy}`. +Object-field save semantics are revoke-based. An absent or empty submission deletes the meta key; a present-but-invalid submission preserves the prior value. A present checkbox submission is normalized to the canonical `yes`/`no` string by the field processor before its sanitize/validate seam runs; the processed value — a custom sanitizer's output included — is what gets stored. Built-in semantic field types are sanitized by default through `wordpress_field_type_sanitizers()`. `TermFieldSurface` covers both native term surfaces: `{taxonomy}_add_form_fields` / `created_{taxonomy}` and `{taxonomy}_edit_form_fields` / `edited_{taxonomy}`. ### Settings REST exposure: section-level opt-in, type-only schema @@ -463,22 +467,68 @@ The generated REST schema is **type-only**: a choice field is typed by its value ### Identifier validation: single-sourced free functions -A user-supplied identifier reused as a storage key, form-field-name segment, nonce key, or DOM id is validated against a single-sourced charset free function at every construction site, so a malformed id fails at wiring time rather than at use. The two charset predicates live in `shared/Identifier/functions.php` — `Shared\Identifier\is_valid_identifier` (a lowercase letter, then lowercase `a-z`, digits, `_`, `-`) and `Shared\Identifier\is_valid_global_name_prefix` (optional leading underscore, then the same charset) — and every consumer imports them via `use function`; there are no per-package mirrors. Validators return bool; throwing stays consumer-local (`utilities`' `Exceptions/InvalidGlobalNamePrefixException`; the settings/WooCommerce descriptors throw their own invalidity exceptions). `is_valid_identifier` gates the settings descriptor family (`SettingsPage`/`SettingsSection`/`SettingsField`/`CustomFieldType`/`FieldGroup`) and `ProductDataTab::$slug`; `is_valid_global_name_prefix` gates derived WordPress-global names — `ProductDataTab::$meta_key_prefix`, the `TransientCache` ctor (key prefix, which also feeds the generation option key), the `ObjectCache` ctor (group + generation option key), and the `AdminNoticesService` ctor (the dismiss action feeding the `wp_ajax_` hook name). `utilities` also uses `AdminNotices\is_valid_notice_id` (lowercase `a-z`, digits, `_`, `-` — matching `sanitize_key`'s retained set, so a passing id round-trips WP's `data-dismissible` dismissal key unchanged). An `AdminNotice` id is validated at the `AdminNotice` ctor, the `AdminNoticeLogger` ctor (so a bad id throws at logger construction, before the fail-closed kernel-boot installer path that calls the logger), and explicit `DependencyRequirement` ids; a derived `dep_…` id is provably within the charset by construction, so it never throws. +A user-supplied identifier reused as a storage key, form-field-name segment, nonce key, or DOM id is validated against a single-sourced charset free function at every construction site, so a malformed id fails at wiring time rather than at use. The two charset predicates live in `shared/Identifier/functions.php` — `Shared\Identifier\is_valid_identifier` (a lowercase letter, then lowercase `a-z`, digits, `_`, `-`) and `Shared\Identifier\is_valid_global_name_prefix` (optional leading underscore, then the same charset) — and every consumer imports them via `use function`; there are no per-package mirrors. Validators return bool; throwing stays consumer-local (`utilities`' `Exceptions/InvalidGlobalNamePrefixException`; the settings/WooCommerce descriptors throw their own invalidity exceptions). `is_valid_identifier` gates the settings descriptor family (`SettingsPage`/`SettingsSection`/`SettingsField`/`CustomFieldType`/`FieldGroup`), `ProductDataTab::$slug`, `MetaBoxPlacement::$screen` (hook-interpolated, with context/priority validated against their closed sets), and `PluginHeader`'s derived slug (`InvalidPluginHeaderException`, VO family); `is_valid_global_name_prefix` gates derived WordPress-global names — `ProductDataTab::$meta_key_prefix`, the `TransientCache` ctor (key prefix, which also feeds the generation option key), the `ObjectCache` ctor (group + generation option key), and the `AdminNoticesService` ctor (the dismiss action feeding the `wp_ajax_` hook name). `utilities` also uses `AdminNotices\is_valid_notice_id` (lowercase `a-z`, digits, `_`, `-` — matching `sanitize_key`'s retained set, so a passing id round-trips WP's `data-dismissible` dismissal key unchanged). An `AdminNotice` id is validated at the `AdminNotice` ctor, the `AdminNoticeLogger` ctor (so a bad id throws at logger construction, before the fail-closed kernel-boot installer path that calls the logger), and explicit `DependencyRequirement` ids; a derived `dep_…` id is provably within the charset by construction, so it never throws. ### composer-require-checker: declared-dependency completeness, the third boundary axis `composer lint:php` runs a third dependency-boundary check beside PHPStan (symbol existence under the loaded stubs) and deptrac (internal package edges): `maglnet/composer-require-checker` verifies every package declares each Composer-dependency symbol it uses, catching a package that reaches a transitively-installed dependency without a direct `require`. -The packages install only through the monorepo root vendor (they are path repositories there), so `bin/composer-require-check.php` checks each one against that root vendor: it points `packages//vendor` at the root vendor for the duration of the check, derives the WordPress/WooCommerce stub files to treat as host symbols from the package's own `extra.scoping-stubs` (the source the scoper already reads), and layers the `composer-require-checker.json` allow-list on top. `scan-files` feeds a scanned stub into BOTH the defined and the used symbol sets, so the comprehensive WordPress/WooCommerce stubs surface their own external references (PHP-extension classes, WordPress runtime constants, PSR contracts, WooCommerce internals); the allow-list clears exactly those host symbols. Infrastructure keeps its `php-stubs/woocommerce-stubs:woocommerce-packages-stubs.php` declaration for exactly this host-symbol derivation (Utilities' `as_*` calls); the *scoping exclusion* for `as_*` does not depend on it — wordpress-configs ships and self-declares its own Action Scheduler catalog, so the exclusion survives a consumer dropping the WooCommerce stubs. +The packages install only through the monorepo root vendor (they are path repositories there), so `bin/composer-require-check.php` checks each one against that root vendor: it points `packages//vendor` at the root vendor for the duration of the check, derives the WordPress/WooCommerce stub files to treat as host symbols from the package's own `extra.scoping-stubs` (the source the scoper already reads), and layers the `composer-require-checker.json` allow-list on top (plus `composer-require-checker.woocommerce.json`, keyed to the WooCommerce stub surface and applied only to packages that declare those stubs). `scan-files` feeds a scanned stub into BOTH the defined and the used symbol sets, so the comprehensive WordPress/WooCommerce stubs surface their own external references (PHP-extension classes, WordPress runtime constants, PSR contracts, WooCommerce internals); the allow-list clears exactly those host symbols. Infrastructure keeps its `php-stubs/woocommerce-stubs:woocommerce-packages-stubs.php` declaration for exactly this host-symbol derivation (Utilities' `as_*` calls); the *scoping exclusion* for `as_*` does not depend on it — wordpress-configs ships and self-declares its own Action Scheduler catalog, so the exclusion survives a consumer dropping the WooCommerce stubs. **No framework symbol is ever whitelisted** — a framework symbol used across a package boundary must resolve through a declared `require`, not the allow-list, and `psr/log` / `psr/container` are declared requires deliberately absent from the allow-list so an undeclared use of either still fails. Two framework free functions are therefore made resolvable for static analysis rather than whitelisted: a same-namespace free-function call carries the `namespace\` prefix (a bare call is recorded by the analyzer as a global symbol and never matches the namespaced definition — `shared`'s `convert_to_primitives` calls `namespace\get_public_property_names`); and `bootstrap`, the only functions-only package (no psr-4, so its `src/` is otherwise unscanned), declares a `classmap` for `src/` so its public functions are discoverable through its own autoload. The `files` aggregator still loads them at runtime, and a class-free `src/` yields an empty class map, so the `classmap` is inert at runtime — it exists purely to expose the functions to the analyzer. Keep `bootstrap/src` class-free: a class added there is already loaded by the aggregator's `require_once`, so its classmap entry would be a redundant, confusing duplicate. `Psr\Http\*` and `Psr\SimpleCache\CacheInterface` are the only allow-list entries that are PHP-FIG standard contracts mapping to a `psr/*` package a framework package would declare if it adopted them (the WordPress / WooCommerce / Action Scheduler symbols are runtime-host symbols, never a framework `require`); they are present solely because the WordPress stub references them, and the framework uses neither. If a package ever genuinely depends on a PSR-18 HTTP client or PSR-16 cache, drop the matching entry and declare the package so the check stays honest. +### Naming rules: Store vs Surface, the Settings prefix, and host tokens + +Three rules, settled after an industry survey (ACF, MetaBox, CMB2, Carbon Fields, Fieldmanager, Pods, WC core — zero surveyed projects use "Store" for the hook-registering/rendering/saving role; every surveyed Store-family name is passive persistence): + +- **`Store` = passive key-value persistence only** (`MemoryStore`/`OptionsStore`/`UserMetaStore`, `NoticeStore`, `KeyValueStoreInterface`). The class that mounts a field group onto one WP admin surface — registration, render, save, CRUD, cleanup — is a **`*FieldSurface`** (`PostMetaFieldSurface`, `TermFieldSurface`, `UserProfileFieldSurface`, `OrderFieldSurface`, `ProductDataFieldSurface`; folder `MetaField/Surfaces/`). "Surface" is a deliberate coinage: WordPress has no cross-surface idiom (every WP fields library coins its own — Hookup, Container, Context, Form), and the borrowed candidates all collide with existing vocabulary here (PSR-11 container, `Conditionals/Context/`, `ObjectFieldForm`). Rename, don't split: the industry's two-class idiom (Carbon Container+Datastore, WC MetaBox+DataStore) is already satisfied by the surface/repository split. +- **The `Settings` class-name prefix marks the descriptor family and types named FOR it** (`SettingsField`/`SettingsPage`/`SettingsSection`; `SettingsFieldAggregator`, `SettingsFieldProviderInterface` — they aggregate/provide SettingsField objects). Generic machinery stays bare (`FieldType`, `FieldProcessor`, `FieldRenderer`, `OptionsResolver`, `OptionsProviderInterface`, `CustomFieldType`). +- **Host tokens are per-token, not per-role: `WooCommerce` spells out everywhere; `WP` abbreviates everywhere** (`WooCommerceSettingsBuilder`, `DescriptorBackedWooCommerceSettingsPage`, `WooCommerceVersionConditional` vs `WPVersionConditional`). Test fixtures follow the same rule. + +### Wiring verb: register_hooks() everywhere + +One verb for one-time WordPress self-wiring: `register_hooks()` — on kernel components (`HookableInterface`), on services (`AdminNoticesService`), on facade members (`SchedulerBackendInterface`, `HookHandlerInterface` — a member with nothing to wire implements it empty), and on the facades themselves, which forward to every member (`Scheduler`, `HooksService`), so a composed member never needs out-of-band wiring. There is no `register_lifecycle()`; "lifecycle" is reserved for the kernel's component-lifecycle vocabulary (`PluginKernel::register_lifecycle_hooks()` — the activation/deactivation wiring — keeps its name: it registers WP lifecycle hooks, a different concept). + +### Failure channel: framework misuse throws the framework family + +A misuse fixable only in code throws a framework exception, at the earliest seam that can see it: + +- **Unknown named targets throw** `Unknown*Exception extends AbstractRuntimeException` (`UnknownHookHandlerException`, `UnknownNoticeStoreException` — `add_notice()` and explicit-store `remove_notice()` included; there is no `_doing_it_wrong()` soft path and no bare SPL throw in Utilities). +- **Fail at wiring, not at hook time:** a consumer that will hit a named target inside a WP hook validates at construction (`DependencyAdminNoticeRenderer` validates its store in the ctor — a render-time throw would fatal every admin request). +- **PSR's `InvalidArgumentException` survives only where PSR-3 mandates it** — level vocabulary (the `log()`-time check and, for domain coherence, the ctor minimum-level check in `AdminNoticeLogger`/`WooCommerceLogger`). Everything else in those ctors throws framework types (`InvalidNoticeIdentifierException`, `UnknownNoticeStoreException`). +- **shared may throw its own concretes** (`Reflection/Exceptions/CyclicObjectGraphException`); the deferred-concretes rule means concretes are added AT the first throw site, never worked around with SPL/PSR types. +- **Every kernel-dispatched lifecycle verb has a consumer-contract exception type** (`InitializationException`, `HookRegistrationException`, the five installer types, the two rendering types). All are `@throws` vocabulary; the kernel branches only on `FeatureException`. +- **Throw-message grammar:** offending values single-quoted, sentences end with a terminal period. `InvalidValueObjectException`'s template carries no terminal punctuation; every reason string supplied by a subclass ends with a period (the recorded invariant on the template param). + +### Service trio: member identity and mutability + +All three composed services are `final readonly` with members fixed at construction via the constructor-parameter-default idiom (omitted argument ⇒ the documented default member; explicit `array()` ⇒ none, except Scheduler which requires ≥1). Member identity follows the member's nature: **reusable strategies self-identify** (`HookHandlerInterface::$id`, `DEFAULT_ID` consts on the members); **interchangeable containers are caller-keyed** (`AdminNoticesService::$stores`, `DEFAULT_STORE` const on the service); **an ordered failover chain is anonymous** (`Scheduler` — the recorded v1-relapse guard). No post-construction mutators anywhere; the public readonly map/property is the lookup. + +### Field-surface CRUD: three recorded address tuples, unification deferred + +One verb family (`get`/`set`/`has`/`delete`, `$default_value`, `void` set, `bool` delete) across five surfaces, with three addressing tuples that follow binding physics — a settings backend binds its page at registration (`get( field_id )`), a group surface serves many groups (`get( FieldGroup, object_id, field_id )`), a product surface binds one tab (`get( section_id, product_id, field_id )`). Argument order is uniformly descriptor → object → field. Cleanup enumeration stays surface-typed (`option_keys( SettingsPage )` / `meta_keys( FieldGroup )` / `meta_keys()`). A unified `FieldAddress`-style programmatic-CRUD layer was evaluated and deferred: re-enter only at real substitutability demand — the first consumer writing a polymorphic sweep over multiple surfaces (the likely case is a wave-2 uninstall pass). + +### Style rules settled at the 2026-07 consistency remediation + +- **Bool properties** use the WP API's term where one exists (`show_in_rest`, `autoload`), a bare adjective otherwise (`dismissible`, `persistent`, `required`); `is_*` is method grammar (`is_met()`, `is_enabled()`). +- **VO construction:** named `from_*` factories only when construction parses an alternate representation (`Version::from_string`/`from_parts`); otherwise a plain public ctor that validates (`PluginHeader`, `AdminNotice`, `MetaBoxPlacement`). +- **Protected helper predicates may keep natural names** (`are_conditionals_met()`, `hook_tables_match()`); the `is_*` rule binds the public surface. +- **Exception classes carry no region markers** — the one-line-body family is exempt from the region convention. +- **Bootstrap keeps `@return void`** where PHP 5.6 forbids a native `: void`: the tag is load-bearing for PHPStan level 8, not decorative. +- **`SettingsField` is capped:** a new surface-specific flag goes on the surface descriptor (`ProductDataTab`, `FieldGroup`, `MetaBoxPlacement`), not on the shared field descriptor. +- **Stateless helpers take one of three shapes:** a static-method final class for a consumer-facing generic utility bag (`Helpers/Arrays`, `Helpers/Assets`); namespaced free functions for concept-local predicates/probes usable pre-instance (`Identifier`, `Schema`, `AdminNotices`, `Scheduling` function files); a zero-state `final readonly` instance class for an injectable/typehintable collaborator (`DeprecatedHooksDispatcher`, `CapabilityRegistrar`). +- **Constructor docblocks are "Constructor."** — everywhere, the conditionals bag included. + +### Test conventions + +Tests mirror `src/` structure within each suite root (infrastructure's `tests//` inversion is forced by the frozen namespaces and stays). Shared machinery lives in per-package `tests/Support/` traits — `CreatesUsers`, `IsolatesHooks` (const-driven `$wp_filter` snapshot/restore), `RequiresWooCommerce`, `NormalizesHookTables` — and cross-package test imports resolve through the root autoload-dev (tests only run from the monorepo root), so woocommerce imports infrastructure's traits and fixtures rather than copying them. The three meta-field surface unit suites extend `ObjectFieldSurfaceContractTestCase` (not glob-collected — no `Test.php` suffix); the order surface stays standalone (extending the contract would invert package direction in the split mirrors). Integration tests carry exhaustive `#[UsesClass]`/`#[UsesFunction]` lists in every package. Tests ship no `index.php` guards. Real instances + file-local recording spies over PHPUnit mocks; `test_snake_case` methods; `#[DataProvider]` attributes with hyphenated dataset keys. + ### CI: PHP checks via the reusable composer-script-matrix workflow The `Quality` workflow runs every PHP check — phpcs, phpstan, deptrac, composer-require-checker, and changelog validation — as a single `lint-php` job that calls `wordpress-configs`' `reusable-php-lint.yml`, which fans a caller-supplied JSON list of composer scripts into a parallel matrix in a standard PHP environment. Each check's command lives in this repo's `composer.json` (`lint:php:*` + `changelog:validate`), single-sourced between local dev and CI; the reusable workflow owns only the environment (checkout, PHP, composer install). -This shape is load-bearing for PHPStan: it runs seven configs across five packages, isolating each dependency/stub surface (only `woocommerce` and infrastructure's utilities config scan the WooCommerce stubs — see the static-analysis-stubs decision), and a single `phpstan analyse -c ` run cannot reproduce that isolation, so `composer lint:php:phpstan` (the seven-run sweep) is the unit the workflow invokes. Delegating to consumer composer scripts is the de-facto standard among the configs reusable workflows; policy-enforcing workflows (supply-chain audit, schema conformance, release) keep their command hardcoded so a consumer cannot weaken them. +This shape is load-bearing for PHPStan: it runs seven configs across five packages, isolating each dependency/stub surface (only `woocommerce` and infrastructure's utilities config scan the WooCommerce stubs — see the static-analysis-stubs decision), and a single `phpstan analyse -c ` run cannot reproduce that isolation, so `composer lint:php:phpstan` (the seven-run sweep) is the unit the workflow invokes. Delegating to consumer composer scripts is the de-facto standard among the configs reusable workflows; policy-enforcing workflows (supply-chain audit, schema conformance, release) keep their command hardcoded; callers tune only the flags the reusable workflow exposes (this repo's audit passes `--omit=dev --audit-level=high`). A reusable workflow was chosen over a composite action deliberately: a composite action cannot declare a `strategy.matrix` (a job-level key), so it would push a matrix-job wrapper into every consumer; the reusable workflow owns the matrix and consumers pass only their script list. From 52ff407da65819fe91dd0ab0c7b316929712adcf Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Wed, 8 Jul 2026 08:07:49 +0200 Subject: [PATCH 10/10] fix(ci): re-lock wordpress-configs past the phpstan root-fallback bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit configs 4214abd drops the no-layout root fallback from phpstan auto-discovery — the actual cause of the Quality workflow's 198 symbol-not-found errors (misattributed to a phpstan-wordpress 2.x-dev regression; no v2.0.4 wait needed): from the repo root every per-package run was analyzing the whole monorepo. All seven workflow refs bump to the same SHA per the bump-together rule. The AGENTS.md PHPStan guidance inverts accordingly: the from-root sweep is the clean canonical mode (each config analyzes exactly its declared paths — verified, 7/7 No errors); an in-package run is the noisy mode, where layout discovery unions the package tree and the settings/storage configs surface their ~20 Action Scheduler baseline errors. Assisted-by: Claude Code:claude-fable-5 --- .github/workflows/audit.yml | 2 +- .github/workflows/codeql.yml | 2 +- .github/workflows/quality.yml | 2 +- .github/workflows/tests-mutation.yml | 2 +- .github/workflows/tests.yml | 4 ++-- .github/workflows/workflow-checks.yml | 2 +- AGENTS.md | 2 +- composer.lock | 17 +++++++++++------ 8 files changed, 19 insertions(+), 14 deletions(-) diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 924eb05..cebf6ee 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -16,6 +16,6 @@ jobs: audit: name: Audit # npm gate audits production deps only — the dev-tooling graph (wp-scripts/playwright trees) is advisory via dependabot alerts. - uses: ahegyes/wordpress-configs/.github/workflows/reusable-supply-chain-audit.yml@7c1ef45c6be4cffdea69b05e855ec0c03e51166c + uses: ahegyes/wordpress-configs/.github/workflows/reusable-supply-chain-audit.yml@4214abd176545fe11f90ce6a449f8fc3fd7a9315 with: npm-audit-flags: '--omit=dev --audit-level=high' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9145ab3..f43ac4c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -21,4 +21,4 @@ jobs: actions: read contents: read security-events: write - uses: ahegyes/wordpress-configs/.github/workflows/reusable-codeql.yml@7c1ef45c6be4cffdea69b05e855ec0c03e51166c + uses: ahegyes/wordpress-configs/.github/workflows/reusable-codeql.yml@4214abd176545fe11f90ce6a449f8fc3fd7a9315 diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 1a37bdd..3b3a0d6 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -11,6 +11,6 @@ permissions: jobs: lint-php: name: Lint PHP - uses: ahegyes/wordpress-configs/.github/workflows/reusable-php-lint.yml@7c1ef45c6be4cffdea69b05e855ec0c03e51166c + uses: ahegyes/wordpress-configs/.github/workflows/reusable-php-lint.yml@4214abd176545fe11f90ce6a449f8fc3fd7a9315 with: scripts: '["lint:php:phpcs", "lint:php:phpstan", "lint:php:deptrac", "lint:php:composer-require-checker", "changelog:validate"]' diff --git a/.github/workflows/tests-mutation.yml b/.github/workflows/tests-mutation.yml index a61ee14..1f7ba43 100644 --- a/.github/workflows/tests-mutation.yml +++ b/.github/workflows/tests-mutation.yml @@ -42,7 +42,7 @@ jobs: infection-integration: name: Run Infection (Integration) - uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@7c1ef45c6be4cffdea69b05e855ec0c03e51166c + uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@4214abd176545fe11f90ce6a449f8fc3fd7a9315 with: php-version: '8.5' composer-script: 'test:integration:mutation' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b285b07..1d16085 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,7 +12,7 @@ permissions: jobs: unit: name: Unit - uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@7c1ef45c6be4cffdea69b05e855ec0c03e51166c + uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@4214abd176545fe11f90ce6a449f8fc3fd7a9315 with: php-version: '8.5' composer-script: 'test:unit' @@ -33,7 +33,7 @@ jobs: - label: 'WP 6.9.4 (below floor)' php-version: '8.5' wp-env-core: 'WordPress/WordPress#tags/6.9.4' - uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@7c1ef45c6be4cffdea69b05e855ec0c03e51166c + uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@4214abd176545fe11f90ce6a449f8fc3fd7a9315 with: php-version: ${{ matrix.php-version }} wp-env-core: ${{ matrix.wp-env-core }} diff --git a/.github/workflows/workflow-checks.yml b/.github/workflows/workflow-checks.yml index 4150d2a..7eb15a5 100644 --- a/.github/workflows/workflow-checks.yml +++ b/.github/workflows/workflow-checks.yml @@ -25,4 +25,4 @@ jobs: contents: read security-events: write actions: read - uses: ahegyes/wordpress-configs/.github/workflows/reusable-workflow-checks.yml@7c1ef45c6be4cffdea69b05e855ec0c03e51166c + uses: ahegyes/wordpress-configs/.github/workflows/reusable-workflow-checks.yml@4214abd176545fe11f90ce6a449f8fc3fd7a9315 diff --git a/AGENTS.md b/AGENTS.md index c0cc611..97a5dad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,7 @@ composer test:unit:mutation # Infection mutation tests (no Docker; Unit suite on `composer lint:php` aggregates PHPCS + PHPStan + **deptrac** (architecture-rule check via `deptrac.yaml`) + composer-require-checker. The deptrac cache is the file `tests/.cache/deptrac`. -**Run PHPStan from inside a package directory, never from the repo root.** wordpress-configs' `phpstan.dist.neon.php` resolves layout from `getcwd()`; from the monorepo root it matches nothing and falls back to analyzing the whole repo including fixture vendor trees — 1000+ spurious errors, worker OOMs, and a poisoned AST cache in `$TMPDIR/phpstan` that survives `clear-result-cache` (delete the directory if runs start reporting phantom errors). The `phpstan.settings.neon`/`phpstan.storage.neon` in-package runs report a known baseline of 20 Action Scheduler `as_*` errors (those configs do not scan the AS stubs); treat only a delta from 20 as a finding. CI runs per-package and is unaffected. +**Run PHPStan from the repo root (`composer lint:php:phpstan`) — that is the clean, canonical mode.** wordpress-configs' `phpstan.dist.neon.php` resolves layout from `getcwd()`: from the root it matches no layout and contributes no paths, so each config analyzes exactly its declared paths. An IN-PACKAGE run is the noisy mode — the package dir matches the library layout, so discovery unions the whole package's `src/`+`tests/` onto the config's declared slice; `phpstan.settings.neon`/`phpstan.storage.neon` then report a baseline of ~20 Action Scheduler `as_*` errors from Scheduling files outside their slice (those configs do not scan the AS stubs) — treat only a delta as a finding. If runs report phantom errors after an aborted sweep, delete `$TMPDIR/phpstan` (the AST cache survives `clear-result-cache`). Requires wordpress-configs ≥ `4214abd` (older refs carried a no-layout root fallback that made from-root runs analyze the whole monorepo). wp-env runs on **port 8801** (per workspace port scheme — see `feedback_wp_env_port_scheme` memory). diff --git a/composer.lock b/composer.lock index 9b6b224..117f5b7 100644 --- a/composer.lock +++ b/composer.lock @@ -334,7 +334,7 @@ "dist": { "type": "path", "url": "packages/woocommerce", - "reference": "e2842b1d5915abc0a6279d593be244bb000ec004" + "reference": "6e1df153c143e12e34121bba799211cb1cc15ee3" }, "require": { "ahegyes/wp-framework-core": "^2.0@dev", @@ -386,10 +386,15 @@ "homepage": "https://github.com/ahegyes/wordpress-framework/graphs/contributors" } ], - "description": "WooCommerce settings backend and WC-aware helpers for plugins built on the DWS framework.", + "description": "WooCommerce settings backend, product-data and order-data fields, version and database-version conditionals, and a PSR-3 logger for plugins built on the DWS framework.", "homepage": "https://github.com/ahegyes/wordpress-framework", "keywords": [ + "conditionals", "framework", + "logging", + "order-data", + "product-data", + "settings", "woocommerce", "wordpress", "wordpress-plugin" @@ -514,12 +519,12 @@ "source": { "type": "git", "url": "https://github.com/ahegyes/wordpress-configs.git", - "reference": "7c1ef45c6be4cffdea69b05e855ec0c03e51166c" + "reference": "4214abd176545fe11f90ce6a449f8fc3fd7a9315" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/7c1ef45c6be4cffdea69b05e855ec0c03e51166c", - "reference": "7c1ef45c6be4cffdea69b05e855ec0c03e51166c", + "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/4214abd176545fe11f90ce6a449f8fc3fd7a9315", + "reference": "4214abd176545fe11f90ce6a449f8fc3fd7a9315", "shasum": "" }, "require": { @@ -628,7 +633,7 @@ "source": "https://github.com/ahegyes/wordpress-configs/tree/trunk", "issues": "https://github.com/ahegyes/wordpress-configs/issues" }, - "time": "2026-07-06T11:19:11+00:00" + "time": "2026-07-08T06:02:49+00:00" }, { "name": "automattic/jetpack-changelogger",