actionsForRouteNameSuffix('.assets'); // 모집단 가드 — 하나도 안 잡히면 아래 foreach 는 공허참이 된다. $this->assertNotEmpty( $actions, '확장 자산 서빙 라우트가 하나도 잡히지 않았습니다 — 이 테스트가 공허하게 통과하고 있습니다 ' .'(routes/api.php 의 Route::dualAsset(...)->name(\'api.public.*.assets\') 등록을 확인하세요).' ); // 지금 알려진 세 확장 유형이 실제로 모집단에 들어왔는지 — 이름 규약이 바뀌어 일부가 // 조용히 빠지는 것을 막는다. foreach (['templates', 'modules', 'plugins'] as $type) { $this->assertNotEmpty( array_filter(array_keys($actions), static fn (string $name): bool => str_contains($name, ".{$type}.")), "api.public.{$type}.assets 라우트가 모집단에 없습니다 — 이름 규약이 바뀌었거나 라우트가 사라졌습니다." ); } foreach ($actions as $routeName => $action) { $source = $this->methodSource($action[0], $action[1]); $this->assertStringContainsString( self::SERVING_ENTRYPOINT, $source, "{$routeName} ({$action[0]}::{$action[1]}) 이 ".self::SERVING_ENTRYPOINT.'() 를 거치지 않습니다. ' .'CSS 안의 상대 참조가 그대로 나가면 확장자 없는 URL 모드에서 글꼴·아이콘이 404 가 되는데, ' .'그 실패는 예외도 서버 로그 흔적도 남기지 않습니다.' ); } } /** * 자산 서빙 컨트롤러는 치환 트레이트를 실제로 보유해야 한다. * * 위 단언은 호출문만 본다 — 트레이트가 빠지면 호출문이 남아 있어도 치명적 오류가 되므로, * 보유 여부를 함께 고정한다. */ public function test_asset_serving_controllers_use_the_rewrite_trait(): void { $actions = $this->actionsForRouteNameSuffix('.assets'); $this->assertNotEmpty($actions, '확장 자산 서빙 라우트가 하나도 잡히지 않았습니다.'); foreach ($actions as $routeName => $action) { $this->assertContains( ServesRewritableCssAssets::class, array_values(class_uses_recursive($action[0])), "{$routeName} 의 컨트롤러 {$action[0]} 가 ServesRewritableCssAssets 트레이트를 쓰지 않습니다." ); } } /** * 병합 CSS 번들도 같은 치환 규칙을 거쳐야 한다. * * 병합본의 주소(`/api/{type}/bundle.css`, 정적 게시본)는 어느 확장의 dist 디렉토리도 * 아니므로 상대 해석이 반드시 어긋난다. 종전에는 상대 참조를 가진 CSS 를 번들에서 * **제외**했는데, 번들 URL 이 내려오면 프론트는 개별 로딩을 아예 타지 않으므로 * 제외 = 그 확장의 스타일이 하나도 적용되지 않음이었다. */ public function test_css_bundle_route_builds_through_the_rewriter(): void { $actions = $this->actionsForRouteNameSuffix('.bundle.css'); $this->assertNotEmpty( $actions, '병합 CSS 번들 라우트가 하나도 잡히지 않았습니다 — 이 테스트가 공허하게 통과하고 있습니다.' ); // 병합 루프는 mergeCss() 에 있다 — buildCssBundle()/buildBundleContent()/캐시 경로가 모두 이 루프를 거친다. $source = $this->methodSource(ExtensionBundleService::class, 'mergeCss'); $this->assertStringContainsString( self::REWRITER, $source, 'ExtensionBundleService::mergeCss() 이 '.self::REWRITER.' 를 거치지 않습니다. ' .'개별 자산 서빙과 병합 번들이 서로 다른 규칙을 쓰면 한쪽만 고쳐진 채 남습니다.' ); } /** * 라우트 이름 접미사로 컨트롤러 액션 모집단을 도출합니다. * * @param string $suffix 라우트 이름 접미사 (예: `.assets`) * @return array 라우트명 => [컨트롤러, 메서드] */ private function actionsForRouteNameSuffix(string $suffix): array { $actions = []; foreach (RouteFacade::getRoutes() as $route) { $name = $route->getName(); if (! is_string($name) || ! str_starts_with($name, 'api.public.') || ! str_ends_with($name, $suffix)) { continue; } $controller = $route->getAction('controller'); if (! is_string($controller) || ! str_contains($controller, '@')) { continue; } [$class, $method] = explode('@', $controller, 2); $actions[$name] = [$class, $method]; } return $actions; } /** * 메서드 본문 소스를 읽습니다. * * @param class-string $class 클래스 * @param string $method 메서드명 * @return string 메서드 소스 */ private function methodSource(string $class, string $method): string { $reflection = new ReflectionMethod($class, $method); $file = $reflection->getFileName(); $this->assertIsString($file, "{$class}::{$method} 의 소스 파일을 찾을 수 없습니다."); $lines = file($file); $this->assertIsArray($lines, "{$file} 을 읽을 수 없습니다."); return implode('', array_slice( $lines, $reflection->getStartLine() - 1, $reflection->getEndLine() - $reflection->getStartLine() + 1 )); } }