pluginDir().'/custom'); } protected function tearDown(): void { File::deleteDirectory($this->pluginDir()); CustomAssets::flushCache(); parent::tearDown(); } private function writeCustom(string $relative, string $contents = '/* x */'): void { $path = $this->pluginDir().'/custom/'.$relative; File::ensureDirectoryExists(dirname($path)); File::put($path, $contents); } /** * 하위 디렉토리의 글꼴·이미지까지 열거한다 — 게시와 같은 범위. * * @scenario custom_source=convention_scan, custom_asset=static_file, custom_change_detection=nested_static_file * * @effects custom_signature_covers_publishable_tree */ #[Test] public function 하위_디렉토리의_정적_파일까지_열거한다(): void { $this->writeCustom('custom.css'); $this->writeCustom('fonts/x.woff2', 'font'); $this->writeCustom('img/deep/marker.svg', ''); $relatives = array_column(CustomAssets::publishableFiles('plugins', self::FAKE_PLUGIN), 'relative'); $this->assertSame(['custom.css', 'fonts/x.woff2', 'img/deep/marker.svg'], $relatives); } /** * 허용 확장자 밖(텍스트·PHP)과 소스맵은 뺀다 — 게시 화이트리스트와 동일. * * @scenario custom_source=convention_scan, custom_asset=static_file, custom_change_detection=nested_static_file * * @effects custom_signature_covers_publishable_tree */ #[Test] public function 허용_확장자_밖과_소스맵은_열거하지_않는다(): void { $this->writeCustom('custom.css'); $this->writeCustom('notes.txt', 'not an asset'); $this->writeCustom('evil.php', 'writeCustom('custom.css.map', '{}'); $relatives = array_column(CustomAssets::publishableFiles('plugins', self::FAKE_PLUGIN), 'relative'); $this->assertSame(['custom.css'], $relatives); } /** * 항목마다 절대 경로·수정 시각·크기를 싣는다 — 서명 재료이자 게시 복사 원본이다. * * @scenario custom_source=convention_scan, custom_asset=css, custom_change_detection=top_level_asset * * @effects custom_signature_covers_publishable_tree */ #[Test] public function 항목은_절대경로_mtime_크기를_갖는다(): void { $this->writeCustom('custom.css', '/* twelve b */'); $files = CustomAssets::publishableFiles('plugins', self::FAKE_PLUGIN); $this->assertCount(1, $files); $this->assertSame('custom.css', $files[0]['relative']); $this->assertFileExists($files[0]['absolute']); $this->assertSame(14, $files[0]['size']); $this->assertGreaterThan(0, $files[0]['mtime']); } /** * 요청 스코프 메모이즈 — 같은 요청에서 두 번 물어도 디스크를 다시 훑지 않고, `flushCache()` 가 비운다. * * @scenario custom_source=convention_scan, custom_asset=css, custom_change_detection=no_change * * @effects custom_signature_covers_publishable_tree */ #[Test] public function 결과는_요청_스코프로_메모이즈되고_flush_로_비워진다(): void { $this->writeCustom('custom.css'); $first = CustomAssets::publishableFiles('plugins', self::FAKE_PLUGIN); // 메모가 살아 있는 동안 추가한 파일은 보이지 않는다 $this->writeCustom('fonts/x.woff2', 'font'); $this->assertSame($first, CustomAssets::publishableFiles('plugins', self::FAKE_PLUGIN)); CustomAssets::flushCache(); $this->assertCount(2, CustomAssets::publishableFiles('plugins', self::FAKE_PLUGIN)); } /** * 디렉토리가 없거나 타입이 유효하지 않으면 빈 목록이다. * * @scenario custom_source=none, custom_asset=css, custom_change_detection=no_change * * @effects custom_signature_covers_publishable_tree */ #[Test] public function 디렉토리_부재와_무효_타입은_빈_목록이다(): void { File::deleteDirectory($this->pluginDir()); $this->assertSame([], CustomAssets::publishableFiles('plugins', self::FAKE_PLUGIN)); $this->assertSame([], CustomAssets::publishableFiles('unknown', self::FAKE_PLUGIN)); $this->assertSame([], CustomAssets::publishableFiles('plugins', '../escape')); } /** * 로드 목록(`forExtension`)은 그대로 최상위 css/js 만 싣는다 — 열거자 도입이 로드 의미를 바꾸지 않는다. * * @scenario custom_source=convention_scan, custom_asset=static_file, custom_change_detection=nested_static_file * * @effects custom_signature_covers_publishable_tree */ #[Test] public function 로드_목록은_여전히_최상위_css_js_만_싣는다(): void { $this->writeCustom('custom.css'); $this->writeCustom('fonts/inner.css'); $this->writeCustom('img/marker.svg', ''); $loaded = CustomAssets::forExtension('plugins', self::FAKE_PLUGIN); $publishable = CustomAssets::publishableFiles('plugins', self::FAKE_PLUGIN); $this->assertCount(1, $loaded); $this->assertStringContainsString('custom.css', $loaded[0]['url']); $this->assertCount(3, $publishable); } }