diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 6d4eadd..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@714fc2a66d6ae5011999beb18a4b01fb1992f4bd + 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 e0b5a19..f43ac4c 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@4214abd176545fe11f90ce6a449f8fc3fd7a9315 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..3b3a0d6 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@4214abd176545fe11f90ce6a449f8fc3fd7a9315 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-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 a8d7a20..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@714fc2a66d6ae5011999beb18a4b01fb1992f4bd + uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@4214abd176545fe11f90ce6a449f8fc3fd7a9315 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@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 52a54bb..7eb15a5 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@4214abd176545fe11f90ce6a449f8fc3fd7a9315 diff --git a/AGENTS.md b/AGENTS.md index aabc069..97a5dad 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 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). -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. 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/composer.lock b/composer.lock index 6e20763..117f5b7 100644 --- a/composer.lock +++ b/composer.lock @@ -334,7 +334,7 @@ "dist": { "type": "path", "url": "packages/woocommerce", - "reference": "9c4f77356ad588c05b9ec9ec8f3a951aa55b21e5" + "reference": "6e1df153c143e12e34121bba799211cb1cc15ee3" }, "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": { @@ -389,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" @@ -517,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": { @@ -631,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", 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 f433735..ddb57e5 100644 --- a/infection.integration.json +++ b/infection.integration.json @@ -6,15 +6,15 @@ "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": [ "tests", "changelog", - "Backend/DescriptorBackedWCSettingsPage.php" + "Backend/DescriptorBackedWooCommerceSettingsPage.php" ] }, "phpUnit": { 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/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 . '
  • ' . \implode( '
  • ', $items ) . '
'; - 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/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/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/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/infrastructure/tests/Settings/Integration/MetaField/Stores/index.php b/packages/core/src/ValueObjects/Exceptions/index.php similarity index 100% rename from packages/infrastructure/tests/Settings/Integration/MetaField/Stores/index.php rename to packages/core/src/ValueObjects/Exceptions/index.php diff --git a/packages/core/src/ValueObjects/PluginBootReport.php b/packages/core/src/ValueObjects/PluginBootReport.php index 87ddeb6..ba7d47d 100644 --- a/packages/core/src/ValueObjects/PluginBootReport.php +++ b/packages/core/src/ValueObjects/PluginBootReport.php @@ -6,7 +6,7 @@ use DeepWebSolutions\Framework\Core\Feature\FeatureInterface; /** - * Diagnostic report of a plugin boot attempt: the attempt's status, an optional failure + * Descriptor for a plugin boot attempt: the attempt's status, an optional failure * summary, and the per-phase component lists the kernel records. The constructor defaults * are the not-started shape a kernel exposes before its first boot. * diff --git a/packages/core/src/ValueObjects/PluginHeader.php b/packages/core/src/ValueObjects/PluginHeader.php index 9bd064d..691a356 100644 --- a/packages/core/src/ValueObjects/PluginHeader.php +++ b/packages/core/src/ValueObjects/PluginHeader.php @@ -2,9 +2,11 @@ namespace DeepWebSolutions\Framework\Core\ValueObjects; +use DeepWebSolutions\Framework\Core\ValueObjects\Exceptions\InvalidPluginHeaderException; use DeepWebSolutions\Framework\Shared\ValueObject\AbstractValueObject; use function DeepWebSolutions\Framework\Bootstrap\Plugin\get_plugin_metadata; +use function DeepWebSolutions\Framework\Shared\Identifier\is_valid_identifier; /** * Value object wrapping the WP plugin file header, read once and typed. @@ -110,6 +112,8 @@ * @version 2.0.0 * * @param string $file_path Absolute path to the plugin's main PHP file; must live under `WP_PLUGIN_DIR` for round-trip via `plugin_basename()` to succeed. + * + * @throws InvalidPluginHeaderException If the derived slug is not a valid identifier. */ public function __construct( public string $file_path @@ -124,12 +128,19 @@ public function __construct( $this->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/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/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( @@ -869,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/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/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/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/Stores/PostMetaFieldStore.php b/packages/infrastructure/src/Settings/MetaField/Surfaces/PostMetaFieldSurface.php similarity index 81% rename from packages/infrastructure/src/Settings/MetaField/Stores/PostMetaFieldStore.php rename to packages/infrastructure/src/Settings/MetaField/Surfaces/PostMetaFieldSurface.php index 1db1bd0..bad8342 100644 --- a/packages/infrastructure/src/Settings/MetaField/Stores/PostMetaFieldStore.php +++ b/packages/infrastructure/src/Settings/MetaField/Surfaces/PostMetaFieldSurface.php @@ -1,6 +1,6 @@ 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 ); } /** @@ -224,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 ); @@ -267,14 +264,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, @@ -287,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 @@ -297,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 ); } /** @@ -313,10 +305,11 @@ 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 { - $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/Stores/TermFieldStore.php b/packages/infrastructure/src/Settings/MetaField/Surfaces/TermFieldSurface.php similarity index 88% rename from packages/infrastructure/src/Settings/MetaField/Stores/TermFieldStore.php rename to packages/infrastructure/src/Settings/MetaField/Surfaces/TermFieldSurface.php index 70a34dc..3a56c80 100644 --- a/packages/infrastructure/src/Settings/MetaField/Stores/TermFieldStore.php +++ b/packages/infrastructure/src/Settings/MetaField/Surfaces/TermFieldSurface.php @@ -1,6 +1,6 @@ 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 ); } /** @@ -252,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 ); @@ -265,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 ); @@ -282,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/MetaField/Stores/UserProfileFieldStore.php b/packages/infrastructure/src/Settings/MetaField/Surfaces/UserProfileFieldSurface.php similarity index 87% rename from packages/infrastructure/src/Settings/MetaField/Stores/UserProfileFieldStore.php rename to packages/infrastructure/src/Settings/MetaField/Surfaces/UserProfileFieldSurface.php index c89f41e..4565376 100644 --- a/packages/infrastructure/src/Settings/MetaField/Stores/UserProfileFieldStore.php +++ b/packages/infrastructure/src/Settings/MetaField/Surfaces/UserProfileFieldSurface.php @@ -1,6 +1,6 @@ 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/tests/Settings/Integration/index.php b/packages/infrastructure/src/Settings/MetaField/Surfaces/index.php similarity index 100% rename from packages/infrastructure/tests/Settings/Integration/index.php rename to packages/infrastructure/src/Settings/MetaField/Surfaces/index.php 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/tests/Settings/Unit/MetaField/Stores/index.php b/packages/infrastructure/src/Settings/Schema/Errors/index.php similarity index 100% rename from packages/infrastructure/tests/Settings/Unit/MetaField/Stores/index.php rename to packages/infrastructure/src/Settings/Schema/Errors/index.php diff --git a/packages/infrastructure/src/Settings/Schema/Options/SettingsOptionsProviderInterface.php b/packages/infrastructure/src/Settings/Schema/Options/OptionsProviderInterface.php similarity index 93% rename from packages/infrastructure/src/Settings/Schema/Options/SettingsOptionsProviderInterface.php rename to packages/infrastructure/src/Settings/Schema/Options/OptionsProviderInterface.php index c46b469..5c841bb 100644 --- a/packages/infrastructure/src/Settings/Schema/Options/SettingsOptionsProviderInterface.php +++ b/packages/infrastructure/src/Settings/Schema/Options/OptionsProviderInterface.php @@ -13,7 +13,7 @@ * @since 2.0.0 * @version 2.0.0 */ -interface SettingsOptionsProviderInterface { +interface OptionsProviderInterface { /** * Returns the available options as a value-to-label map. * diff --git a/packages/infrastructure/src/Settings/Schema/Options/OptionsResolver.php b/packages/infrastructure/src/Settings/Schema/Options/OptionsResolver.php index 7998d6a..fa5d590 100644 --- a/packages/infrastructure/src/Settings/Schema/Options/OptionsResolver.php +++ b/packages/infrastructure/src/Settings/Schema/Options/OptionsResolver.php @@ -24,14 +24,14 @@ * @since 2.0.0 * @version 2.0.0 * - * @param array|\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/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 ec3e7aa..d4b38b8 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,14 +84,14 @@ 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, ) { 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..a5a1060 100644 --- a/packages/infrastructure/src/Settings/Schema/functions.php +++ b/packages/infrastructure/src/Settings/Schema/functions.php @@ -1,5 +1,13 @@ $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 @@ -166,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/src/Storage/KeyValueStoreInterface.php b/packages/infrastructure/src/Storage/KeyValueStoreInterface.php index 25f8ef6..ab46219 100644 --- a/packages/infrastructure/src/Storage/KeyValueStoreInterface.php +++ b/packages/infrastructure/src/Storage/KeyValueStoreInterface.php @@ -19,36 +19,36 @@ */ interface KeyValueStoreInterface { /** - * Persist a value under the given key. Overwrites any existing value at the same key. + * Retrieves a value by key, or the default if no value is stored under it. * * @since 2.0.0 * @version 2.0.0 * - * @phpstan-param T $value + * @phpstan-param T|null $default_value + * @phpstan-return T|null * - * @param string $key Identifier under which to store the value. - * @param mixed $value Value to persist. + * @param string $key Identifier to look up. + * @param mixed $default_value Value to return when no entry exists at $key. Defaults to null. + * + * @return mixed */ - public function set( string $key, mixed $value ): void; + public function get( string $key, mixed $default_value = null ): mixed; /** - * Retrieve a value by key, or the default if no value is stored under it. + * Persists a value under the given key. Overwrites any existing value at the same key. * * @since 2.0.0 * @version 2.0.0 * - * @phpstan-param T|null $default_value - * @phpstan-return T|null - * - * @param string $key Identifier to look up. - * @param mixed $default_value Value to return when no entry exists at $key. Defaults to null. + * @phpstan-param T $value * - * @return mixed + * @param string $key Identifier under which to store the value. + * @param mixed $value Value to persist. */ - public function get( string $key, mixed $default_value = null ): mixed; + public function set( string $key, mixed $value ): void; /** - * Check whether a value is stored at the given key. + * Checks whether a value is stored at the given key. * * @since 2.0.0 * @version 2.0.0 @@ -60,19 +60,19 @@ public function get( string $key, mixed $default_value = null ): mixed; public function has( string $key ): bool; /** - * Delete the value stored at the given key. + * Deletes the value stored at the given key. * * @since 2.0.0 * @version 2.0.0 * * @param string $key Identifier to delete. * - * @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. */ public function delete( string $key ): bool; /** - * Return all stored values as a key-indexed array. + * Returns all stored values as a key-indexed array. * * @since 2.0.0 * @version 2.0.0 @@ -82,7 +82,7 @@ public function delete( string $key ): bool; public function get_all(): array; /** - * Remove every stored value. + * Removes every stored value. * * @since 2.0.0 * @version 2.0.0 diff --git a/packages/infrastructure/src/Storage/MemoryStore.php b/packages/infrastructure/src/Storage/MemoryStore.php index ae779ce..92adb55 100644 --- a/packages/infrastructure/src/Storage/MemoryStore.php +++ b/packages/infrastructure/src/Storage/MemoryStore.php @@ -39,8 +39,8 @@ final class MemoryStore implements KeyValueStoreInterface { * @version 2.0.0 */ #[\Override] - public function set( string $key, mixed $value ): void { - $this->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/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..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 @@ -118,8 +128,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/Storage/Integration/ObjectMeta/index.php b/packages/infrastructure/src/index.php similarity index 100% rename from packages/infrastructure/tests/Storage/Integration/ObjectMeta/index.php rename to packages/infrastructure/src/index.php diff --git a/packages/infrastructure/tests/Settings/Integration/WordPressSettingsBackendTest.php b/packages/infrastructure/tests/Settings/Integration/Backend/WordPressSettingsBackendTest.php similarity index 98% rename from packages/infrastructure/tests/Settings/Integration/WordPressSettingsBackendTest.php rename to packages/infrastructure/tests/Settings/Integration/Backend/WordPressSettingsBackendTest.php index eaecb42..9c23391 100644 --- a/packages/infrastructure/tests/Settings/Integration/WordPressSettingsBackendTest.php +++ b/packages/infrastructure/tests/Settings/Integration/Backend/WordPressSettingsBackendTest.php @@ -1,7 +1,8 @@ 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/Integration/MetaField/Stores/PostMetaFieldStoreTest.php b/packages/infrastructure/tests/Settings/Integration/MetaField/Surfaces/PostMetaFieldSurfaceTest.php similarity index 79% rename from packages/infrastructure/tests/Settings/Integration/MetaField/Stores/PostMetaFieldStoreTest.php rename to packages/infrastructure/tests/Settings/Integration/MetaField/Surfaces/PostMetaFieldSurfaceTest.php index 8391f98..0103c2a 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 @@ - */ - 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,20 +63,11 @@ 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(); } public function test_register_adds_the_meta_box_on_the_screen(): 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 ) ); self::assertArrayHasKey( self::GROUP_ID, $this->boxes_on( 'post' ) ); @@ -92,7 +75,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 +85,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,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 PostMetaFieldStore(); - $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 PostMetaFieldStore(); - $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 PostMetaFieldStore(); - $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 PostMetaFieldStore(); - $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( @@ -180,7 +163,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 +183,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,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 PostMetaFieldStore(); + $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( @@ -232,7 +215,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 81% rename from packages/infrastructure/tests/Settings/Integration/MetaField/Stores/TermFieldStoreTest.php rename to packages/infrastructure/tests/Settings/Integration/MetaField/Surfaces/TermFieldSurfaceTest.php index ccf9440..90db1d7 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 @@ - */ - 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,20 +54,11 @@ 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(); } public function test_editing_a_term_renders_the_nonce_and_control(): 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' ) ); @@ -87,7 +70,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 +83,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 +94,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 +105,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 +114,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,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 TermFieldStore(); + $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,19 +137,19 @@ 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 { - ( new TermFieldStore() )->register( $this->term_group() ); + ( new TermFieldSurface() )->register( $this->term_group() ); $_POST = array( self::NONCE_NAME => $this->nonce_for( 0 ), @@ -179,7 +162,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 +174,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 +191,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 +210,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 +255,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 +287,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 80% rename from packages/infrastructure/tests/Settings/Integration/MetaField/Stores/UserProfileFieldStoreTest.php rename to packages/infrastructure/tests/Settings/Integration/MetaField/Surfaces/UserProfileFieldSurfaceTest.php index 3a50dec..dd415ab 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 @@ - */ - 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,20 +67,11 @@ 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(); } public function test_editing_a_profile_renders_the_nonce_control_and_form_table(): void { - ( new UserProfileFieldStore() )->register( $this->profile() ); + ( new UserProfileFieldSurface() )->register( $this->profile() ); \ob_start(); \do_action( 'edit_user_profile', \get_userdata( $this->user_id ) ); @@ -101,7 +84,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 +95,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,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 UserProfileFieldStore(); + $surface = new UserProfileFieldSurface(); $profile = $this->text_profile(); $group = $profile->group; - $store->register( $profile ); + $surface->register( $profile ); $_POST = array( self::NONCE_NAME => $this->nonce(), @@ -135,20 +118,20 @@ 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 { $raw = 'x'; - ( new UserProfileFieldStore() )->register( $this->text_profile() ); + ( new UserProfileFieldSurface() )->register( $this->text_profile() ); $_POST = array( self::NONCE_NAME => $this->nonce(), @@ -160,7 +143,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 +160,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 +169,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 +177,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 +196,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 +220,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/CrossComponentSettingsTest.php b/packages/infrastructure/tests/Settings/Integration/Schema/Aggregation/CrossComponentSettingsTest.php similarity index 98% rename from packages/infrastructure/tests/Settings/Integration/CrossComponentSettingsTest.php rename to packages/infrastructure/tests/Settings/Integration/Schema/Aggregation/CrossComponentSettingsTest.php index 68bf9a1..0175e8f 100644 --- a/packages/infrastructure/tests/Settings/Integration/CrossComponentSettingsTest.php +++ b/packages/infrastructure/tests/Settings/Integration/Schema/Aggregation/CrossComponentSettingsTest.php @@ -1,6 +1,6 @@ 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/Support/CreatesUsers.php b/packages/infrastructure/tests/Settings/Support/CreatesUsers.php new file mode 100644 index 0000000..139e1f9 --- /dev/null +++ b/packages/infrastructure/tests/Settings/Support/CreatesUsers.php @@ -0,0 +1,27 @@ +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 90% rename from packages/infrastructure/tests/Settings/Unit/WordPressSettingsBackendTest.php rename to packages/infrastructure/tests/Settings/Unit/Backend/WordPressSettingsBackendTest.php index e166824..b83c958 100644 --- a/packages/infrastructure/tests/Settings/Unit/WordPressSettingsBackendTest.php +++ b/packages/infrastructure/tests/Settings/Unit/Backend/WordPressSettingsBackendTest.php @@ -1,6 +1,6 @@ option_keys( $page ) ); diff --git a/packages/infrastructure/tests/Settings/Unit/MetaField/MetaBoxPlacementTest.php b/packages/infrastructure/tests/Settings/Unit/MetaField/MetaBoxPlacementTest.php deleted file mode 100644 index 8daed98..0000000 --- a/packages/infrastructure/tests/Settings/Unit/MetaField/MetaBoxPlacementTest.php +++ /dev/null @@ -1,61 +0,0 @@ -capability ); - } - - public function test_a_capability_override_is_carried(): void { - $placement = new MetaBoxPlacement( - screen: 'product', - context: 'normal', - priority: 'default', - capability: 'manage_woocommerce', - ); - - self::assertSame( 'manage_woocommerce', $placement->capability ); - } - - public function test_equals_is_structural_over_all_four_properties(): void { - $placement = new MetaBoxPlacement( screen: 'post', context: 'side', priority: 'high', capability: 'edit_pages' ); - - self::assertTrue( $placement->equals( new MetaBoxPlacement( screen: 'post', context: 'side', priority: 'high', capability: 'edit_pages' ) ) ); - self::assertFalse( $placement->equals( new MetaBoxPlacement( screen: 'page', context: 'side', priority: 'high', capability: 'edit_pages' ) ) ); - self::assertFalse( $placement->equals( new MetaBoxPlacement( screen: 'post', context: 'normal', priority: 'high', capability: 'edit_pages' ) ) ); - self::assertFalse( $placement->equals( new MetaBoxPlacement( screen: 'post', context: 'side', priority: 'low', capability: 'edit_pages' ) ) ); - self::assertFalse( $placement->equals( new MetaBoxPlacement( screen: 'post', context: 'side', priority: 'high' ) ) ); - } - - public function test_json_serialization_carries_all_four_properties(): void { - $placement = new MetaBoxPlacement( screen: 'post', context: 'side', priority: 'default' ); - - self::assertSame( - array( - 'screen' => 'post', - 'context' => 'side', - 'priority' => 'default', - 'capability' => null, - ), - $placement->jsonSerialize(), - ); - } -} 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/MetaField/Stores/PostMetaFieldStoreTest.php b/packages/infrastructure/tests/Settings/Unit/MetaField/Stores/PostMetaFieldStoreTest.php deleted file mode 100644 index d22a383..0000000 --- a/packages/infrastructure/tests/Settings/Unit/MetaField/Stores/PostMetaFieldStoreTest.php +++ /dev/null @@ -1,164 +0,0 @@ -repository = new InMemoryObjectMetaRepository(); - $this->store = new PostMetaFieldStore( 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' ); - } - - public function test_two_fields_sharing_an_effective_storage_key_are_rejected(): void { - $this->expectException( DuplicateSettingsFieldException::class ); - - $this->store->get( - $this->group( - new SettingsField( id: 'note', type: 'text', label: 'A' ), - new SettingsField( id: 'alias', type: 'text', label: 'B', meta_key: 'note' ), - ), - 7, - 'note', - ); - } - - public function test_the_key_resolution_follows_a_per_object_fields_provider(): void { - $group = new FieldGroup( - id: 'dws_group', - title: 'Group', - fields_provider: static fn ( int $object_id ): array => array( - new SettingsField( id: 'note', type: 'text', label: 'Note', meta_key: '_dws_note_' . $object_id ), - ), - ); - - $this->store->set( $group, 7, 'note', 'hello' ); - - self::assertSame( 'hello', $this->repository->get( 7, '_dws_note_7' ) ); - } - - public function test_meta_keys_enumerates_the_resolved_storage_keys_for_the_objectless_evaluation(): void { - $group = new FieldGroup( - id: 'dws_group', - title: 'Group', - fields_provider: static fn ( int $object_id ): array => 0 === $object_id - ? array( - new SettingsField( id: 'note', type: 'text', label: 'Note' ), - new SettingsField( id: 'color', type: 'text', label: 'Color', meta_key: '_dws_color' ), - ) - : 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, - ); - } -} diff --git a/packages/infrastructure/tests/Settings/Unit/MetaField/Stores/TermFieldStoreTest.php b/packages/infrastructure/tests/Settings/Unit/MetaField/Stores/TermFieldStoreTest.php deleted file mode 100644 index 0270f30..0000000 --- a/packages/infrastructure/tests/Settings/Unit/MetaField/Stores/TermFieldStoreTest.php +++ /dev/null @@ -1,121 +0,0 @@ -repository = new InMemoryObjectMetaRepository(); - $this->store = new TermFieldStore( 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, - ); - } -} diff --git a/packages/infrastructure/tests/Settings/Unit/MetaField/Stores/UserProfileFieldStoreTest.php b/packages/infrastructure/tests/Settings/Unit/MetaField/Stores/UserProfileFieldStoreTest.php deleted file mode 100644 index 1ad567b..0000000 --- a/packages/infrastructure/tests/Settings/Unit/MetaField/Stores/UserProfileFieldStoreTest.php +++ /dev/null @@ -1,121 +0,0 @@ -repository = new InMemoryObjectMetaRepository(); - $this->store = new UserProfileFieldStore( 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, - ); - } -} diff --git a/packages/infrastructure/tests/Settings/Unit/MetaField/Surfaces/ObjectFieldSurfaceContractTestCase.php b/packages/infrastructure/tests/Settings/Unit/MetaField/Surfaces/ObjectFieldSurfaceContractTestCase.php new file mode 100644 index 0000000..3be7ed0 --- /dev/null +++ b/packages/infrastructure/tests/Settings/Unit/MetaField/Surfaces/ObjectFieldSurfaceContractTestCase.php @@ -0,0 +1,115 @@ +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 new file mode 100644 index 0000000..60d7ad0 --- /dev/null +++ b/packages/infrastructure/tests/Settings/Unit/MetaField/Surfaces/PostMetaFieldSurfaceTest.php @@ -0,0 +1,75 @@ +expectException( DuplicateSettingsFieldException::class ); + + $this->surface->get( + $this->group( + new SettingsField( id: 'note', type: 'text', label: 'A' ), + new SettingsField( id: 'alias', type: 'text', label: 'B', meta_key: 'note' ), + ), + 7, + 'note', + ); + } + + public function test_the_key_resolution_follows_a_per_object_fields_provider(): void { + $group = new FieldGroup( + id: 'dws_group', + title: 'Group', + fields_provider: static fn ( int $object_id ): array => array( + new SettingsField( id: 'note', type: 'text', label: 'Note', meta_key: '_dws_note_' . $object_id ), + ), + ); + + $this->surface->set( $group, 7, 'note', 'hello' ); + + self::assertSame( 'hello', $this->repository->get( 7, '_dws_note_7' ) ); + } + + public function test_meta_keys_enumerates_the_resolved_storage_keys_for_the_objectless_evaluation(): void { + $group = new FieldGroup( + id: 'dws_group', + title: 'Group', + fields_provider: static fn ( int $object_id ): array => 0 === $object_id + ? array( + new SettingsField( id: 'note', type: 'text', label: 'Note' ), + new SettingsField( id: 'color', type: 'text', label: 'Color', meta_key: '_dws_color' ), + ) + : array(), + ); + + 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 new file mode 100644 index 0000000..04f96a9 --- /dev/null +++ b/packages/infrastructure/tests/Settings/Unit/MetaField/Surfaces/TermFieldSurfaceTest.php @@ -0,0 +1,32 @@ +capability ); + } + + public function test_a_capability_override_is_carried(): void { + $placement = new MetaBoxPlacement( + screen: 'product', + context: 'normal', + priority: 'default', + capability: 'manage_woocommerce', + ); + + self::assertSame( 'manage_woocommerce', $placement->capability ); + } + + public function test_equals_is_structural_over_all_four_properties(): void { + $placement = new MetaBoxPlacement( screen: 'post', context: 'side', priority: 'high', capability: 'edit_pages' ); + + self::assertTrue( $placement->equals( new MetaBoxPlacement( screen: 'post', context: 'side', priority: 'high', capability: 'edit_pages' ) ) ); + self::assertFalse( $placement->equals( new MetaBoxPlacement( screen: 'page', context: 'side', priority: 'high', capability: 'edit_pages' ) ) ); + self::assertFalse( $placement->equals( new MetaBoxPlacement( screen: 'post', context: 'normal', priority: 'high', capability: 'edit_pages' ) ) ); + self::assertFalse( $placement->equals( new MetaBoxPlacement( screen: 'post', context: 'side', priority: 'low', capability: 'edit_pages' ) ) ); + self::assertFalse( $placement->equals( new MetaBoxPlacement( screen: 'post', context: 'side', priority: 'high' ) ) ); + } + + public function test_json_serialization_carries_all_four_properties(): void { + $placement = new MetaBoxPlacement( screen: 'post', context: 'side', priority: 'default' ); + + self::assertSame( + array( + 'screen' => 'post', + 'context' => 'side', + 'priority' => 'default', + 'capability' => null, + ), + $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/TermFieldGroupTest.php b/packages/infrastructure/tests/Settings/Unit/MetaField/ValueObjects/TermFieldGroupTest.php similarity index 99% rename from packages/infrastructure/tests/Settings/Unit/MetaField/TermFieldGroupTest.php rename to packages/infrastructure/tests/Settings/Unit/MetaField/ValueObjects/TermFieldGroupTest.php index d357302..4b8fdf2 100644 --- a/packages/infrastructure/tests/Settings/Unit/MetaField/TermFieldGroupTest.php +++ b/packages/infrastructure/tests/Settings/Unit/MetaField/ValueObjects/TermFieldGroupTest.php @@ -1,6 +1,6 @@ '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/SchemaFunctionsTest.php b/packages/infrastructure/tests/Settings/Unit/Schema/SchemaFunctionsTest.php similarity index 96% rename from packages/infrastructure/tests/Settings/Unit/SchemaFunctionsTest.php rename to packages/infrastructure/tests/Settings/Unit/Schema/SchemaFunctionsTest.php index 635c302..8502094 100644 --- a/packages/infrastructure/tests/Settings/Unit/SchemaFunctionsTest.php +++ b/packages/infrastructure/tests/Settings/Unit/Schema/SchemaFunctionsTest.php @@ -1,6 +1,6 @@ '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/infrastructure/tests/Settings/Unit/CustomFieldTypeTest.php b/packages/infrastructure/tests/Settings/Unit/Schema/ValueObjects/CustomFieldTypeTest.php similarity index 98% rename from packages/infrastructure/tests/Settings/Unit/CustomFieldTypeTest.php rename to packages/infrastructure/tests/Settings/Unit/Schema/ValueObjects/CustomFieldTypeTest.php index 1d9d34b..340fe11 100644 --- a/packages/infrastructure/tests/Settings/Unit/CustomFieldTypeTest.php +++ b/packages/infrastructure/tests/Settings/Unit/Schema/ValueObjects/CustomFieldTypeTest.php @@ -1,6 +1,6 @@ 'V' ); } diff --git a/packages/infrastructure/tests/Settings/Unit/SettingsPageTest.php b/packages/infrastructure/tests/Settings/Unit/Schema/ValueObjects/SettingsPageTest.php similarity index 96% rename from packages/infrastructure/tests/Settings/Unit/SettingsPageTest.php rename to packages/infrastructure/tests/Settings/Unit/Schema/ValueObjects/SettingsPageTest.php index 5527d60..40ee68b 100644 --- a/packages/infrastructure/tests/Settings/Unit/SettingsPageTest.php +++ b/packages/infrastructure/tests/Settings/Unit/Schema/ValueObjects/SettingsPageTest.php @@ -1,6 +1,6 @@ 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/SettingsSectionTest.php b/packages/infrastructure/tests/Settings/Unit/Schema/ValueObjects/SettingsSectionTest.php similarity index 97% rename from packages/infrastructure/tests/Settings/Unit/SettingsSectionTest.php rename to packages/infrastructure/tests/Settings/Unit/Schema/ValueObjects/SettingsSectionTest.php index 14f65c2..0a91c09 100644 --- a/packages/infrastructure/tests/Settings/Unit/SettingsSectionTest.php +++ b/packages/infrastructure/tests/Settings/Unit/Schema/ValueObjects/SettingsSectionTest.php @@ -1,6 +1,6 @@ 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/Utilities/Integration/AdminNotices/AdminNoticeLoggerTest.php b/packages/infrastructure/tests/Utilities/Integration/AdminNotices/AdminNoticeLoggerTest.php index 414f2c1..7711b7c 100644 --- a/packages/infrastructure/tests/Utilities/Integration/AdminNotices/AdminNoticeLoggerTest.php +++ b/packages/infrastructure/tests/Utilities/Integration/AdminNotices/AdminNoticeLoggerTest.php @@ -9,6 +9,7 @@ use DeepWebSolutions\Framework\Core\PluginKernel; use DeepWebSolutions\Framework\Core\ValueObjects\PluginHeader; use DeepWebSolutions\Framework\Core\Feature\FeatureInterface; +use DeepWebSolutions\Framework\Settings\Tests\Support\CreatesUsers; use DeepWebSolutions\Framework\Shared\Version\Version; use DeepWebSolutions\Framework\Storage\OptionsStore; use DeepWebSolutions\Framework\Utilities\AdminNotices\AdminNoticeLogger; @@ -33,6 +34,8 @@ #[UsesClass( NoticeType::class )] #[UsesClass( OptionsStore::class )] final class AdminNoticeLoggerTest extends TestCase { + use CreatesUsers; + private const VERSION_OPTION = 'dws_reference_plugin_version'; private const NOTICE_OPTION = 'dws_reference_plugin_notices'; private const NOTICE_ID = 'plugin-install-failure'; @@ -43,7 +46,7 @@ final class AdminNoticeLoggerTest extends TestCase { protected function setUp(): void { parent::setUp(); $this->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 ); } @@ -67,7 +70,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. @@ -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 3f6e636..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'; @@ -74,7 +77,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 +92,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 +112,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 +127,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 +165,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 +176,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 +260,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 +311,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 +324,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 +337,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 +361,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 +381,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', ); @@ -488,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 8874f0b..b1ef846 100644 --- a/packages/infrastructure/tests/Utilities/Integration/AdminNotices/DependencyAdminNoticeRendererTest.php +++ b/packages/infrastructure/tests/Utilities/Integration/AdminNotices/DependencyAdminNoticeRendererTest.php @@ -3,9 +3,11 @@ 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; +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; @@ -25,11 +27,14 @@ #[UsesClass( AdminNotice::class )] #[UsesClass( DependencyRequirement::class )] #[UsesClass( NoticeType::class )] +#[UsesClass( UnknownNoticeStoreException::class )] #[UsesClass( MemoryStore::class )] #[UsesClass( OptionsStore::class )] #[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'; @@ -76,8 +81,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 +99,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 ); } @@ -153,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 { @@ -253,23 +244,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 { @@ -119,12 +121,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/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 @@ -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/Hooks/Handlers/index.php b/packages/infrastructure/tests/Utilities/Integration/Hooks/Handlers/index.php deleted file mode 100644 index f767346..0000000 --- a/packages/infrastructure/tests/Utilities/Integration/Hooks/Handlers/index.php +++ /dev/null @@ -1 +0,0 @@ -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/Backends/index.php b/packages/infrastructure/tests/Utilities/Integration/Scheduling/Backends/index.php deleted file mode 100644 index f767346..0000000 --- a/packages/infrastructure/tests/Utilities/Integration/Scheduling/Backends/index.php +++ /dev/null @@ -1 +0,0 @@ -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/Integration/Scheduling/index.php b/packages/infrastructure/tests/Utilities/Integration/Scheduling/index.php deleted file mode 100644 index f767346..0000000 --- a/packages/infrastructure/tests/Utilities/Integration/Scheduling/index.php +++ /dev/null @@ -1 +0,0 @@ -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/AdminNotices/ValueObjects/index.php b/packages/infrastructure/tests/Utilities/Unit/AdminNotices/ValueObjects/index.php deleted file mode 100644 index f767346..0000000 --- a/packages/infrastructure/tests/Utilities/Unit/AdminNotices/ValueObjects/index.php +++ /dev/null @@ -1 +0,0 @@ -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/Conditionals/Dependencies/index.php b/packages/infrastructure/tests/Utilities/Unit/Conditionals/Dependencies/index.php deleted file mode 100644 index f767346..0000000 --- a/packages/infrastructure/tests/Utilities/Unit/Conditionals/Dependencies/index.php +++ /dev/null @@ -1 +0,0 @@ - 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/Helpers/index.php b/packages/infrastructure/tests/Utilities/Unit/Helpers/index.php deleted file mode 100644 index f767346..0000000 --- a/packages/infrastructure/tests/Utilities/Unit/Helpers/index.php +++ /dev/null @@ -1 +0,0 @@ -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/Handlers/index.php b/packages/infrastructure/tests/Utilities/Unit/Hooks/Handlers/index.php deleted file mode 100644 index f767346..0000000 --- a/packages/infrastructure/tests/Utilities/Unit/Hooks/Handlers/index.php +++ /dev/null @@ -1 +0,0 @@ -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/Hooks/index.php b/packages/infrastructure/tests/Utilities/Unit/Hooks/index.php deleted file mode 100644 index f767346..0000000 --- a/packages/infrastructure/tests/Utilities/Unit/Hooks/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 @@ -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; } } diff --git a/packages/infrastructure/tests/Utilities/Unit/Scheduling/index.php b/packages/infrastructure/tests/Utilities/Unit/Scheduling/index.php deleted file mode 100644 index f767346..0000000 --- a/packages/infrastructure/tests/Utilities/Unit/Scheduling/index.php +++ /dev/null @@ -1 +0,0 @@ - */ @@ -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..45ec6f8 100644 --- a/packages/shared/src/ValueObject/Exceptions/InvalidValueObjectException.php +++ b/packages/shared/src/ValueObject/Exceptions/InvalidValueObjectException.php @@ -34,12 +34,12 @@ 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. */ 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/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/shared/tests/Integration/ValueObject/AbstractValueObjectTest.php b/packages/shared/tests/Integration/ValueObject/AbstractValueObjectTest.php index fba1ada..4fc8c8e 100644 --- a/packages/shared/tests/Integration/ValueObject/AbstractValueObjectTest.php +++ b/packages/shared/tests/Integration/ValueObject/AbstractValueObjectTest.php @@ -4,6 +4,7 @@ use DeepWebSolutions\Framework\Shared\ValueObject\AbstractValueObject; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesFunction; use PHPUnit\Framework\TestCase; final readonly class FixtureValueObject extends AbstractValueObject { @@ -52,6 +53,8 @@ public function __construct( } #[CoversClass( AbstractValueObject::class )] +#[UsesFunction( 'DeepWebSolutions\Framework\Shared\Reflection\get_public_property_names' )] +#[UsesFunction( 'DeepWebSolutions\Framework\Shared\Reflection\convert_to_primitives' )] final class AbstractValueObjectTest extends TestCase { public function test_equals_returns_true_for_structurally_identical_objects(): void { $a = new FixtureValueObject( 'foo', 1 ); 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. } diff --git a/packages/shared/tests/Unit/Version/VersionTest.php b/packages/shared/tests/Unit/Version/VersionTest.php index 0b8930a..b617b6e 100644 --- a/packages/shared/tests/Unit/Version/VersionTest.php +++ b/packages/shared/tests/Unit/Version/VersionTest.php @@ -7,6 +7,7 @@ use DeepWebSolutions\Framework\Shared\Version\Version; use DeepWebSolutions\Framework\Shared\Version\Exceptions\InvalidVersionException; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\Attributes\UsesFunction; use PHPUnit\Framework\TestCase; @@ -43,15 +44,23 @@ public function test_from_string_throws_on_invalid(): void { Version::from_string( 'not-a-version' ); } - public function test_from_string_rejects_empty_prerelease_and_build_identifiers(): void { - foreach ( array( '1.0.0-', '1.0.0-.', '1.0.0-alpha..1', '1.0.0+', '1.0.0+.' ) as $invalid ) { - try { - Version::from_string( $invalid ); - self::fail( "Expected InvalidVersionException for '{$invalid}'." ); - } catch ( InvalidVersionException ) { - $this->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/CHANGELOG.md b/packages/woocommerce/CHANGELOG.md index d37955b..3a86308 100644 --- a/packages/woocommerce/CHANGELOG.md +++ b/packages/woocommerce/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to `ahegyes/wp-framework-woocommerce` 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:woocommerce` from the monorepo root. Aggregate into a release with `composer changelog:write:woocommerce`. +Pending entries live in [`changelog/`](./changelog) — add via `composer packages:woocommerce:changelog:add` from the monorepo root. Aggregate into a release with `composer packages:woocommerce:changelog:write`. ## 2.0.0 - unreleased diff --git a/packages/woocommerce/README.md b/packages/woocommerce/README.md index 3daa2ef..d9f8b44 100644 --- a/packages/woocommerce/README.md +++ b/packages/woocommerce/README.md @@ -10,6 +10,43 @@ Part of the [DWS WordPress framework](https://github.com/ahegyes/wordpress-frame composer require ahegyes/wp-framework-woocommerce ``` +## Usage + +### Add a product-data tab + +Describe the tab with the settings descriptors and register it on a `ProductDataFieldSurface` — one surface drives one tab: + +```php +use DeepWebSolutions\Framework\Settings\Schema\Field\FieldType; +use DeepWebSolutions\Framework\Settings\Schema\ValueObjects\SettingsField; +use DeepWebSolutions\Framework\Settings\Schema\ValueObjects\SettingsSection; +use DeepWebSolutions\Framework\WooCommerce\ProductData\ProductDataFieldSurface; +use DeepWebSolutions\Framework\WooCommerce\ProductData\ProductDataTab; + +$surface = new ProductDataFieldSurface(); +$surface->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 2fb085e..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" @@ -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/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/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 83d060f..a0118c8 100644 --- a/packages/woocommerce/src/Backend/WooCommerceSettingsBackend.php +++ b/packages/woocommerce/src/Backend/WooCommerceSettingsBackend.php @@ -6,9 +6,11 @@ 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; +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; @@ -22,7 +24,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,10 +73,12 @@ 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. + * @param ?LoggerInterface $logger Logger for late-registration diagnostics; null silences them. */ public function __construct( protected string $page_class, + protected ?LoggerInterface $logger = null, ) {} // endregion @@ -87,6 +91,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,16 +100,30 @@ 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 ); + 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. \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 85% rename from packages/woocommerce/src/Backend/WCSettingsBuilder.php rename to packages/woocommerce/src/Backend/WooCommerceSettingsBuilder.php index a43af7d..b5718a7 100644 --- a/packages/woocommerce/src/Backend/WCSettingsBuilder.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. @@ -23,7 +24,7 @@ * @since 2.0.0 * @version 2.0.0 */ -final readonly class WCSettingsBuilder { +final readonly class WooCommerceSettingsBuilder { // region MAGIC METHODS /** @@ -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/OrderFieldStore.php b/packages/woocommerce/src/OrderData/OrderFieldSurface.php similarity index 86% rename from packages/woocommerce/src/OrderData/OrderFieldStore.php rename to packages/woocommerce/src/OrderData/OrderFieldSurface.php index ff62d92..4be63a6 100644 --- a/packages/woocommerce/src/OrderData/OrderFieldStore.php +++ b/packages/woocommerce/src/OrderData/OrderFieldSurface.php @@ -14,10 +14,10 @@ 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; /** - * 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 /** @@ -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 ); } /** @@ -303,7 +298,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 ); @@ -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, @@ -345,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 @@ -355,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/ProductDataFieldStore.php b/packages/woocommerce/src/ProductData/ProductDataFieldSurface.php similarity index 85% rename from packages/woocommerce/src/ProductData/ProductDataFieldStore.php rename to packages/woocommerce/src/ProductData/ProductDataFieldSurface.php index 21bd255..b73929b 100644 --- a/packages/woocommerce/src/ProductData/ProductDataFieldStore.php +++ b/packages/woocommerce/src/ProductData/ProductDataFieldSurface.php @@ -13,13 +13,14 @@ 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; /** - * 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 + * through WooCommerce's product CRUD. * - * 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 +29,7 @@ * @since 2.0.0 * @version 2.0.0 */ -final class ProductDataFieldStore { +final class ProductDataFieldSurface { // region FIELDS AND CONSTANTS /** @@ -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/infrastructure/tests/Storage/Unit/index.php b/packages/woocommerce/src/ProductData/index.php similarity index 100% rename from packages/infrastructure/tests/Storage/Unit/index.php rename to packages/woocommerce/src/ProductData/index.php 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 @@ -> - */ - 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/DescriptorBackedWCSettingsPageTest.php b/packages/woocommerce/tests/Integration/Backend/DescriptorBackedWooCommerceSettingsPageTest.php similarity index 72% rename from packages/woocommerce/tests/Integration/DescriptorBackedWCSettingsPageTest.php rename to packages/woocommerce/tests/Integration/Backend/DescriptorBackedWooCommerceSettingsPageTest.php index e098aff..2067dea 100644 --- a/packages/woocommerce/tests/Integration/DescriptorBackedWCSettingsPageTest.php +++ b/packages/woocommerce/tests/Integration/Backend/DescriptorBackedWooCommerceSettingsPageTest.php @@ -1,22 +1,22 @@ 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, - new SettingsPage( slug: 'dws-foo', page_title: 'Foo', menu_title: 'Foo', capability: 'manage_woocommerce' ), + DescriptorBackedWooCommerceSettingsPage::bind( + FooWooCommerceSettingsPage::class, + new SettingsPage( slug: 'dws-foo', page_title: 'Foo', menu_title: 'Foo', capability: 'manage_woocommerce', sections: array() ), ); - 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, - new SettingsPage( slug: 'dws-foo', page_title: 'Foo', menu_title: 'Foo', capability: 'manage_woocommerce', location: 'DWS Foo' ), + DescriptorBackedWooCommerceSettingsPage::bind( + FooWooCommerceSettingsPage::class, + 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 // 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/WooCommerceSettingsBackendTest.php b/packages/woocommerce/tests/Integration/Backend/WooCommerceSettingsBackendTest.php similarity index 87% rename from packages/woocommerce/tests/Integration/WooCommerceSettingsBackendTest.php rename to packages/woocommerce/tests/Integration/Backend/WooCommerceSettingsBackendTest.php index 6832eb6..c7c5886 100644 --- a/packages/woocommerce/tests/Integration/WooCommerceSettingsBackendTest.php +++ b/packages/woocommerce/tests/Integration/Backend/WooCommerceSettingsBackendTest.php @@ -1,6 +1,6 @@ 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/Integration/Conditionals/Dependencies/index.php b/packages/woocommerce/tests/Integration/Conditionals/Dependencies/index.php deleted file mode 100644 index f767346..0000000 --- a/packages/woocommerce/tests/Integration/Conditionals/Dependencies/index.php +++ /dev/null @@ -1 +0,0 @@ - - */ - 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(); } @@ -104,7 +85,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 +100,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 +110,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 +121,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 +134,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,8 +157,8 @@ public function test_a_bespoke_render_and_save_group_emits_the_nonce_and_saves() $saved_for = $object_id; }, ); - $store = new OrderFieldStore(); - $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 OrderFieldStore(); - $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 OrderFieldStore(); - $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 OrderFieldStore(); - $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 OrderFieldStore(); - $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 OrderFieldStore(); - $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 OrderFieldStore(); - $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 ); @@ -297,16 +278,16 @@ 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' ) ); } public function test_a_field_meta_key_overrides_the_id_for_storage(): void { - $repo = new OrderMetaRepository(); - $store = new OrderFieldStore(); - $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 OrderFieldStore(); - $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 OrderFieldStore(); - $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( @@ -376,7 +357,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 ) ); @@ -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 OrderFieldStore(); - $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 OrderFieldStore(); - $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 OrderFieldStore(); - $store->register( + $surface = new OrderFieldSurface(); + $surface->register( new FieldGroup( id: self::GROUP_ID, title: 'Dup', @@ -468,7 +449,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 +461,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,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 OrderFieldStore( + $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 OrderFieldStore(); - $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/ProductDataFieldStoreTest.php b/packages/woocommerce/tests/Integration/ProductData/ProductDataFieldSurfaceTest.php similarity index 67% rename from packages/woocommerce/tests/Integration/ProductDataFieldStoreTest.php rename to packages/woocommerce/tests/Integration/ProductData/ProductDataFieldSurfaceTest.php index e29abc1..fab176e 100644 --- a/packages/woocommerce/tests/Integration/ProductDataFieldStoreTest.php +++ b/packages/woocommerce/tests/Integration/ProductData/ProductDataFieldSurfaceTest.php @@ -1,20 +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(); @@ -66,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 ProductDataFieldStore(); - $store->register_tab( $this->tab() ); + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab() ); $tabs = \apply_filters( 'woocommerce_product_data_tabs', array() ); @@ -96,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 ProductDataFieldStore(); - $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() ); @@ -106,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 ProductDataFieldStore(); - $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() ); @@ -115,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 ProductDataFieldStore(); - $store->register_tab( $this->tab() ); + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab() ); \ob_start(); \do_action( 'woocommerce_product_data_panels' ); @@ -135,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 ProductDataFieldStore(); - $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' ); @@ -145,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 ProductDataFieldStore(); - $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( $this->product_id, 'general', 'warranty-type' ) ); + self::assertSame( 'addon', $surface->get( 'general', $this->product_id, 'warranty-type' ) ); } public function test_save_applies_the_field_sanitizer(): void { - $store = new ProductDataFieldStore(); - $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 ) ), ), @@ -170,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( $this->product_id, 'general', 'code' ) ); + self::assertSame( 'ABC', $surface->get( 'general', $this->product_id, 'code' ) ); } public function test_save_applies_the_builtin_default_sanitizer(): void { - $store = new ProductDataFieldStore(); - $raw = 'x'; - $store->register_tab( + $surface = new ProductDataFieldSurface(); + $raw = 'x'; + $surface->register_tab( $this->tab_with( new SettingsField( id: 'code', type: 'text', label: 'Code' ), ), @@ -185,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( $this->product_id, 'general', '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 ProductDataFieldStore(); - $store->register_tab( + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab_with( new SettingsField( id: 'warranty-type', @@ -203,17 +173,17 @@ public function test_save_preserves_an_existing_value_when_a_present_submission_ ), ), ); - $store->set( $this->product_id, 'general', '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( $this->product_id, 'general', '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 ProductDataFieldStore(); - $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 ); @@ -222,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 ProductDataFieldStore(); - $store->register_tab( + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab_with( new SettingsField( id: 'locations', @@ -241,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( $this->product_id, 'general', '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 ProductDataFieldStore(); - $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 ), @@ -254,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( $this->product_id, 'general', '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( $this->product_id, 'general', '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 ProductDataFieldStore(); - $store->register_tab( + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab_with( new SettingsField( id: 'span', @@ -283,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( $this->product_id, 'general', '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 ProductDataFieldStore(); + $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 ), ), @@ -299,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 ProductDataFieldStore(); + $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() ), @@ -312,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 ProductDataFieldStore(); - $store->register_tab( + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab_with( new SettingsField( id: 'span', @@ -331,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( $this->product_id, 'general', '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 ProductDataFieldStore(); - $store->register_tab( + $seen = null; + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab_with( new SettingsField( id: 'span', @@ -357,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( $this->product_id, 'general', '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 ProductDataFieldStore(); - $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 ); @@ -378,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 ProductDataFieldStore(); - $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 ); @@ -392,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 ProductDataFieldStore(); - $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 ProductDataFieldStore(); - $store->register_tab( + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab_with( new SettingsField( id: 'locations', @@ -425,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 ProductDataFieldStore(); - $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 ); @@ -437,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 ProductDataFieldStore(); - $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 ) ); @@ -452,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 ProductDataFieldStore(); - $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 ProductDataFieldStore(); - $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 ); @@ -470,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 ProductDataFieldStore(); + $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( @@ -491,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 ProductDataFieldStore(); - $store->register_tab( + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab( supports: static function ( int $product_id ) use ( &$gate_calls ): bool { ++$gate_calls; @@ -513,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 ProductDataFieldStore(); - $store->register_tab( + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab_with( new SettingsField( id: 'secret', type: 'text', label: 'Secret', capability: 'dws_protected_cap' ), ), @@ -534,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 ProductDataFieldStore(); - $store->register_tab( + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab_with( new SettingsField( id: 'secret', type: 'text', label: 'Secret', capability: 'dws_protected_cap' ), ), @@ -548,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 ProductDataFieldStore(); - $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' ), ), @@ -566,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 ProductDataFieldStore(); - $store->register_tab( + $surface = new ProductDataFieldSurface(); + $surface->register_tab( new ProductDataTab( slug: 'dws_warranty', label: 'Warranty', @@ -607,70 +565,142 @@ 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' ), $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 ); + $surface = 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. + $surface->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 ); } - // endregion + public function test_a_tab_level_custom_renderer_wins_over_a_renderer_registered_custom_type(): void { + $this->set_current_product( $this->product_id ); + $surface = new ProductDataFieldSurface( + new ProductDataFieldRenderer( + custom_types: array( + 'dws_rds' => new CustomFieldType( 'dws_rds', static fn (): string => '' ), + ), + ), + ); + + $surface->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 ''; + }, + ), + ), + ); - // region CRUD + UNINSTALL SURFACE + \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 { + $surface = 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. + $surface->register_tab( + $this->tab_with( + new SettingsField( id: 'span', type: 'dws_rds', label: 'Span' ), + ), + ); + } public function test_crud_round_trips_by_section_and_field(): void { - $store = new ProductDataFieldStore(); - $store->register_tab( $this->tab() ); + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab() ); - self::assertFalse( $store->has( $this->product_id, 'general', 'code' ) ); - self::assertFalse( $store->delete( $this->product_id, 'general', 'code' ) ); + self::assertFalse( $surface->has( 'general', $this->product_id, 'code' ) ); + self::assertFalse( $surface->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' ) ); + $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( $this->product_id, 'general', 'code' ) ); - self::assertFalse( $store->has( $this->product_id, 'general', '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 ProductDataFieldStore(); - $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( $this->product_id, 'general', 'flag', true ); + $surface->set( 'general', $this->product_id, 'flag', true ); - self::assertSame( 'yes', $store->get( $this->product_id, 'general', '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 ProductDataFieldStore(); - $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( $this->product_id, 'general', 'warranty-type', 'global' ); + $surface->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( $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 ProductDataFieldStore(); - $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( $this->product_id, 'general', 'warranty-type' ) ); - self::assertSame( 'global', $store->get( $this->product_id, 'general', '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 ProductDataFieldStore(); - $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( $this->product_id, 'general', '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 ProductDataFieldStore(); - $store->register_tab( + $surface = new ProductDataFieldSurface(); + $surface->register_tab( new ProductDataTab( slug: 'dws_warranty', label: 'Warranty', @@ -688,20 +718,26 @@ 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(), + $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. + $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 ProductDataFieldStore(); + $surface = new ProductDataFieldSurface(); $this->expectException( DuplicateSettingsFieldException::class ); - $store->register_tab( + $surface->register_tab( new ProductDataTab( slug: 'dws_warranty', label: 'Warranty', @@ -715,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 ProductDataFieldStore(); - $store->register_tab( $this->tab() ); + $surface = new ProductDataFieldSurface(); + $surface->register_tab( $this->tab() ); $this->expectException( InvalidSettingsFieldException::class ); - $store->get( $this->product_id, 'general', '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 ); @@ -780,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 @@ -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 +47,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 +55,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 +73,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 +81,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 +91,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 +99,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 +109,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 +127,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 +142,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 +160,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 +172,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 +182,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 +192,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 +202,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 +212,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 +223,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 +234,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 +252,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 +268,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 +288,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 +298,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 +308,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 +316,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 +327,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 +346,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,20 +356,23 @@ 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'] ); } 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 WCSettingsBuilder() )->build( $page ) ); + self::assertSame( array(), ( new WooCommerceSettingsBuilder() )->build( $page ) ); } private function page(): SettingsPage { diff --git a/packages/woocommerce/tests/Unit/Conditionals/Dependencies/index.php b/packages/woocommerce/tests/Unit/Conditionals/Dependencies/index.php deleted file mode 100644 index f767346..0000000 --- a/packages/woocommerce/tests/Unit/Conditionals/Dependencies/index.php +++ /dev/null @@ -1 +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/OrderData/OrderFieldStoreTest.php b/packages/woocommerce/tests/Unit/OrderData/OrderFieldSurfaceTest.php similarity index 72% rename from packages/woocommerce/tests/Unit/OrderData/OrderFieldStoreTest.php rename to packages/woocommerce/tests/Unit/OrderData/OrderFieldSurfaceTest.php index 8fa2f37..52f58f3 100644 --- a/packages/woocommerce/tests/Unit/OrderData/OrderFieldStoreTest.php +++ b/packages/woocommerce/tests/Unit/OrderData/OrderFieldSurfaceTest.php @@ -9,15 +9,15 @@ 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 DeepWebSolutions\Framework\WooCommerce\OrderData\OrderFieldStore; -use DeepWebSolutions\Framework\WooCommerce\Tests\Fixtures\InMemoryObjectMetaRepository; +use DeepWebSolutions\Framework\WooCommerce\OrderData\OrderFieldSurface; 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,31 +28,31 @@ #[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 $surface; protected function setUp(): void { parent::setUp(); $this->repository = new InMemoryObjectMetaRepository(); - $this->store = new OrderFieldStore( 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 80% rename from packages/woocommerce/tests/Unit/ProductDataFieldRendererTest.php rename to packages/woocommerce/tests/Unit/ProductData/ProductDataFieldRendererTest.php index 16a3884..a941680 100644 --- a/packages/woocommerce/tests/Unit/ProductDataFieldRendererTest.php +++ b/packages/woocommerce/tests/Unit/ProductData/ProductDataFieldRendererTest.php @@ -1,9 +1,11 @@ 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/ProductDataTabTest.php b/packages/woocommerce/tests/Unit/ProductData/ProductDataTabTest.php similarity index 98% rename from packages/woocommerce/tests/Unit/ProductDataTabTest.php rename to packages/woocommerce/tests/Unit/ProductData/ProductDataTabTest.php index c3898d0..658d6f2 100644 --- a/packages/woocommerce/tests/Unit/ProductDataTabTest.php +++ b/packages/woocommerce/tests/Unit/ProductData/ProductDataTabTest.php @@ -1,6 +1,6 @@ __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'], ) ); 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().