-fragment($id).php ← action * -fragment(none).phtml ← view (no-layout) * * This test scans JS files for `initDetailDrawer` calls, extracts the fragment * path from the fetchUrl template literal, and verifies both files exist. */ class DetailDrawerFragmentContractTest extends TestCase { use ProjectFileAssertionSupport; public function testEveryDrawerConsumerHasMatchingFragmentEndpoint(): void { $root = $this->projectRootPath(); $scanRoots = ['web/js', 'modules']; $consumers = []; foreach ($scanRoots as $scanRoot) { $basePath = $root . '/' . $scanRoot; if (!is_dir($basePath)) { continue; } $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($basePath, \FilesystemIterator::SKIP_DOTS)); /** @var \SplFileInfo $file */ foreach ($iterator as $file) { if (!$file->isFile() || $file->getExtension() !== 'js') { continue; } // Exclude the drawer implementation itself. $relativePath = str_replace($root . '/', '', $file->getPathname()); if ($relativePath === 'web/js/components/app-detail-drawer.js') { continue; } $content = file_get_contents($file->getPathname()); if ($content === false || !str_contains($content, 'initDetailDrawer(')) { continue; } // Find `fetchUrl: (uuid) => new URL(`/${uuid}`, ...)` — extract . // The path must contain "-fragment" per the convention. if (preg_match_all( '/fetchUrl\s*:\s*\([^)]*\)\s*=>\s*new\s+URL\s*\(\s*`([^`${]+)\$\{[^}]+\}[^`]*`/', $content, $matches )) { foreach ($matches[1] as $prefix) { $prefix = rtrim($prefix, '/'); $consumers[$prefix] = $relativePath; } } } } $this->assertNotEmpty( $consumers, 'No initDetailDrawer fetchUrl patterns found. Either the convention changed or the regex is stale.' ); $violations = []; foreach ($consumers as $fragmentPath => $consumerFile) { if (!str_contains($fragmentPath, '-fragment')) { $violations[] = sprintf( "Fragment path does not follow *-fragment convention: %s (in %s)", $fragmentPath, $consumerFile ); continue; } [$actionFound, $viewFound, $searchedDirs] = $this->locateFragmentFiles($fragmentPath); if (!$actionFound) { $violations[] = sprintf( "Missing action file for fragment path '%s'. Expected somewhere like:\n pages/%s(\$id).php\n modules/*/pages/%s(\$id).php\nSearched in: %s\n(Consumer: %s)", $fragmentPath, $fragmentPath, $fragmentPath, implode(', ', $searchedDirs), $consumerFile ); } if (!$viewFound) { $violations[] = sprintf( "Missing (none) view file for fragment path '%s'. Expected:\n %s(none).phtml\nSearched in: %s\n(Consumer: %s)", $fragmentPath, $fragmentPath, implode(', ', $searchedDirs), $consumerFile ); } } $this->assertSame( [], $violations, "Detail-drawer fragment-contract violations:\n" . implode("\n", $violations) ); } /** * @return array{0: bool, 1: bool, 2: list} [actionFound, viewFound, searchedDirs] */ private function locateFragmentFiles(string $fragmentPath): array { $root = $this->projectRootPath(); $searchBases = [$root . '/pages']; $modulesDir = $root . '/modules'; if (is_dir($modulesDir)) { foreach (scandir($modulesDir) ?: [] as $moduleEntry) { if ($moduleEntry === '.' || $moduleEntry === '..') { continue; } $modulePages = $modulesDir . '/' . $moduleEntry . '/pages'; if (is_dir($modulePages)) { $searchBases[] = $modulePages; } } } $actionFound = false; $viewFound = false; foreach ($searchBases as $base) { $actionCandidate = $base . '/' . $fragmentPath . '($id).php'; $viewCandidate = $base . '/' . $fragmentPath . '(none).phtml'; if (is_file($actionCandidate)) { $actionFound = true; } if (is_file($viewCandidate)) { $viewFound = true; } } return [$actionFound, $viewFound, $searchBases]; } }