diff --git a/CHANGELOG.md b/CHANGELOG.md index b94640c1..40161204 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/Pollora/framework/compare/v13.38.1...develop) +### Changed +- The domain layer no longer calls WordPress or imports the Infrastructure layer (harness rules ARCH-01 and ARCH-02). Theme menus, sidebars and image sizes are registered through `ThemeFeatureRegistrarInterface`, the theme URI filters resolve through `ContentUrlResolverInterface` and `ThemeAssetUrlResolverInterface`, the dashboard reads WordPress through `WordPressEnvironmentInterface`, `AbstractTaxonomy` translates its default labels through `LabelTranslatorInterface`, and `WooCommerceService` asks `WooCommerceEnvironmentInterface` about the child theme. Each port is bound to a WordPress adapter by its module's service provider; behaviour and public signatures are unchanged + ## [v13.38.1](https://github.com/Pollora/framework/compare/v13.38.0...v13.38.1) - 2026-10-21 ### Fixed diff --git a/src/Dashboard/Domain/Contracts/WordPressEnvironmentInterface.php b/src/Dashboard/Domain/Contracts/WordPressEnvironmentInterface.php new file mode 100644 index 00000000..4bddb401 --- /dev/null +++ b/src/Dashboard/Domain/Contracts/WordPressEnvironmentInterface.php @@ -0,0 +1,48 @@ +wordpress->locale() ?? 'Unknown'; return [ 'override_active' => $this->isTranslationOverrideActive(), @@ -156,9 +157,7 @@ public function collectFrameworkInfo(): array */ public function collectEnvironmentInfo(): array { - $wpVersion = function_exists('get_bloginfo') - ? (string) get_bloginfo('version') - : 'Unknown'; + $wpVersion = $this->wordpress->version() ?? 'Unknown'; return [ 'php' => PHP_VERSION, @@ -173,10 +172,8 @@ public function collectEnvironmentInfo(): array public function collectWordPressConfig(): array { $debug = defined('WP_DEBUG') && WP_DEBUG; - $multisite = function_exists('is_multisite') && is_multisite(); - $permalink = function_exists('get_option') - ? (string) get_option('permalink_structure', '') - : ''; + $multisite = $this->wordpress->isMultisite(); + $permalink = $this->wordpress->permalinkStructure(); return [ 'debug' => $debug, @@ -290,28 +287,14 @@ private function attributeOf(string $class, string $attributeClass): ?object private function getPostTypeLabel(string $slug, string $class, ?string $plural = null): string { - if (function_exists('get_post_type_object')) { - $object = get_post_type_object($slug); - - if ($object !== null && isset($object->labels->name)) { - return $object->labels->name; - } - } - - return $plural ?? StringHelper::headline(class_basename($class)); + return $this->wordpress->postTypeLabel($slug) + ?? $plural ?? StringHelper::headline(class_basename($class)); } private function getTaxonomyLabel(string $slug, string $class, ?string $plural = null): string { - if (function_exists('get_taxonomy')) { - $object = get_taxonomy($slug); - - if ($object !== false && isset($object->labels->name)) { - return $object->labels->name; - } - } - - return $plural ?? StringHelper::headline(class_basename($class)); + return $this->wordpress->taxonomyLabel($slug) + ?? $plural ?? StringHelper::headline(class_basename($class)); } /** @@ -506,16 +489,7 @@ private function moduleConnectorLabel(): ?string */ public function collectThemeInfo(): array { - if (! function_exists('wp_get_theme')) { - return ['name' => 'Unknown', 'version' => 'Unknown', 'template' => 'Unknown']; - } - - $theme = wp_get_theme(); - - return [ - 'name' => (string) $theme->get('Name'), - 'version' => (string) $theme->get('Version'), - 'template' => (string) $theme->get_template(), - ]; + return $this->wordpress->activeTheme() + ?? ['name' => 'Unknown', 'version' => 'Unknown', 'template' => 'Unknown']; } } diff --git a/src/Dashboard/Domain/Support/UnavailableWordPressEnvironment.php b/src/Dashboard/Domain/Support/UnavailableWordPressEnvironment.php new file mode 100644 index 00000000..ab95d97b --- /dev/null +++ b/src/Dashboard/Domain/Support/UnavailableWordPressEnvironment.php @@ -0,0 +1,48 @@ +labels->name) ? $object->labels->name : null; + } + + public function taxonomyLabel(string $slug): ?string + { + $object = function_exists('get_taxonomy') ? get_taxonomy($slug) : false; + + return $object !== false && isset($object->labels->name) ? $object->labels->name : null; + } + + public function activeTheme(): ?array + { + $theme = function_exists('wp_get_theme') ? wp_get_theme() : null; + + return $theme === null ? null : [ + 'name' => (string) $theme->get('Name'), + 'version' => (string) $theme->get('Version'), + 'template' => (string) $theme->get_template(), + ]; + } +} diff --git a/src/Dashboard/Infrastructure/Providers/DashboardServiceProvider.php b/src/Dashboard/Infrastructure/Providers/DashboardServiceProvider.php index 3e53b449..c8b6351b 100644 --- a/src/Dashboard/Infrastructure/Providers/DashboardServiceProvider.php +++ b/src/Dashboard/Infrastructure/Providers/DashboardServiceProvider.php @@ -6,7 +6,9 @@ use Illuminate\Foundation\Application; use Illuminate\Support\ServiceProvider; +use Pollora\Dashboard\Domain\Contracts\WordPressEnvironmentInterface; use Pollora\Dashboard\Domain\Services\SystemInfoCollector; +use Pollora\Dashboard\Infrastructure\Adapters\WordPressEnvironment; use Pollora\Dashboard\UI\Console\StatusCommand; use Pollora\Dashboard\UI\Http\DashboardController; use Pollora\Discovery\Application\Services\DiscoveryManager; @@ -24,11 +26,14 @@ class DashboardServiceProvider extends ServiceProvider { public function register(): void { + $this->app->singleton(WordPressEnvironmentInterface::class, WordPressEnvironment::class); + $this->app->singleton(SystemInfoCollector::class, fn ($app): SystemInfoCollector => new SystemInfoCollector( $app->make(VersionComparator::class), $app->make(DiscoveryManager::class), $app, Application::VERSION, + $app->make(WordPressEnvironmentInterface::class), )); if ($this->app->runningInConsole()) { diff --git a/src/Taxonomy/Domain/Contracts/LabelTranslatorInterface.php b/src/Taxonomy/Domain/Contracts/LabelTranslatorInterface.php new file mode 100644 index 00000000..f064b242 --- /dev/null +++ b/src/Taxonomy/Domain/Contracts/LabelTranslatorInterface.php @@ -0,0 +1,19 @@ + $name, 'menu_name' => $pluralName, /* translators: %s: taxonomy general name (plural) */ - 'all_items' => sprintf(__('All %s', 'pollora'), $pluralName), + 'all_items' => sprintf($this->translateLabel('All %s'), $pluralName), /* translators: %s: taxonomy singular name */ - 'edit_item' => sprintf(__('Edit %s', 'pollora'), $name), + 'edit_item' => sprintf($this->translateLabel('Edit %s'), $name), /* translators: %s: taxonomy singular name */ - 'view_item' => sprintf(__('View %s', 'pollora'), $name), + 'view_item' => sprintf($this->translateLabel('View %s'), $name), /* translators: %s: taxonomy singular name */ - 'update_item' => sprintf(__('Update %s', 'pollora'), $name), + 'update_item' => sprintf($this->translateLabel('Update %s'), $name), /* translators: %s: taxonomy singular name */ - 'add_new_item' => sprintf(__('Add New %s', 'pollora'), $name), + 'add_new_item' => sprintf($this->translateLabel('Add New %s'), $name), /* translators: %s: taxonomy singular name */ - 'new_item_name' => sprintf(__('New %s Name', 'pollora'), $name), + 'new_item_name' => sprintf($this->translateLabel('New %s Name'), $name), /* translators: %s: taxonomy general name (plural) */ - 'search_items' => sprintf(__('Search %s', 'pollora'), $pluralName), + 'search_items' => sprintf($this->translateLabel('Search %s'), $pluralName), /* translators: %s: taxonomy general name (plural) */ - 'popular_items' => sprintf(__('Popular %s', 'pollora'), $pluralName), + 'popular_items' => sprintf($this->translateLabel('Popular %s'), $pluralName), /* translators: %s: taxonomy general name (plural) */ - 'separate_items_with_commas' => sprintf(__('Separate %s with commas', 'pollora'), $pluralName), + 'separate_items_with_commas' => sprintf($this->translateLabel('Separate %s with commas'), $pluralName), /* translators: %s: taxonomy general name (plural) */ - 'add_or_remove_items' => sprintf(__('Add or remove %s', 'pollora'), $pluralName), + 'add_or_remove_items' => sprintf($this->translateLabel('Add or remove %s'), $pluralName), /* translators: %s: taxonomy general name (plural) */ - 'choose_from_most_used' => sprintf(__('Choose from the most used %s', 'pollora'), $pluralName), + 'choose_from_most_used' => sprintf($this->translateLabel('Choose from the most used %s'), $pluralName), /* translators: %s: taxonomy general name (plural) */ - 'not_found' => sprintf(__('No %s found', 'pollora'), $pluralName), + 'not_found' => sprintf($this->translateLabel('No %s found'), $pluralName), /* translators: %s: taxonomy singular name */ - 'parent_item' => sprintf(__('Parent %s', 'pollora'), $name), + 'parent_item' => sprintf($this->translateLabel('Parent %s'), $name), /* translators: %s: taxonomy singular name */ - 'parent_item_colon' => sprintf(__('Parent %s:', 'pollora'), $name), + 'parent_item_colon' => sprintf($this->translateLabel('Parent %s:'), $name), ]; } + /** + * Translate a label template in the framework's text domain. + */ + private function translateLabel(string $text): string + { + return self::$labelTranslator instanceof LabelTranslatorInterface + ? self::$labelTranslator->translate($text, 'pollora') + : $text; + } + /** * Get the arguments for registering the taxonomy. * diff --git a/src/Taxonomy/Infrastructure/Providers/TaxonomyServiceProvider.php b/src/Taxonomy/Infrastructure/Providers/TaxonomyServiceProvider.php index 86ad34bc..d240364b 100755 --- a/src/Taxonomy/Infrastructure/Providers/TaxonomyServiceProvider.php +++ b/src/Taxonomy/Infrastructure/Providers/TaxonomyServiceProvider.php @@ -6,14 +6,17 @@ use Illuminate\Support\ServiceProvider; use Pollora\Taxonomy\Application\Services\TaxonomyService; +use Pollora\Taxonomy\Domain\Contracts\LabelTranslatorInterface; use Pollora\Taxonomy\Domain\Contracts\TaxonomyFactoryInterface; use Pollora\Taxonomy\Domain\Contracts\TaxonomyRegistryInterface; use Pollora\Taxonomy\Domain\Contracts\TaxonomyRepositoryInterface; use Pollora\Taxonomy\Domain\Contracts\TaxonomyServiceInterface; +use Pollora\Taxonomy\Domain\Models\AbstractTaxonomy; use Pollora\Taxonomy\Infrastructure\Adapters\WordPressTaxonomyRegistry; use Pollora\Taxonomy\Infrastructure\Factories\TaxonomyFactory; use Pollora\Taxonomy\Infrastructure\Repositories\TaxonomyRepository; use Pollora\Taxonomy\Infrastructure\Services\TaxonomyDiscovery; +use Pollora\Taxonomy\Infrastructure\Services\WordPressLabelTranslator; use Pollora\Taxonomy\UI\Console\TaxonomyMakeCommand; /** @@ -32,6 +35,11 @@ public function register(): void // Bind interfaces to implementations $this->app->singleton(TaxonomyFactoryInterface::class, TaxonomyFactory::class); $this->app->singleton(TaxonomyRegistryInterface::class, WordPressTaxonomyRegistry::class); + $this->app->singleton(LabelTranslatorInterface::class, WordPressLabelTranslator::class); + + // Taxonomy classes are built without arguments: hand their default + // labels the translator once, rather than through every constructor + AbstractTaxonomy::useLabelTranslator($this->app->make(LabelTranslatorInterface::class)); // Register the repository $this->app->singleton(TaxonomyRepositoryInterface::class, fn ($app): TaxonomyRepository => new TaxonomyRepository( diff --git a/src/Taxonomy/Infrastructure/Services/WordPressLabelTranslator.php b/src/Taxonomy/Infrastructure/Services/WordPressLabelTranslator.php new file mode 100644 index 00000000..1c40c573 --- /dev/null +++ b/src/Taxonomy/Infrastructure/Services/WordPressLabelTranslator.php @@ -0,0 +1,18 @@ + $locations Menu location slugs and their labels + */ + public function registerNavMenus(array $locations): void; + + /** + * Register a sidebar (widget area). + * + * @param array|string $args The sidebar arguments, as WordPress takes them + */ + public function registerSidebar(array|string $args): void; + + /** + * Register an image size. + * + * The dimensions and the crop flag are passed through as the theme config + * declares them, so WordPress keeps interpreting them as it always has. + */ + public function addImageSize(int|string $name, mixed $width = 0, mixed $height = 0, mixed $crop = false): void; +} diff --git a/src/Theme/Domain/Models/ImageSize.php b/src/Theme/Domain/Models/ImageSize.php index 32d3454f..94475c87 100755 --- a/src/Theme/Domain/Models/ImageSize.php +++ b/src/Theme/Domain/Models/ImageSize.php @@ -13,6 +13,7 @@ use Pollora\Config\Domain\Contracts\ConfigRepositoryInterface; use Pollora\Hook\Domain\Contract\Action; use Pollora\Theme\Domain\Contracts\ThemeComponent; +use Pollora\Theme\Domain\Contracts\ThemeFeatureRegistrarInterface; use Pollora\Theme\Domain\Support\ThemeConfig; use Psr\Container\ContainerInterface; @@ -42,8 +43,10 @@ public function addImageSize(): void { $images = ThemeConfig::get('images', []); + $features = $this->app->get(ThemeFeatureRegistrarInterface::class); + foreach ($images as $name => $sizes) { - \add_image_size( + $features->addImageSize( $name, $sizes[0] ?? 0, $sizes[1] ?? 0, diff --git a/src/Theme/Domain/Models/Menus.php b/src/Theme/Domain/Models/Menus.php index 4e42635e..b28c5754 100755 --- a/src/Theme/Domain/Models/Menus.php +++ b/src/Theme/Domain/Models/Menus.php @@ -18,6 +18,7 @@ use Pollora\Hook\Domain\Contract\Filter; use Pollora\Services\Translater; use Pollora\Theme\Domain\Contracts\ThemeComponent; +use Pollora\Theme\Domain\Contracts\ThemeFeatureRegistrarInterface; use Pollora\Theme\Domain\Support\ThemeConfig; use Psr\Container\ContainerInterface; @@ -77,7 +78,7 @@ public function registerMenus(): void $translater = new Translater($menus, 'menus'); $menus = $translater->translate(['*']); - \register_nav_menus($menus); + $this->app->get(ThemeFeatureRegistrarInterface::class)->registerNavMenus($menus); } /** diff --git a/src/Theme/Domain/Models/Sidebar.php b/src/Theme/Domain/Models/Sidebar.php index 0e40f7b1..48f27c0f 100755 --- a/src/Theme/Domain/Models/Sidebar.php +++ b/src/Theme/Domain/Models/Sidebar.php @@ -8,6 +8,7 @@ use Pollora\Hook\Domain\Contract\Action; use Pollora\Services\Translater; use Pollora\Theme\Domain\Contracts\ThemeComponent; +use Pollora\Theme\Domain\Contracts\ThemeFeatureRegistrarInterface; use Pollora\Theme\Domain\Support\ThemeConfig; use Psr\Container\ContainerInterface; @@ -39,8 +40,10 @@ public function registerSidebars(): void $translater = new Translater($sidebars, 'sidebars'); $sidebars = $translater->translate(['*.name', '*.description']); + $features = $this->app->get(ThemeFeatureRegistrarInterface::class); + foreach ($sidebars as $value) { - \register_sidebar($value); + $features->registerSidebar($value); } } } diff --git a/src/Theme/Domain/Models/ThemeInitializer.php b/src/Theme/Domain/Models/ThemeInitializer.php index c54efe46..954f5644 100644 --- a/src/Theme/Domain/Models/ThemeInitializer.php +++ b/src/Theme/Domain/Models/ThemeInitializer.php @@ -5,11 +5,12 @@ namespace Pollora\Theme\Domain\Models; use Pollora\Asset\Application\Services\AssetManager; -use Pollora\Asset\Infrastructure\Services\AssetFile; use Pollora\Config\Domain\Contracts\ConfigRepositoryInterface; use Pollora\Hook\Domain\Contract\Action; use Pollora\Hook\Domain\Contract\Filter; use Pollora\Theme\Domain\Contracts\ContainerInterface; +use Pollora\Theme\Domain\Contracts\ContentUrlResolverInterface; +use Pollora\Theme\Domain\Contracts\ThemeAssetUrlResolverInterface; use Pollora\Theme\Domain\Contracts\ThemeComponent; use Pollora\Theme\Domain\Contracts\ThemeModuleInterface; use Pollora\Theme\Domain\Contracts\ThemeRegistrarInterface; @@ -241,7 +242,7 @@ public function resolveThemeRootUri(mixed $uri): mixed // asset has, which is what theme_file_uri now answers — but a URL // that does not resolve is still better than a path that says where // the site lives on disk. - return \content_url('themes'); + return $this->app->get(ContentUrlResolverInterface::class)->contentUrl('themes'); } /** @@ -281,13 +282,13 @@ protected function builtAssetUrl(string $file): ?string { foreach ($this->manifestCandidates($file, $this->assetRoot()) as $candidate) { try { - $url = (string) (new AssetFile($candidate))->from('theme'); + $url = $this->app->get(ThemeAssetUrlResolverInterface::class)->assetUrl($candidate); } catch (\Throwable) { - // AssetFile means to swallow a missing manifest entry, but it - // logs before returning, and the logger is itself something - // that can be absent. An exception escaping here would break - // get_theme_file_uri() for every caller — the opposite of the - // fallback this method exists to provide. + // The asset resolver means to swallow a missing manifest + // entry, but it logs before returning, and the logger is + // itself something that can be absent. An exception escaping + // here would break get_theme_file_uri() for every caller — the + // opposite of the fallback this method exists to provide. continue; } diff --git a/src/Theme/Infrastructure/Providers/ThemeServiceProvider.php b/src/Theme/Infrastructure/Providers/ThemeServiceProvider.php index 0a144f2f..aaeca5b6 100755 --- a/src/Theme/Infrastructure/Providers/ThemeServiceProvider.php +++ b/src/Theme/Infrastructure/Providers/ThemeServiceProvider.php @@ -17,6 +17,9 @@ use Pollora\Theme\Application\Services\ThemeManager; use Pollora\Theme\Application\Services\ThemeRegistrar; use Pollora\Theme\Domain\Contracts\ContainerInterface; +use Pollora\Theme\Domain\Contracts\ContentUrlResolverInterface; +use Pollora\Theme\Domain\Contracts\ThemeAssetUrlResolverInterface; +use Pollora\Theme\Domain\Contracts\ThemeFeatureRegistrarInterface; use Pollora\Theme\Domain\Contracts\ThemeJsonResolverInterface; use Pollora\Theme\Domain\Contracts\ThemeRegistrarInterface; use Pollora\Theme\Domain\Contracts\ThemeService; @@ -27,10 +30,13 @@ use Pollora\Theme\Infrastructure\Models\LaravelThemeModule; use Pollora\Theme\Infrastructure\Repositories\ThemeRepository; use Pollora\Theme\Infrastructure\Services\EditorStyleResolver; +use Pollora\Theme\Infrastructure\Services\ThemeAssetFileResolver; use Pollora\Theme\Infrastructure\Services\ThemeAutoloader; use Pollora\Theme\Infrastructure\Services\ThemeJsonResolver; use Pollora\Theme\Infrastructure\Services\ThemeUpdateGuard; +use Pollora\Theme\Infrastructure\Services\WordPressContentUrlResolver; use Pollora\Theme\Infrastructure\Services\WordPressThemeAdapter; +use Pollora\Theme\Infrastructure\Services\WordPressThemeFeatureRegistrar; use Pollora\Theme\Infrastructure\Services\WordPressThemeParser; use Pollora\Theme\UI\Console\Commands\ThemeStatusCommand; use Pollora\Theme\UI\Console\MakeThemeCommand; @@ -260,6 +266,11 @@ private function registerWordPressServices(): void // WordPress theme interface adapter $this->app->singleton(WordPressThemeInterface::class, WordPressThemeAdapter::class); + // Ports the theme components reach WordPress and the asset build through + $this->app->singleton(ThemeFeatureRegistrarInterface::class, WordPressThemeFeatureRegistrar::class); + $this->app->singleton(ContentUrlResolverInterface::class, WordPressContentUrlResolver::class); + $this->app->singleton(ThemeAssetUrlResolverInterface::class, ThemeAssetFileResolver::class); + // WordPress theme parser $this->app->singleton(WordPressThemeParser::class); diff --git a/src/Theme/Infrastructure/Services/ThemeAssetFileResolver.php b/src/Theme/Infrastructure/Services/ThemeAssetFileResolver.php new file mode 100644 index 00000000..739a45d4 --- /dev/null +++ b/src/Theme/Infrastructure/Services/ThemeAssetFileResolver.php @@ -0,0 +1,19 @@ +from('theme'); + } +} diff --git a/src/Theme/Infrastructure/Services/WordPressContentUrlResolver.php b/src/Theme/Infrastructure/Services/WordPressContentUrlResolver.php new file mode 100644 index 00000000..55f88fcb --- /dev/null +++ b/src/Theme/Infrastructure/Services/WordPressContentUrlResolver.php @@ -0,0 +1,18 @@ +template_path(); - } - - $paths[] = get_template_directory().'/'.$templatePath; + if ($this->environment->isChildTheme()) { + $paths[] = $this->environment->getTemplateDirectory().'/'.$this->environment->getWooCommerceTemplatePath(); } return $paths; @@ -77,11 +79,7 @@ public function isWooCommerceStatusScreen(bool $isAdmin, bool $isDoingAjax, ?obj */ public function getWooCommerceTemplatePath(): string { - if (function_exists('WC') && WC() && method_exists(WC(), 'template_path')) { - return WC()->template_path(); - } - - return 'woocommerce/'; + return $this->environment->getWooCommerceTemplatePath(); } /** diff --git a/src/ThirdParty/WooCommerce/Domain/Support/DefaultWooCommerceEnvironment.php b/src/ThirdParty/WooCommerce/Domain/Support/DefaultWooCommerceEnvironment.php new file mode 100644 index 00000000..38357550 --- /dev/null +++ b/src/ThirdParty/WooCommerce/Domain/Support/DefaultWooCommerceEnvironment.php @@ -0,0 +1,29 @@ +app->singleton(WooCommerceService::class); } @@ -62,6 +64,7 @@ private function registerInfrastructureServices(): void { // Register the WordPress adapter $this->app->singleton(WordPressWooCommerceAdapter::class); + $this->app->singleton(WooCommerceEnvironmentInterface::class, fn (Container $app): WordPressWooCommerceAdapter => $app->make(WordPressWooCommerceAdapter::class)); // Register the main WooCommerce integration implementation $this->app->singleton(WooCommerceIntegrationInterface::class, fn (Container $app): WooCommerce => new WooCommerce( diff --git a/src/VersionCheck/Domain/Contracts/VersionCheckerInterface.php b/src/VersionCheck/Domain/Contracts/VersionCheckerInterface.php index 64798498..1d6b5e61 100644 --- a/src/VersionCheck/Domain/Contracts/VersionCheckerInterface.php +++ b/src/VersionCheck/Domain/Contracts/VersionCheckerInterface.php @@ -4,15 +4,13 @@ namespace Pollora\VersionCheck\Domain\Contracts; -use Pollora\VersionCheck\Infrastructure\Services\PackagistVersionChecker; - /** * Contract for checking Pollora framework version information. * * Implementations are responsible for determining the currently installed * version and fetching the latest available version from a remote source. - * - * @see PackagistVersionChecker + * The framework's own implementation, PackagistVersionChecker, lives in the + * Infrastructure layer of this module and asks Packagist. */ interface VersionCheckerInterface { diff --git a/tests/Feature/Dashboard/WordPressEnvironmentThemeTest.php b/tests/Feature/Dashboard/WordPressEnvironmentThemeTest.php new file mode 100644 index 00000000..54721da1 --- /dev/null +++ b/tests/Feature/Dashboard/WordPressEnvironmentThemeTest.php @@ -0,0 +1,29 @@ +justReturn(new class + { + public function get(string $header): string + { + return ['Name' => 'Apiary', 'Version' => '1.2.0'][$header] ?? ''; + } + + public function get_template(): string + { + return 'apiary'; + } + }); + + expect((new WordPressEnvironment)->activeTheme()) + ->toBe(['name' => 'Apiary', 'version' => '1.2.0', 'template' => 'apiary']); +}); diff --git a/tests/Unit/Dashboard/Infrastructure/WordPressEnvironmentTest.php b/tests/Unit/Dashboard/Infrastructure/WordPressEnvironmentTest.php new file mode 100644 index 00000000..cdce9a20 --- /dev/null +++ b/tests/Unit/Dashboard/Infrastructure/WordPressEnvironmentTest.php @@ -0,0 +1,85 @@ +toBeInstanceOf(WordPressEnvironmentInterface::class); + }); + + it('reads the locale from get_locale()', function (): void { + Functions\when('get_locale')->justReturn('fr_FR'); + + expect((new WordPressEnvironment)->locale())->toBe('fr_FR'); + }); + + it('reads the version from get_bloginfo()', function (): void { + Functions\expect('get_bloginfo')->once()->with('version')->andReturn('6.8.1'); + + expect((new WordPressEnvironment)->version())->toBe('6.8.1'); + }); + + it('is not multisite while WordPress is not loaded', function (): void { + // is_multisite() is deliberately left undefined: once Brain Monkey + // defines a function it stays defined for the whole run, and the async + // hook runner in pollora/hook relies on this one being absent. + expect((new WordPressEnvironment)->isMultisite())->toBeFalse(); + }); + + it('reads the permalink structure from the option', function (): void { + // The suite stubs get_option() for every test; override the stub + Functions\when('get_option')->alias( + fn (string $option, mixed $default = false): mixed => $option === 'permalink_structure' ? '/%postname%/' : $default + ); + + expect((new WordPressEnvironment)->permalinkStructure())->toBe('/%postname%/'); + }); + + it('reads a post type label from its registered object', function (): void { + Functions\when('get_post_type_object')->alias( + fn (string $slug): ?object => $slug === 'project' ? (object) ['labels' => (object) ['name' => 'Projects']] : null + ); + + $environment = new WordPressEnvironment; + + expect($environment->postTypeLabel('project'))->toBe('Projects') + ->and($environment->postTypeLabel('unknown'))->toBeNull(); + }); + + it('reads a taxonomy label from its registered object', function (): void { + Functions\when('get_taxonomy')->alias( + fn (string $slug): object|false => $slug === 'genre' ? (object) ['labels' => (object) ['name' => 'Genres']] : false + ); + + $environment = new WordPressEnvironment; + + expect($environment->taxonomyLabel('genre'))->toBe('Genres') + ->and($environment->taxonomyLabel('unknown'))->toBeNull(); + }); +}); + +describe('UnavailableWordPressEnvironment', function (): void { + it('knows nothing', function (): void { + $environment = new UnavailableWordPressEnvironment; + + expect($environment->locale())->toBeNull() + ->and($environment->version())->toBeNull() + ->and($environment->isMultisite())->toBeFalse() + ->and($environment->permalinkStructure())->toBe('') + ->and($environment->postTypeLabel('post'))->toBeNull() + ->and($environment->taxonomyLabel('category'))->toBeNull() + ->and($environment->activeTheme())->toBeNull(); + }); +}); diff --git a/tests/Unit/Dashboard/SystemInfoCollectorTest.php b/tests/Unit/Dashboard/SystemInfoCollectorTest.php index 73b031e7..87b1ae77 100644 --- a/tests/Unit/Dashboard/SystemInfoCollectorTest.php +++ b/tests/Unit/Dashboard/SystemInfoCollectorTest.php @@ -5,6 +5,7 @@ use Nwidart\Modules\Module; use Pollora\Attributes\PostType; use Pollora\Attributes\Taxonomy; +use Pollora\Dashboard\Domain\Contracts\WordPressEnvironmentInterface; use Pollora\Dashboard\Domain\Services\SystemInfoCollector; use Pollora\Discovery\Application\Services\DiscoveryManager; use Pollora\Discovery\Domain\Contracts\DiscoveryEngineInterface; @@ -28,6 +29,7 @@ function createCollector( ?DiscoveryManager $manager = null, ?ContainerInterface $container = null, string $laravelVersion = '11.0.0', + ?WordPressEnvironmentInterface $wordpress = null, ): SystemInfoCollector { $comparator ??= new VersionComparator( Mockery::mock(VersionCheckerInterface::class, [ @@ -39,7 +41,11 @@ function createCollector( $manager ??= Mockery::mock(DiscoveryManager::class); $container ??= Mockery::mock(ContainerInterface::class); - return new SystemInfoCollector($comparator, $manager, $container, $laravelVersion); + if (! $wordpress instanceof WordPressEnvironmentInterface) { + return new SystemInfoCollector($comparator, $manager, $container, $laravelVersion); + } + + return new SystemInfoCollector($comparator, $manager, $container, $laravelVersion, $wordpress); } describe('SystemInfoCollector', function (): void { @@ -108,6 +114,16 @@ function createCollector( expect($info['laravel'])->toBe('11.0.0'); expect($info)->toHaveKeys(['php', 'laravel', 'wordpress']); }); + + it('reports Unknown for WordPress when it runs without WordPress', function (): void { + expect(createCollector()->collectEnvironmentInfo()['wordpress'])->toBe('Unknown'); + }); + + it('reports the WordPress version the environment gives', function (): void { + $wordpress = Mockery::mock(WordPressEnvironmentInterface::class, ['version' => '6.8.1']); + + expect(createCollector(wordpress: $wordpress)->collectEnvironmentInfo()['wordpress'])->toBe('6.8.1'); + }); }); describe('collectWordPressConfig', function (): void { @@ -119,6 +135,22 @@ function createCollector( expect($info['debug'])->toBeBool(); expect($info['multisite'])->toBeBool(); }); + + it('reports the multisite flag and permalink structure the environment gives', function (): void { + $wordpress = Mockery::mock(WordPressEnvironmentInterface::class, [ + 'isMultisite' => true, + 'permalinkStructure' => '/%postname%/', + ]); + + expect(createCollector(wordpress: $wordpress)->collectWordPressConfig())->toMatchArray([ + 'multisite' => true, + 'permalink_structure' => '/%postname%/', + ]); + }); + + it('calls an empty permalink structure Plain', function (): void { + expect(createCollector()->collectWordPressConfig()['permalink_structure'])->toBe('Plain'); + }); }); describe('collectDiscoveryInfo', function (): void { @@ -161,6 +193,22 @@ function createCollector( ->and($info['taxonomies']['items'][0])->toMatchArray(['slug' => 'theme-presse', 'label' => 'Thèmes de presse']); }); + it('prefers the label WordPress registered over the attribute', function (): void { + $manager = Mockery::mock(DiscoveryManager::class); + $manager->shouldReceive('getDiscoveredItems')->with('post_types')->andReturn([['class' => SyntheseDePresse::class]]); + $manager->shouldReceive('getDiscoveredItems')->with('taxonomies')->andReturn([['class' => ThemeDePresse::class]]); + $manager->shouldReceive('getDiscoveredItems')->withAnyArgs()->andReturn([]); + + $wordpress = Mockery::mock(WordPressEnvironmentInterface::class); + $wordpress->shouldReceive('postTypeLabel')->with('synthese-presse')->andReturn('Revues de presse'); + $wordpress->shouldReceive('taxonomyLabel')->with('theme-presse')->andReturn('Rubriques'); + + $info = createCollector(manager: $manager, wordpress: $wordpress)->collectDiscoveryInfo(); + + expect($info['post_types']['items'][0]['label'])->toBe('Revues de presse') + ->and($info['taxonomies']['items'][0]['label'])->toBe('Rubriques'); + }); + it('collects hook counts by type', function (): void { $manager = Mockery::mock(DiscoveryManager::class); $manager->shouldReceive('getDiscoveredItems') @@ -340,6 +388,13 @@ function createCollector( 'template' => 'Unknown', ]); }); + + it('returns the active theme the environment gives', function (): void { + $theme = ['name' => 'Apiary', 'version' => '1.2.0', 'template' => 'apiary']; + $wordpress = Mockery::mock(WordPressEnvironmentInterface::class, ['activeTheme' => $theme]); + + expect(createCollector(wordpress: $wordpress)->collectThemeInfo())->toBe($theme); + }); }); describe('collectTranslationInfo', function (): void { @@ -364,11 +419,20 @@ function createCollector( }); it('reports the WordPress locale', function (): void { - \Brain\Monkey\Functions\when('get_locale')->justReturn('fr_FR'); + $wordpress = Mockery::mock(WordPressEnvironmentInterface::class, ['locale' => 'fr_FR']); - expect(createCollector()->collectTranslationInfo()['wordpress_locale'])->toBe('fr_FR'); + expect(createCollector(wordpress: $wordpress)->collectTranslationInfo()['wordpress_locale'])->toBe('fr_FR'); }); + it('reports an Unknown WordPress locale when there is none', function (?string $locale): void { + $wordpress = Mockery::mock(WordPressEnvironmentInterface::class, ['locale' => $locale]); + + expect(createCollector(wordpress: $wordpress)->collectTranslationInfo()['wordpress_locale'])->toBe('Unknown'); + })->with([ + 'not loaded' => [null], + 'empty' => [''], + ]); + it('reports the locale the bound translator declares', function (): void { $container = Mockery::mock(ContainerInterface::class); $container->shouldReceive('get')->with('translator')->andReturn( diff --git a/tests/Unit/Plugins/WooCommerce/Domain/Services/WooCommerceServiceTest.php b/tests/Unit/Plugins/WooCommerce/Domain/Services/WooCommerceServiceTest.php index ecf4146d..bdf71a68 100644 --- a/tests/Unit/Plugins/WooCommerce/Domain/Services/WooCommerceServiceTest.php +++ b/tests/Unit/Plugins/WooCommerce/Domain/Services/WooCommerceServiceTest.php @@ -2,8 +2,24 @@ declare(strict_types=1); +use Pollora\ThirdParty\WooCommerce\Domain\Contracts\WooCommerceEnvironmentInterface; use Pollora\ThirdParty\WooCommerce\Domain\Models\Template; use Pollora\ThirdParty\WooCommerce\Domain\Services\WooCommerceService; +use Pollora\ThirdParty\WooCommerce\Domain\Support\DefaultWooCommerceEnvironment; + +/** + * An environment double: a child theme of /themes/parent or not, and the + * override folder WooCommerce declares. + */ +function wooCommerceEnvironment(bool $childTheme, string $templatePath = 'woocommerce/'): WooCommerceEnvironmentInterface +{ + $environment = Mockery::mock(WooCommerceEnvironmentInterface::class); + $environment->shouldReceive('isChildTheme')->andReturn($childTheme); + $environment->shouldReceive('getTemplateDirectory')->andReturn('/themes/parent'); + $environment->shouldReceive('getWooCommerceTemplatePath')->andReturn($templatePath); + + return $environment; +} describe('WooCommerceService', function (): void { beforeEach(function (): void { @@ -32,26 +48,39 @@ }); test('can get theme template paths for child themes', function (): void { - Brain\Monkey\Functions\when('is_child_theme')->justReturn(true); - Brain\Monkey\Functions\when('get_template_directory')->justReturn('/themes/parent'); + $service = new WooCommerceService(wooCommerceEnvironment(true)); - $mockWC = Mockery::mock(); - $mockWC->shouldReceive('template_path')->andReturn('woocommerce/'); - Brain\Monkey\Functions\when('WC')->justReturn($mockWC); - - $paths = $this->service->getThemeTemplatePaths(); + $paths = $service->getThemeTemplatePaths(); expect($paths)->toContain('/themes/parent/woocommerce/'); }); + test('uses the override folder WooCommerce declares for child themes', function (): void { + $service = new WooCommerceService(wooCommerceEnvironment(true, 'shop/')); + + expect($service->getThemeTemplatePaths())->toBe(['/themes/parent/shop/']); + }); + test('returns empty array for non-child themes', function (): void { - Brain\Monkey\Functions\when('is_child_theme')->justReturn(false); + $service = new WooCommerceService(wooCommerceEnvironment(false)); - $paths = $this->service->getThemeTemplatePaths(); + $paths = $service->getThemeTemplatePaths(); expect($paths)->toBe([]); }); + test('knows of no child theme without an environment', function (): void { + expect($this->service->getThemeTemplatePaths())->toBe([]); + }); + + test('has a default environment that knows nothing of the theme', function (): void { + $environment = new DefaultWooCommerceEnvironment; + + expect($environment->isChildTheme())->toBeFalse() + ->and($environment->getTemplateDirectory())->toBe('') + ->and($environment->getWooCommerceTemplatePath())->toBe('woocommerce/'); + }); + test('can detect woocommerce status screen', function (): void { $screen = new stdClass; $screen->id = 'woocommerce_page_wc-status'; @@ -88,19 +117,15 @@ expect($result)->toBeFalse(); }); - test('can get woocommerce template path with WC available', function (): void { - $mockWC = Mockery::mock(); - $mockWC->shouldReceive('template_path')->andReturn('woocommerce/'); - Brain\Monkey\Functions\when('WC')->justReturn($mockWC); + test('can get woocommerce template path from the environment', function (): void { + $service = new WooCommerceService(wooCommerceEnvironment(false, 'shop/')); - $path = $this->service->getWooCommerceTemplatePath(); + $path = $service->getWooCommerceTemplatePath(); - expect($path)->toBe('woocommerce/'); + expect($path)->toBe('shop/'); }); - test('returns default path when WC not available', function (): void { - Brain\Monkey\Functions\when('WC')->justReturn(); - + test('returns default path without an environment', function (): void { $path = $this->service->getWooCommerceTemplatePath(); expect($path)->toBe('woocommerce/'); @@ -111,14 +136,9 @@ define('WC_ABSPATH', '/plugin/woocommerce/'); } - Brain\Monkey\Functions\when('is_child_theme')->justReturn(true); - Brain\Monkey\Functions\when('get_template_directory')->justReturn('/themes/parent'); - - $mockWC = Mockery::mock(); - $mockWC->shouldReceive('template_path')->andReturn('woocommerce/'); - Brain\Monkey\Functions\when('WC')->justReturn($mockWC); + $service = new WooCommerceService(wooCommerceEnvironment(true)); - $paths = $this->service->getAllTemplatePaths(); + $paths = $service->getAllTemplatePaths(); expect($paths)->toContain('/plugin/woocommerce/templates/'); expect($paths)->toContain('/themes/parent/woocommerce/'); diff --git a/tests/Unit/Plugins/WooCommerce/Infrastructure/Providers/WooCommerceServiceProviderTest.php b/tests/Unit/Plugins/WooCommerce/Infrastructure/Providers/WooCommerceServiceProviderTest.php index 53ea7284..d337157e 100644 --- a/tests/Unit/Plugins/WooCommerce/Infrastructure/Providers/WooCommerceServiceProviderTest.php +++ b/tests/Unit/Plugins/WooCommerce/Infrastructure/Providers/WooCommerceServiceProviderTest.php @@ -9,6 +9,7 @@ use Pollora\Hook\Domain\Contract\Filter; use Pollora\ThirdParty\WooCommerce\Application\UseCases\RegisterWooCommerceHooksUseCase; use Pollora\ThirdParty\WooCommerce\Domain\Contracts\TemplateResolverInterface; +use Pollora\ThirdParty\WooCommerce\Domain\Contracts\WooCommerceEnvironmentInterface; use Pollora\ThirdParty\WooCommerce\Domain\Contracts\WooCommerceIntegrationInterface; use Pollora\ThirdParty\WooCommerce\Domain\Services\WooCommerceService; use Pollora\ThirdParty\WooCommerce\Infrastructure\Adapters\WordPressWooCommerceAdapter; @@ -40,6 +41,14 @@ expect($this->container->has(TemplateResolverInterface::class))->toBeTrue(); }); + test('binds the environment port to the WordPress adapter', function (): void { + $this->provider->register(); + + expect($this->container->get(WooCommerceEnvironmentInterface::class)) + ->toBeInstanceOf(WordPressWooCommerceAdapter::class) + ->toBe($this->container->get(WordPressWooCommerceAdapter::class)); + }); + test('can register application services', function (): void { $this->container->instance(Action::class, Mockery::mock(Action::class)); $this->container->instance(Filter::class, Mockery::mock(Filter::class)); diff --git a/tests/Unit/Taxonomy/AbstractTaxonomyLabelsTest.php b/tests/Unit/Taxonomy/AbstractTaxonomyLabelsTest.php new file mode 100644 index 00000000..26ddb81f --- /dev/null +++ b/tests/Unit/Taxonomy/AbstractTaxonomyLabelsTest.php @@ -0,0 +1,95 @@ +getLabels(); + + expect($labels)->toMatchArray([ + 'name' => 'Project categories', + 'singular_name' => 'Project category', + 'all_items' => 'All Project categories', + 'edit_item' => 'Edit Project category', + 'parent_item_colon' => 'Parent Project category:', + ]); + }); + + it('translates every label template in the pollora text domain', function (): void { + $asked = new ArrayObject; + AbstractTaxonomy::useLabelTranslator(new readonly class($asked) implements LabelTranslatorInterface + { + public function __construct(private ArrayObject $asked) {} + + public function translate(string $text, string $domain): string + { + $this->asked[] = $domain; + + return '['.$text.']'; + } + }); + + $labels = (new ProjectCategory)->getLabels(); + + expect($labels['all_items'])->toBe('[All Project categories]') + ->and($labels['search_items'])->toBe('[Search Project categories]') + ->and($labels['name'])->toBe('Project categories') + ->and($asked)->toHaveCount(14) + ->and(array_unique($asked->getArrayCopy()))->toBe(['pollora']); + }); + + it('puts the labels into the registration arguments', function (): void { + $taxonomy = new ProjectCategory; + $taxonomy->setArg('hierarchical', true); + + expect($taxonomy->getArgs())->toMatchArray(['hierarchical' => true]) + ->and($taxonomy->getArgs()['labels'])->toBe($taxonomy->getLabels()); + }); +}); + +describe('WordPressLabelTranslator', function (): void { + it('implements the domain port', function (): void { + expect(new WordPressLabelTranslator)->toBeInstanceOf(LabelTranslatorInterface::class); + }); + + it('translates through __()', function (): void { + // The suite installs a passthrough translator behind __() + expect((new WordPressLabelTranslator)->translate('All %s', 'pollora'))->toBe('All %s'); + }); +}); + +describe('TaxonomyServiceProvider label translator', function (): void { + it('binds the port and hands it to AbstractTaxonomy', function (): void { + $app = new class extends Container + { + public function runningInConsole(): bool + { + return false; + } + }; + + (new TaxonomyServiceProvider($app))->register(); + + $translator = $app->make(LabelTranslatorInterface::class); + $property = new ReflectionProperty(AbstractTaxonomy::class, 'labelTranslator'); + + expect($translator)->toBeInstanceOf(WordPressLabelTranslator::class) + ->and($property->getValue())->toBe($translator); + }); +}); diff --git a/tests/Unit/Theme/ImageSizeTest.php b/tests/Unit/Theme/ImageSizeTest.php index 297dbed5..8c83f33f 100755 --- a/tests/Unit/Theme/ImageSizeTest.php +++ b/tests/Unit/Theme/ImageSizeTest.php @@ -5,7 +5,9 @@ use Mockery as m; use Pollora\Config\Domain\Contracts\ConfigRepositoryInterface; use Pollora\Hook\Domain\Contract\Action; +use Pollora\Theme\Domain\Contracts\ThemeFeatureRegistrarInterface; use Pollora\Theme\Domain\Models\ImageSize; +use Pollora\Theme\Domain\Support\ThemeConfig; use Psr\Container\ContainerInterface; require_once __DIR__.'/../helpers.php'; @@ -24,4 +26,26 @@ $imageSize = new ImageSize($mockContainer, $mockConfig); expect($imageSize)->toBeInstanceOf(ImageSize::class); }); + + it('registers every configured image size through the feature registrar', function (): void { + $config = m::mock(ConfigRepositoryInterface::class); + $config->shouldReceive('get')->with('theme.images', [])->andReturn([ + 'card' => [640, 480, true], + 'banner' => [1600], + ]); + ThemeConfig::setRepository($config); + + $features = m::mock(ThemeFeatureRegistrarInterface::class); + $features->shouldReceive('addImageSize')->once()->with('card', 640, 480, true); + // Missing dimensions and crop fall back to WordPress's own defaults + $features->shouldReceive('addImageSize')->once()->with('banner', 1600, 0, false); + + $container = m::mock(ContainerInterface::class); + $container->shouldReceive('get')->with(Action::class)->andReturn(m::mock(Action::class)); + $container->shouldReceive('get')->with(ThemeFeatureRegistrarInterface::class)->andReturn($features); + + (new ImageSize($container, $config))->addImageSize(); + + ThemeConfig::reset(); + }); }); diff --git a/tests/Unit/Theme/Infrastructure/Services/WordPressThemePortsTest.php b/tests/Unit/Theme/Infrastructure/Services/WordPressThemePortsTest.php new file mode 100644 index 00000000..15350adc --- /dev/null +++ b/tests/Unit/Theme/Infrastructure/Services/WordPressThemePortsTest.php @@ -0,0 +1,74 @@ +toBeInstanceOf(ThemeFeatureRegistrarInterface::class); + }); + + it('registers menu locations with register_nav_menus()', function (): void { + Functions\expect('register_nav_menus')->once()->with(['primary' => 'Primary menu']); + + (new WordPressThemeFeatureRegistrar)->registerNavMenus(['primary' => 'Primary menu']); + }); + + it('registers a sidebar with register_sidebar()', function (): void { + Functions\expect('register_sidebar')->once()->with(['id' => 'primary', 'name' => 'Primary']); + + (new WordPressThemeFeatureRegistrar)->registerSidebar(['id' => 'primary', 'name' => 'Primary']); + }); + + it('registers an image size with add_image_size()', function (): void { + Functions\expect('add_image_size')->once()->with('card', 640, 480, true); + + (new WordPressThemeFeatureRegistrar)->addImageSize('card', 640, 480, true); + }); +}); + +describe('WordPressContentUrlResolver', function (): void { + it('implements the domain port', function (): void { + expect(new WordPressContentUrlResolver)->toBeInstanceOf(ContentUrlResolverInterface::class); + }); + + it('answers with content_url()', function (): void { + Functions\expect('content_url')->once()->with('themes')->andReturn('https://example.test/content/themes'); + + expect((new WordPressContentUrlResolver)->contentUrl('themes'))->toBe('https://example.test/content/themes'); + }); +}); + +describe('ThemeAssetFileResolver', function (): void { + afterEach(function (): void { + AssetFile::resolveUsing(null); + }); + + it('implements the domain port', function (): void { + expect(new ThemeAssetFileResolver)->toBeInstanceOf(ThemeAssetUrlResolverInterface::class); + }); + + it('resolves the file through the theme asset container', function (): void { + $asked = []; + AssetFile::resolveUsing(function (string $file, string $container) use (&$asked): string { + $asked[] = [$file, $container]; + + return 'https://example.test/build/theme/default/'.$file; + }); + + expect((new ThemeAssetFileResolver)->assetUrl('app.js'))->toBe('https://example.test/build/theme/default/app.js') + ->and($asked)->toBe([['app.js', 'theme']]); + }); +}); diff --git a/tests/Unit/Theme/MenusTest.php b/tests/Unit/Theme/MenusTest.php index 82052801..026224b8 100755 --- a/tests/Unit/Theme/MenusTest.php +++ b/tests/Unit/Theme/MenusTest.php @@ -6,7 +6,9 @@ use Pollora\Config\Domain\Contracts\ConfigRepositoryInterface; use Pollora\Hook\Domain\Contract\Action; use Pollora\Hook\Domain\Contract\Filter; +use Pollora\Theme\Domain\Contracts\ThemeFeatureRegistrarInterface; use Pollora\Theme\Domain\Models\Menus; +use Pollora\Theme\Domain\Support\ThemeConfig; use Psr\Container\ContainerInterface; require_once __DIR__.'/../helpers.php'; @@ -36,4 +38,28 @@ expect($ref->getValue($component))->toBe($mockContainer); }); + + it('registers the configured menu locations through the feature registrar', function (): void { + $config = m::mock(ConfigRepositoryInterface::class); + $config->shouldReceive('get')->with('theme.menus', [])->andReturn([ + 'primary' => 'Primary menu', + 'footer' => 'Footer menu', + ]); + ThemeConfig::setRepository($config); + + $features = m::mock(ThemeFeatureRegistrarInterface::class); + $features->shouldReceive('registerNavMenus')->once()->with([ + 'primary' => 'Primary menu', + 'footer' => 'Footer menu', + ]); + + $container = m::mock(ContainerInterface::class); + $container->shouldReceive('get')->with(Action::class)->andReturn(m::mock(Action::class)); + $container->shouldReceive('get')->with(Filter::class)->andReturn(m::mock(Filter::class)); + $container->shouldReceive('get')->with(ThemeFeatureRegistrarInterface::class)->andReturn($features); + + (new Menus($container, $config))->registerMenus(); + + ThemeConfig::reset(); + }); }); diff --git a/tests/Unit/Theme/SidebarTest.php b/tests/Unit/Theme/SidebarTest.php index 36867785..bf2b77bc 100755 --- a/tests/Unit/Theme/SidebarTest.php +++ b/tests/Unit/Theme/SidebarTest.php @@ -5,7 +5,9 @@ use Mockery as m; use Pollora\Config\Domain\Contracts\ConfigRepositoryInterface; use Pollora\Hook\Domain\Contract\Action; +use Pollora\Theme\Domain\Contracts\ThemeFeatureRegistrarInterface; use Pollora\Theme\Domain\Models\Sidebar; +use Pollora\Theme\Domain\Support\ThemeConfig; use Psr\Container\ContainerInterface; require_once __DIR__.'/../helpers.php'; @@ -26,4 +28,29 @@ expect($ref->getValue($sidebar))->toBe($mockContainer); }); + + it('registers every configured sidebar through the feature registrar', function (): void { + $config = m::mock(ConfigRepositoryInterface::class); + $config->shouldReceive('get')->with('theme.sidebars', [])->andReturn([ + ['id' => 'primary', 'name' => 'Primary'], + ['id' => 'footer', 'name' => 'Footer'], + ]); + ThemeConfig::setRepository($config); + + $registered = []; + $features = m::mock(ThemeFeatureRegistrarInterface::class); + $features->shouldReceive('registerSidebar')->andReturnUsing(function (array $args) use (&$registered): void { + $registered[] = $args['id']; + }); + + $container = m::mock(ContainerInterface::class); + $container->shouldReceive('get')->with(Action::class)->andReturn(m::mock(Action::class)); + $container->shouldReceive('get')->with(ThemeFeatureRegistrarInterface::class)->andReturn($features); + + (new Sidebar($container, $config))->registerSidebars(); + + ThemeConfig::reset(); + + expect($registered)->toBe(['primary', 'footer']); + }); }); diff --git a/tests/Unit/Theme/ThemeInitializerTest.php b/tests/Unit/Theme/ThemeInitializerTest.php index aa5dd5b6..c037b027 100755 --- a/tests/Unit/Theme/ThemeInitializerTest.php +++ b/tests/Unit/Theme/ThemeInitializerTest.php @@ -9,6 +9,8 @@ use Pollora\Hook\Domain\Contract\Action; use Pollora\Hook\Domain\Contract\Filter; use Pollora\Theme\Domain\Contracts\ContainerInterface; +use Pollora\Theme\Domain\Contracts\ContentUrlResolverInterface; +use Pollora\Theme\Domain\Contracts\ThemeAssetUrlResolverInterface; use Pollora\Theme\Domain\Contracts\ThemeModuleInterface; use Pollora\Theme\Domain\Contracts\ThemeRegistrarInterface; use Pollora\Theme\Domain\Contracts\ThemeService; @@ -60,9 +62,11 @@ public function basePath($path = ''): string * hooks it registered so the test can fire them. * * @param string|null $themePath the active theme's own directory, or null for no active theme + * @param array $built the files the asset build knows, and the URL it gave each + * @param ThemeAssetUrlResolverInterface|null $assetUrl a resolver to use instead of one answering from $built * @return array{initializer: ThemeInitializer, actions: array, filters: array, directories: ArrayObject} */ -function initializerUnderTest(?string $themePath): array +function initializerUnderTest(?string $themePath, array $built = [], ?ThemeAssetUrlResolverInterface $assetUrl = null): array { $actions = []; $filters = []; @@ -114,7 +118,19 @@ function (string $path) use (&$directories): bool { $themeService = m::mock(ThemeService::class); $themeService->shouldReceive('load')->andReturnNull(); + $contentUrl = m::mock(ContentUrlResolverInterface::class); + $contentUrl->shouldReceive('contentUrl')->andReturnUsing( + fn (string $path = ''): string => 'https://example.test/content'.($path === '' ? '' : '/'.$path) + ); + + if (! $assetUrl instanceof ThemeAssetUrlResolverInterface) { + $assetUrl = m::mock(ThemeAssetUrlResolverInterface::class); + $assetUrl->shouldReceive('assetUrl')->andReturnUsing(fn (string $file): string => $built[$file] ?? ''); + } + $container = m::mock(ContainerInterface::class); + $container->shouldReceive('get')->with(ContentUrlResolverInterface::class)->andReturn($contentUrl); + $container->shouldReceive('get')->with(ThemeAssetUrlResolverInterface::class)->andReturn($assetUrl); $container->shouldReceive('get')->with(Action::class)->andReturn($action); $container->shouldReceive('get')->with(Filter::class)->andReturn($filter); $container->shouldReceive('get')->with(WordPressThemeInterface::class)->andReturn($wpTheme); @@ -214,6 +230,27 @@ function (string $path) use (&$directories): bool { ->toBe('https://example.test/themes/default/style.css'); }); + it('answers with the URL the build gave the file', function (): void { + $harness = initializerUnderTest('/srv/site/themes/default', [ + 'app.js' => 'https://example.test/build/theme/default/assets/app-4f2a.js', + ]); + $filter = $harness['filters']['theme_file_uri']; + + expect($filter('https://example.test/themes/default/app.js', 'app.js')) + ->toBe('https://example.test/build/theme/default/assets/app-4f2a.js'); + }); + + it('falls back to WordPress when the asset resolver throws', function (): void { + $resolver = m::mock(ThemeAssetUrlResolverInterface::class); + $resolver->shouldReceive('assetUrl')->andThrow(new RuntimeException('No logger bound')); + + $harness = initializerUnderTest('/srv/site/themes/default', assetUrl: $resolver); + $filter = $harness['filters']['theme_file_uri']; + + expect($filter('https://example.test/themes/default/app.js', 'app.js')) + ->toBe('https://example.test/themes/default/app.js'); + }); + it('does not try to resolve a file it was not given', function (mixed $file): void { $harness = initializerUnderTest('/srv/site/themes/default'); $filter = $harness['filters']['theme_file_uri']; @@ -227,10 +264,6 @@ function (string $path) use (&$directories): bool { ]); it('replaces a theme root that is a filesystem path', function (): void { - Brain\Monkey\Functions\when('content_url')->alias( - fn (string $path = ''): string => 'https://example.test/content'.($path === '' ? '' : '/'.$path) - ); - $harness = initializerUnderTest('/srv/site/themes/default'); // WordPress falls back to the path itself for a theme root it cannot