diff --git a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-base-transition-overlay.test.tsx b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-base-transition-overlay.test.tsx index 6d587d0d..f4e1730e 100644 --- a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-base-transition-overlay.test.tsx +++ b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-base-transition-overlay.test.tsx @@ -71,12 +71,42 @@ describe('어드민 베이스 transition_overlay (이슈 #245)', () => { }); describe('어드민 베이스 사이드바 프로필 영역 (이슈 #384)', () => { - it('좌측 사이드바 하단 프로필/링크 영역을 렌더링하지 않는다', () => { - const sidebarFooter = findById((adminBase as any).components, 'sidebar_footer'); + /** + * #384 가 막으려는 것은 "사이드바 하단 **프로필/계정 UI**" 이지 sidebar_footer + * 컨테이너 자체가 아니다. #450 이 같은 컨테이너에 "사이트 보기" 크로스 이동 링크를 + * 재도입했으므로, 컨테이너 id 부재로 단언하면 그 정상 기능에 걸린다. + * 의도대로 계정 UI 부재를 단언한다. + */ + it('좌측 사이드바에 프로필/계정 UI 를 렌더링하지 않는다', () => { const sidebarProfile = findById((adminBase as any).components, 'user_profile'); - - expect(sidebarFooter).toBeUndefined(); expect(sidebarProfile).toBeUndefined(); + + const sidebarFooter = findById((adminBase as any).components, 'sidebar_footer'); + if (sidebarFooter) { + // 컨테이너가 있어도 그 안에 계정/프로필 컴포넌트가 있으면 안 된다. + const names: string[] = []; + const walk = (node: any): void => { + if (!node || typeof node !== 'object') return; + if (Array.isArray(node)) return node.forEach(walk); + if (node.name) names.push(node.name); + Object.values(node).forEach(walk); + }; + walk(sidebarFooter); + + expect(names).not.toContain('UserProfile'); + expect(names).not.toContain('Avatar'); + } + }); + + /** + * #450 크로스 이동 링크는 사이드바 하단에 유지되어야 한다 (제거 회귀 차단). + */ + it('사이드바 하단 "사이트 보기" 크로스 이동 링크는 유지한다', () => { + const viewSiteLink = findById((adminBase as any).components, 'sidebar_view_site_link'); + + expect(viewSiteLink).toBeDefined(); + expect(viewSiteLink?.name).toBe('A'); + expect(viewSiteLink?.props).toMatchObject({ href: '/', target: '_blank' }); }); it('계정 기능은 상단 헤더 드롭다운으로 유지한다', () => { diff --git a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin_dashboard.alerts.test.tsx b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin_dashboard.alerts.test.tsx index 0c3b46c4..0c24155c 100644 --- a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin_dashboard.alerts.test.tsx +++ b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin_dashboard.alerts.test.tsx @@ -236,9 +236,11 @@ describe('어드민 대시보드 - 시스템 알림 (incompatible_core)', () => it('dismiss 버튼 액션이 POST /api/admin/extensions/{type}/{id}/dismiss 로 정의되어 있다', () => { // JSON 구조 검증 — alert_dismiss_button 의 actions[0].target + // iteration template 안의 노드는 HTML id 유일성을 위해 `{{$idx}}` 접미가 붙는다(#421). + // 정확 일치만 보면 보간 도입 시 조용히 null 이 되므로 접미 형태도 인정한다. function findById(node: any, id: string): any | null { if (!node || typeof node !== 'object') return null; - if (node.id === id) return node; + if (node.id === id || node.id === `${id}_{{$idx}}`) return node; const keys = ['children']; for (const k of keys) { const arr = node[k]; @@ -284,9 +286,11 @@ describe('어드민 대시보드 - 시스템 알림 (incompatible_core)', () => }); it('system_alerts_card 의 if 조건이 if:false 가 아닌 dashboard_alerts.length 기반이다 (회귀)', () => { + // iteration template 안의 노드는 HTML id 유일성을 위해 `{{$idx}}` 접미가 붙는다(#421). + // 정확 일치만 보면 보간 도입 시 조용히 null 이 되므로 접미 형태도 인정한다. function findById(node: any, id: string): any | null { if (!node || typeof node !== 'object') return null; - if (node.id === id) return node; + if (node.id === id || node.id === `${id}_{{$idx}}`) return node; const arr = node.children; if (Array.isArray(arr)) { for (const c of arr) { diff --git a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin_dashboard.recovery.test.tsx b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin_dashboard.recovery.test.tsx index 745bf8cf..b4def9f0 100644 --- a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin_dashboard.recovery.test.tsx +++ b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin_dashboard.recovery.test.tsx @@ -159,10 +159,14 @@ function setupDefaultMocks(testUtils: ReturnType) { }); } -// 레이아웃 트리에서 id 로 노드 찾기 +// 레이아웃 트리에서 id 로 노드 찾기. +// +// iteration template 안의 노드는 HTML id 유일성을 위해 `{{$idx}}` 접미가 붙는다(#421). +// 따라서 정확 일치뿐 아니라 `_{{$idx}}` 형태도 같은 노드로 인정한다. +// 정확 일치만 쓰면 iteration id 보간 도입 시 조용히 null 이 되어 테스트가 깨진다. function findById(node: any, id: string): any | null { if (!node || typeof node !== 'object') return null; - if (node.id === id) return node; + if (node.id === id || node.id === `${id}_{{$idx}}`) return node; if (Array.isArray(node.children)) { for (const c of node.children) { const r = findById(c, id); diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/admin_settings.json b/templates/_bundled/sirsoft-admin_basic/layouts/admin_settings.json index 98041494..fadb39d5 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/admin_settings.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/admin_settings.json @@ -26,7 +26,8 @@ "identityMessages", "adminIdentityPolicies", "policies", - "language_packs" + "language_packs", + "sitemap_status" ] }, "data_sources": [ diff --git a/tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts b/tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts index ab5f0da3..fee9300a 100644 --- a/tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts +++ b/tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts @@ -295,3 +295,36 @@ test('#481 - 재생성 후 실시간/폴링 모드에 맞게 진행상황이 갱 expect(statusGets).toBeLessThanOrEqual(2); } }); + +// @scenario tab=seo, transition_overlay=wait_for +// @effects sitemap_status_awaited_before_reveal +test('#481 - SEO 탭 콘텐츠 노출 시점에 sitemap 상태가 이미 채워져 있다', async ({ page }) => { + const token = issueToken('core.settings.read', 'core.settings.update'); + await authenticatePage(page, token); + + // 상태 API 를 의도적으로 지연시켜, 전환 오버레이가 이 데이터소스를 기다리는지 드러낸다. + // wait_for 에 sitemap_status 가 빠져 있으면 오버레이가 먼저 걷히고 빈 상태로 노출된다. + let statusResolvedAt = 0; + await page.route('**/api/admin/seo/sitemap/status*', async (route) => { + await new Promise((r) => setTimeout(r, 1_500)); + statusResolvedAt = Date.now(); + await route.continue(); + }); + + await page.goto('/admin/settings?tab=seo'); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + + // 진행상황 블록이 보이는 시점 = 오버레이가 걷힌 시점 + const statusBlock = page.locator('#sitemap_last_updated_block'); + await expect(statusBlock).toBeVisible({ timeout: 30_000 }); + const revealedAt = Date.now(); + + // 상대 비교: 노출 시점이 상태 응답 이후여야 한다 (절대 임계값 사용 안 함) + expect(statusResolvedAt).toBeGreaterThan(0); + expect(revealedAt).toBeGreaterThanOrEqual(statusResolvedAt); + + // 노출된 내용이 빈 껍데기가 아니어야 한다 (미해석 바인딩/빈 문자열 회귀 가드) + const text = (await statusBlock.innerText()).trim(); + expect(text).not.toContain('$t:'); + expect(text.length).toBeGreaterThan(0); +});