fixturesDir = sys_get_temp_dir() . '/corecore-module-test-' . uniqid(); mkdir($this->fixturesDir, 0777, true); } protected function tearDown(): void { $this->removeDir($this->fixturesDir); } public function testBootWithNoModules(): void { $registry = ModuleRegistry::boot($this->fixturesDir, []); self::assertSame([], $registry->getModules()); self::assertSame([], $registry->getRoutes()); self::assertSame([], $registry->getPermissions()); } public function testBootWithNonExistentDir(): void { $registry = ModuleRegistry::boot(sys_get_temp_dir() . '/does-not-exist-' . uniqid(), ['foo']); self::assertSame([], $registry->getModules()); } public function testBootLoadsSingleModule(): void { $this->createModuleManifest('testmod', [ 'id' => 'testmod', 'version' => '1.0.0', 'load_order' => 10, 'permissions' => [ ['key' => 'test.view', 'description' => 'Test view'], ['key' => 'test.edit', 'description' => 'Test edit'], ], 'search_resources' => [], ]); $registry = ModuleRegistry::boot($this->fixturesDir, ['testmod']); self::assertTrue($registry->hasModule('testmod')); self::assertSame('1.0.0', $registry->getModule('testmod')->version); self::assertSame(['test.view', 'test.edit'], $registry->getPermissionKeys()); } public function testDeterministicOrdering(): void { $this->createModuleManifest('beta', ['id' => 'beta', 'load_order' => 20]); $this->createModuleManifest('alpha', ['id' => 'alpha', 'load_order' => 10]); $this->createModuleManifest('gamma', ['id' => 'gamma', 'load_order' => 10]); $registry = ModuleRegistry::boot($this->fixturesDir, ['beta', 'alpha', 'gamma']); $ids = array_keys($registry->getModules()); // load_order 10: alpha, gamma (alpha < gamma by id); then load_order 20: beta self::assertSame(['alpha', 'gamma', 'beta'], $ids); } public function testDuplicateModuleIdThrows(): void { $this->createModuleManifest('dup', ['id' => 'dup']); $this->expectException(RuntimeException::class); $this->expectExceptionMessage("Duplicate module id: 'dup'"); // Pass same ID twice — registry should fail-fast ModuleRegistry::boot($this->fixturesDir, ['dup', 'dup']); } public function testPermissionConflictThrows(): void { $this->createModuleManifest('mod-a', [ 'id' => 'mod-a', 'load_order' => 1, 'permissions' => [['key' => 'shared.perm', 'description' => 'Shared permission']], ]); $this->createModuleManifest('mod-b', [ 'id' => 'mod-b', 'load_order' => 2, 'permissions' => [['key' => 'shared.perm', 'description' => 'Shared permission duplicate']], ]); $this->expectException(RuntimeException::class); $this->expectExceptionMessage("Permission conflict: 'shared.perm'"); ModuleRegistry::boot($this->fixturesDir, ['mod-a', 'mod-b']); } public function testRouteConflictThrows(): void { $this->createModuleManifest('mod-a', [ 'id' => 'mod-a', 'load_order' => 1, 'routes' => [['path' => '/shared', 'target' => 'shared/index']], ]); $this->createModuleManifest('mod-b', [ 'id' => 'mod-b', 'load_order' => 2, 'routes' => [['path' => '/shared-alt', 'target' => 'shared/index']], ]); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Route target conflict'); ModuleRegistry::boot($this->fixturesDir, ['mod-a', 'mod-b']); } public function testRoutePathConflictThrows(): void { $this->createModuleManifest('mod-a', [ 'id' => 'mod-a', 'load_order' => 1, 'routes' => [['path' => '/shared-path', 'target' => 'a/index']], ]); $this->createModuleManifest('mod-b', [ 'id' => 'mod-b', 'load_order' => 2, 'routes' => [['path' => '/shared-path', 'target' => 'b/index']], ]); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Route path conflict'); ModuleRegistry::boot($this->fixturesDir, ['mod-a', 'mod-b']); } public function testUiSlotConflictThrows(): void { $panelTemplateA = $this->createFixtureFile('mod-a-panel.phtml', ''); $panelTemplateB = $this->createFixtureFile('mod-b-panel.phtml', ''); $this->createModuleManifest('mod-a', [ 'id' => 'mod-a', 'load_order' => 1, 'ui_slots' => [ 'aside.tab_panel' => [ [ 'key' => 'people', 'label' => 'People', 'icon' => 'bi-people', 'permission' => 'can_view_people', 'panel_template' => $panelTemplateA, ], ], ], ]); $this->createModuleManifest('mod-b', [ 'id' => 'mod-b', 'load_order' => 2, 'ui_slots' => [ 'aside.tab_panel' => [ [ 'key' => 'people', 'label' => 'People B', 'icon' => 'bi-people', 'permission' => 'can_view_people', 'panel_template' => $panelTemplateB, ], ], ], ]); $this->expectException(RuntimeException::class); $this->expectExceptionMessage("UI slot conflict: slot 'aside.tab_panel' key 'people'"); ModuleRegistry::boot($this->fixturesDir, ['mod-a', 'mod-b']); } public function testInvalidRouteContractThrows(): void { $this->createModuleManifest('mod-a', [ 'id' => 'mod-a', 'routes' => [['path' => '', 'target' => 'address-book/index']], ]); $this->expectException(RuntimeException::class); $this->expectExceptionMessage("must define non-empty 'path' and 'target'"); ModuleRegistry::boot($this->fixturesDir, ['mod-a']); } public function testInvalidUiSlotContractThrows(): void { $this->createModuleManifest('mod-a', [ 'id' => 'mod-a', 'ui_slots' => [ 'aside.tab_panel' => [ ['label' => 'Missing Key'], ], ], ]); $this->expectException(RuntimeException::class); $this->expectExceptionMessage("must define a non-empty 'key'"); ModuleRegistry::boot($this->fixturesDir, ['mod-a']); } public function testUiSlotsAreSortedDeterministicallyByOrderModuleAndKey(): void { $this->createModuleManifest('mod-z', [ 'id' => 'mod-z', 'load_order' => 1, 'ui_slots' => [ 'search.resource_item' => [ ['key' => 'z-second', 'label' => 'Z Second', 'base_url' => 'z-second', 'permission' => 'can.z', 'order' => 20], ['key' => 'z-first', 'label' => 'Z First', 'base_url' => 'z-first', 'permission' => 'can.z', 'order' => 20], ], ], ]); $this->createModuleManifest('mod-a', [ 'id' => 'mod-a', 'load_order' => 1, 'ui_slots' => [ 'search.resource_item' => [ ['key' => 'a-mid', 'label' => 'A Mid', 'base_url' => 'a-mid', 'permission' => 'can.a', 'order' => 20], ], ], ]); $this->createModuleManifest('mod-b', [ 'id' => 'mod-b', 'load_order' => 1, 'ui_slots' => [ 'search.resource_item' => [ ['key' => 'b-low', 'label' => 'B Low', 'base_url' => 'b-low', 'permission' => 'can.b', 'order' => 10], ], ], ]); $registry = ModuleRegistry::boot($this->fixturesDir, ['mod-z', 'mod-b', 'mod-a']); $slotItems = $registry->getSlotContributions('search.resource_item'); self::assertSame(['b-low', 'a-mid', 'z-first', 'z-second'], array_column($slotItems, 'key')); foreach ($slotItems as $slotItem) { self::assertArrayNotHasKey('__module_id', $slotItem); } } public function testMissingManifestFileThrows(): void { $this->expectException(RuntimeException::class); $this->expectExceptionMessage("manifest not found"); ModuleRegistry::boot($this->fixturesDir, ['nonexistent']); } public function testManifestIdMismatchThrows(): void { $this->createModuleManifest('wrongdir', ['id' => 'actualid']); $this->expectException(RuntimeException::class); $this->expectExceptionMessage("does not match manifest id 'actualid'"); ModuleRegistry::boot($this->fixturesDir, ['wrongdir']); } public function testResolveEnabledModulesFromConfigWhenEnvUnset(): void { putenv('APP_ENABLED_MODULES'); try { $result = ModuleRegistry::resolveEnabledModules([ 'enabled_modules' => ['mod-a', 'mod-b'], ]); self::assertSame(['mod-a', 'mod-b'], $result); } finally { putenv('APP_ENABLED_MODULES'); } } public function testResolveEnabledModulesFromEnvList(): void { putenv('APP_ENABLED_MODULES=env-a,env-b'); try { $result = ModuleRegistry::resolveEnabledModules([ 'enabled_modules' => ['config-a'], ]); self::assertSame(['env-a', 'env-b'], $result); } finally { putenv('APP_ENABLED_MODULES'); } } public function testResolveEnabledModulesFromEmptyEnvReturnsNoModules(): void { putenv('APP_ENABLED_MODULES='); try { $result = ModuleRegistry::resolveEnabledModules([ 'enabled_modules' => ['config-a', 'config-b'], ]); self::assertSame([], $result); } finally { putenv('APP_ENABLED_MODULES'); } } public function testResolveEnabledModulesDeduplicates(): void { putenv('APP_ENABLED_MODULES'); try { $result = ModuleRegistry::resolveEnabledModules([ 'enabled_modules' => ['mod-a', 'mod-a', 'mod-b'], ]); self::assertSame(['mod-a', 'mod-b'], $result); } finally { putenv('APP_ENABLED_MODULES'); } } public function testGetNonExistentModuleThrows(): void { $registry = ModuleRegistry::boot($this->fixturesDir, []); $this->expectException(RuntimeException::class); $this->expectExceptionMessage("Module 'nope' is not registered"); $registry->getModule('nope'); } public function testMergedContributions(): void { $this->createModuleManifest('mod-a', [ 'id' => 'mod-a', 'load_order' => 1, 'layout_context_providers' => ['ProviderA'], 'session_providers' => ['SessionA'], 'search_resources' => ['SearchA'], 'asset_groups' => ['group-a' => ['a.css']], ]); $this->createModuleManifest('mod-b', [ 'id' => 'mod-b', 'load_order' => 2, 'layout_context_providers' => ['ProviderB'], 'session_providers' => ['SessionB'], 'search_resources' => ['SearchB'], 'asset_groups' => ['group-b' => ['b.css']], ]); $registry = ModuleRegistry::boot($this->fixturesDir, ['mod-a', 'mod-b']); self::assertSame(['ProviderA', 'ProviderB'], $registry->getLayoutContextProviders()); self::assertSame(['SessionA', 'SessionB'], $registry->getSessionProviders()); self::assertSame(['SearchA', 'SearchB'], $registry->getSearchResources()); self::assertSame(['group-a' => ['a.css'], 'group-b' => ['b.css']], $registry->getAssetGroups()); } // ── Helpers ────────────────────────────────────────────────────── /** * @param array $manifest */ private function createModuleManifest(string $dirName, array $manifest): void { $dir = $this->fixturesDir . '/' . $dirName; if (!is_dir($dir)) { mkdir($dir, 0777, true); } file_put_contents( $dir . '/module.php', 'fixturesDir . '/' . ltrim($relativePath, '/'); $dir = dirname($path); if (!is_dir($dir)) { mkdir($dir, 0777, true); } file_put_contents($path, $content); return $path; } private function removeDir(string $dir): void { if (!is_dir($dir)) { return; } $items = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST ); foreach ($items as $item) { if ($item->isDir()) { rmdir($item->getPathname()); } else { unlink($item->getPathname()); } } rmdir($dir); } }