From 57b55f2ef6d9fcc62a0ba4807361f67c825df1dc Mon Sep 17 00:00:00 2001 From: dev-wooyeon Date: Thu, 23 Apr 2026 17:23:16 +0900 Subject: [PATCH] =?UTF-8?q?refactor(convention):=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 현재 AppShell 기반 구조에서 더 이상 렌더되지 않는\n예전 헤더, 홈 섹션, 로고, 테스트 유틸을 제거했어요.\n\n모바일 드로어 구조에 맞게 잔존 테스트도 현재 동작 기준으로 정리했어요. --- .gitignore | 3 +- .../home/ui/sections/HeroSection.test.tsx | 222 -------- src/features/home/ui/sections/HeroSection.tsx | 504 ------------------ .../home/ui/sections/RecentPostsSection.tsx | 90 ---- .../home/ui/sections/ResumePreviewSection.tsx | 136 ----- src/shared/layout/AppShell/AppShell.tsx | 2 +- src/shared/layout/Header/Header.test.tsx | 146 ----- src/shared/layout/Header/Header.tsx | 143 ----- src/shared/layout/Header/index.ts | 1 - .../Header/useScrollVisibility.test.tsx | 176 ------ .../layout/Header/useScrollVisibility.ts | 132 ----- src/shared/layout/index.ts | 1 - .../motion/ui/MotionModeToggle.test.tsx | 67 --- src/shared/motion/ui/MotionModeToggle.tsx | 63 --- src/shared/testing/route-mocks.ts | 28 - src/shared/testing/test-utils.tsx | 7 - src/shared/ui/Logo.test.tsx | 30 -- src/shared/ui/Logo.tsx | 18 - src/shared/ui/index.ts | 2 - src/styles/globals.test.ts | 7 +- tests/e2e/layout/safe-area.spec.ts | 62 +-- tests/e2e/regression/theme-regression.spec.ts | 36 +- .../session-storage-fallback.spec.ts | 4 +- tests/e2e/smoke/home-renewal.smoke.spec.ts | 72 +-- tests/e2e/smoke/mobile-nav.smoke.spec.ts | 101 ++-- tests/e2e/smoke/navigation-ia.smoke.spec.ts | 75 ++- 26 files changed, 149 insertions(+), 1979 deletions(-) delete mode 100644 src/features/home/ui/sections/HeroSection.test.tsx delete mode 100644 src/features/home/ui/sections/HeroSection.tsx delete mode 100644 src/features/home/ui/sections/RecentPostsSection.tsx delete mode 100644 src/features/home/ui/sections/ResumePreviewSection.tsx delete mode 100644 src/shared/layout/Header/Header.test.tsx delete mode 100644 src/shared/layout/Header/Header.tsx delete mode 100644 src/shared/layout/Header/index.ts delete mode 100644 src/shared/layout/Header/useScrollVisibility.test.tsx delete mode 100644 src/shared/layout/Header/useScrollVisibility.ts delete mode 100644 src/shared/motion/ui/MotionModeToggle.test.tsx delete mode 100644 src/shared/motion/ui/MotionModeToggle.tsx delete mode 100644 src/shared/testing/route-mocks.ts delete mode 100644 src/shared/testing/test-utils.tsx delete mode 100644 src/shared/ui/Logo.test.tsx delete mode 100644 src/shared/ui/Logo.tsx diff --git a/.gitignore b/.gitignore index 1a770062..c1219681 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ # misc .DS_Store *.pem +/output/ # debug npm-debug.log* @@ -41,4 +42,4 @@ next-env.d.ts .sisyphus/ .claude/ /.agentation/ -.idea \ No newline at end of file +.idea diff --git a/src/features/home/ui/sections/HeroSection.test.tsx b/src/features/home/ui/sections/HeroSection.test.tsx deleted file mode 100644 index 049e6c07..00000000 --- a/src/features/home/ui/sections/HeroSection.test.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import { fireEvent, render, screen, within } from '@testing-library/react'; -import { createElement, type ImgHTMLAttributes } from 'react'; -import { describe, expect, it, vi } from 'vitest'; -import type { FeedData } from '@/domains/post/model/types'; -import type { SeriesSummary } from '@/features/blog/model/series-group'; -import HeroSection from './HeroSection'; - -vi.mock('next/image', () => ({ - default: ({ - alt, - fill: _fill, - priority: _priority, - ...props - }: ImgHTMLAttributes & { - fill?: boolean; - priority?: boolean; - }) => createElement('img', { ...props, alt: alt ?? '' }), -})); - -vi.mock('@/shared/analytics/lib/analytics', () => ({ - trackEvent: vi.fn(), -})); - -function createArticle(index: number): FeedData { - return { - slug: `article-${index}`, - title: `아티클 ${index}`, - description: `본문 ${index}`, - date: `2026-03-${String(index).padStart(2, '0')}`, - category: index % 2 === 0 ? 'Tech' : 'Life', - }; -} - -const articlePosts: FeedData[] = Array.from({ length: 6 }, (_, idx) => - createArticle(idx + 1) -); - -const seriesPost: FeedData = { - slug: 'series-episode-1', - title: '시리즈 1화', - description: '시리즈 설명', - date: '2026-03-01', - category: 'Tech', - series: { - id: 'redis-deep-dive', - title: 'Redis 완전정복', - order: 1, - }, -}; - -const seriesSummaries: SeriesSummary[] = [ - { - id: 'redis-deep-dive', - title: 'Redis 완전정복', - posts: [seriesPost], - latestDate: '2026-03-01', - firstPostSlug: 'series-episode-1', - postCount: 1, - totalReadingMinutes: 7, - }, -]; - -const popularPosts = [ - { post: articlePosts[0], viewCount: 1200 }, - { post: articlePosts[1], viewCount: 950 }, -]; - -describe('HeroSection', () => { - it('shows five non-series articles per page', () => { - render( - - ); - - const articleSection = document.getElementById('home-article-list'); - expect(articleSection).toBeTruthy(); - - const articleScope = within(articleSection as HTMLElement); - expect(articleScope.getByText('아티클 1')).toBeInTheDocument(); - expect(articleScope.getByText('아티클 5')).toBeInTheDocument(); - expect(articleScope.queryByText('2026년 3월 1일')).not.toBeInTheDocument(); - expect(articleScope.queryByText('아티클 6')).not.toBeInTheDocument(); - expect(articleScope.queryByText('시리즈 1화')).not.toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: '2' })); - - expect(articleScope.getByText('아티클 6')).toBeInTheDocument(); - }); - - it('renders popular posts with rank emoji and hides date metadata', () => { - render( - - ); - - const popularHeading = screen.getByRole('heading', { name: '인기 글' }); - const popularSection = popularHeading.closest('section'); - - expect(popularHeading).toBeInTheDocument(); - expect(popularSection).toBeTruthy(); - - const popularScope = within(popularSection as HTMLElement); - expect(popularScope.getByText('아티클 1')).toBeInTheDocument(); - expect(popularScope.getByText('🥇')).toBeInTheDocument(); - expect(popularScope.queryByText('Life')).not.toBeInTheDocument(); - expect(popularScope.queryByText('Tech')).not.toBeInTheDocument(); - expect(popularScope.queryByText('2026년 3월 1일')).not.toBeInTheDocument(); - expect(popularScope.queryByText('1,200')).not.toBeInTheDocument(); - }); - - it('renders hero CTA labels with matched Korean length', () => { - render( - - ); - - expect(screen.getByRole('link', { name: '아티클 보기' })).toHaveAttribute( - 'href', - '/engineering' - ); - expect(screen.getByText('1개 글')).toBeInTheDocument(); - expect(screen.getByRole('link', { name: '이력서 보기' })).toHaveAttribute( - 'href', - '/resume' - ); - expect( - screen.queryByRole('link', { name: 'Engineering' }) - ).not.toBeInTheDocument(); - expect( - screen.queryByRole('link', { name: 'Resume' }) - ).not.toBeInTheDocument(); - }); - - it('separates CTA emoji from label text and keeps sans font on labels', () => { - render( - - ); - - const engineeringCta = screen.getByRole('link', { name: '아티클 보기' }); - const resumeCta = screen.getByRole('link', { name: '이력서 보기' }); - - expect(within(engineeringCta).getByText('📝')).toHaveClass('tossface'); - expect(within(resumeCta).getByText('👨‍💻')).toHaveClass('tossface'); - expect(within(engineeringCta).getByText('아티클 보기')).toHaveStyle({ - fontFamily: 'var(--font-sans)', - }); - expect(within(resumeCta).getByText('이력서 보기')).toHaveStyle({ - fontFamily: 'var(--font-sans)', - }); - }); - - it('renders scroll indicator with tiny label and wheel drag motion', () => { - render( - - ); - - const scrollButton = screen.getByRole('button', { - name: '전체 아티클 보기', - }); - const scrollLabel = within(scrollButton).getByText('Scroll down'); - - expect(scrollLabel).toHaveStyle({ fontSize: '10px' }); - expect(scrollButton.querySelector('.animate-bounce')).toBeNull(); - expect( - scrollButton.querySelector('.animate-scroll-wheel-drag') - ).toBeInTheDocument(); - }); - - it('shows fallback text when popular list is empty', () => { - render( - - ); - - expect(screen.getByText('인기 글을 집계하고 있어요.')).toBeInTheDocument(); - }); - - it('keeps mobile section order as article -> popular -> series', () => { - render( - - ); - - const articleHeading = screen.getByRole('heading', { name: '전체 아티클' }); - const popularHeading = screen.getByRole('heading', { name: '인기 글' }); - const seriesHeading = screen.getByRole('heading', { - name: '아티클 시리즈', - }); - - expect( - articleHeading.compareDocumentPosition(popularHeading) & - Node.DOCUMENT_POSITION_FOLLOWING - ).toBeTruthy(); - expect( - popularHeading.compareDocumentPosition(seriesHeading) & - Node.DOCUMENT_POSITION_FOLLOWING - ).toBeTruthy(); - }); -}); diff --git a/src/features/home/ui/sections/HeroSection.tsx b/src/features/home/ui/sections/HeroSection.tsx deleted file mode 100644 index 5f7e1fcf..00000000 --- a/src/features/home/ui/sections/HeroSection.tsx +++ /dev/null @@ -1,504 +0,0 @@ -'use client'; - -import Image from 'next/image'; -import Link from 'next/link'; -import { useEffect, useMemo, useRef, useState } from 'react'; -import { - motion, - useReducedMotion, - useScroll, - useSpring, - useTransform, -} from 'framer-motion'; -import type { FeedData } from '@/domains/post/model/types'; -import type { SeriesSummary } from '@/features/blog/model/series-group'; -import { Container } from '@/shared/layout'; -import { trackEvent } from '@/shared/analytics/lib/analytics'; -import { Button } from '@/shared/ui'; - -interface HeroSectionProps { - allArticles: FeedData[]; - seriesSummaries: SeriesSummary[]; - popularPosts: Array<{ - post: FeedData; - viewCount: number | null; - }>; -} - -const ARTICLE_PAGE_SIZE = 5; -const HERO_ACTION_BUTTON_CLASS = - '!h-11 !bg-[rgba(0,12,30,0.8)] !backdrop-blur-md !border !border-white/10 !text-white transition-all shadow-lg hover:!bg-[rgba(0,12,30,1)] hover:shadow-xl hover:-translate-y-0.5'; -const HERO_ACTION_CONTENT_CLASS = 'inline-flex items-center gap-2'; -const PANEL_SPRING = { - stiffness: 110, - damping: 24, - mass: 0.28, -}; - -function useSoftRevealMotion( - targetRef: React.RefObject, - prefersReducedMotion: boolean -) { - const { scrollYProgress } = useScroll({ - target: targetRef, - offset: ['start 92%', 'start 58%'], - }); - const smoothProgress = useSpring(scrollYProgress, PANEL_SPRING); - - const opacity = useTransform(smoothProgress, [0, 0.45, 1], [0.08, 0.5, 1]); - const y = useTransform(smoothProgress, [0, 1], [24, 0]); - const scale = useTransform(smoothProgress, [0, 1], [0.985, 1]); - - if (prefersReducedMotion) { - return undefined; - } - - return { - opacity, - y, - scale, - }; -} - -function CategoryPill({ post }: { post: FeedData }) { - const toneClass = - post.category === 'Tech' - ? 'bg-[var(--color-toss-blue)]/10 text-[var(--color-toss-blue)]' - : 'bg-[var(--color-grey-100)] text-[var(--color-grey-700)]'; - - return ( - - {post.category} - - ); -} - -function ArticleThumbnail({ post }: { post: FeedData }) { - return ( - - ); -} - -function PopularPostList({ - popularPosts, -}: { - popularPosts: HeroSectionProps['popularPosts']; -}) { - const rankEmojis = ['🥇', '🥈', '🥉', '4️⃣', '5️⃣']; - - if (popularPosts.length === 0) { - return ( -
- 인기 글을 집계하고 있어요. -
- ); - } - - return ( -
    - {popularPosts.map(({ post }, index) => ( -
  1. - - trackEvent('cta_click', { - cta_name: 'home_popular_post', - cta_location: 'home_popular', - destination: `/blog/${post.slug}`, - rank: index + 1, - }) - } - className="group flex h-24 items-center gap-3 overflow-hidden rounded-lg border border-[var(--color-grey-100)] bg-[var(--color-bg-primary)] px-4 py-3 transition-all hover:border-[var(--color-grey-300)] hover:bg-[var(--color-grey-50)] hover:shadow-sm" - > - -
    -

    - {post.title} -

    -
    - -
  2. - ))} -
- ); -} - -function SeriesList({ seriesSummaries }: { seriesSummaries: SeriesSummary[] }) { - if (seriesSummaries.length === 0) { - return ( -
- 연재 글이 준비되면 바로 보여드려요. -
- ); - } - - return ( -
- {seriesSummaries.map((summary) => { - return ( - - trackEvent('cta_click', { - cta_name: 'home_series_hub', - cta_location: 'home_series', - destination: `/engineering/series/${summary.id}`, - }) - } - className="group flex h-24 items-center justify-between gap-3 overflow-hidden rounded-lg border border-[var(--color-grey-100)] bg-[var(--color-bg-primary)] px-4 py-3 transition-all hover:border-[var(--color-grey-300)] hover:bg-[var(--color-grey-50)] hover:shadow-sm" - > -
-

- {summary.title} -

-
- - {summary.postCount}개 글 - - - ); - })} -
- ); -} - -function HeroActionIcon({ children }: { children: string }) { - return ( - - ); -} - -function HeroActionLabel({ children }: { children: string }) { - return ( - - {children} - - ); -} - -function HeroActionContent({ - icon, - label, -}: { - icon: string; - label: string; -}) { - return ( - - {icon} - {label} - - ); -} - -export default function HeroSection({ - allArticles, - seriesSummaries, - popularPosts, -}: HeroSectionProps) { - const [currentPage, setCurrentPage] = useState(1); - const prefersReducedMotion = useReducedMotion() ?? false; - const articlePanelRef = useRef(null); - const popularPanelRef = useRef(null); - const seriesPanelRef = useRef(null); - - const articlePosts = useMemo( - () => allArticles.filter((post) => !post.series), - [allArticles] - ); - - const totalPages = Math.max( - 1, - Math.ceil(articlePosts.length / ARTICLE_PAGE_SIZE) - ); - - const displaySeries = useMemo( - () => seriesSummaries.slice(0, 5), - [seriesSummaries] - ); - - useEffect(() => { - if (currentPage > totalPages) { - setCurrentPage(totalPages); - } - }, [currentPage, totalPages]); - - const pagedArticles = useMemo(() => { - const startIndex = (currentPage - 1) * ARTICLE_PAGE_SIZE; - return articlePosts.slice(startIndex, startIndex + ARTICLE_PAGE_SIZE); - }, [articlePosts, currentPage]); - - const pageNumbers = useMemo( - () => Array.from({ length: totalPages }, (_, index) => index + 1), - [totalPages] - ); - const articleMotion = useSoftRevealMotion(articlePanelRef, prefersReducedMotion); - const popularMotion = useSoftRevealMotion(popularPanelRef, prefersReducedMotion); - const seriesMotion = useSoftRevealMotion(seriesPanelRef, prefersReducedMotion); - - return ( -
- -
-

- 안녕하세요, 우연입니다 -

- -
-

- Make Creative, Data, Systems things. -
- Currently working as a Software Engineer{' '} - @9.81park. -

-
- -
- - -
-
- - -
- -
- -
- -
-

- 전체 아티클 -

-
- - 전체 보기 - -
-
- -
- {pagedArticles.map((post) => ( - - trackEvent('cta_click', { - cta_name: 'home_spotlight_post', - cta_location: 'home_article', - destination: `/blog/${post.slug}`, - }) - } - className="group flex h-36 items-center justify-between gap-4 rounded-lg border border-[var(--color-grey-100)] bg-[var(--color-bg-primary)] px-4 py-4 transition-all hover:border-[var(--color-grey-300)] hover:bg-[var(--color-grey-100)] hover:shadow-sm" - > -
-
- -
-

- {post.title} -

-

- {post.description} -

-
- - - ))} -
- - {totalPages > 1 ? ( -
- - {pageNumbers.map((page) => ( - - ))} - -
- ) : null} -
- - -
-
-
-
- ); -} diff --git a/src/features/home/ui/sections/RecentPostsSection.tsx b/src/features/home/ui/sections/RecentPostsSection.tsx deleted file mode 100644 index 6e08b002..00000000 --- a/src/features/home/ui/sections/RecentPostsSection.tsx +++ /dev/null @@ -1,90 +0,0 @@ -'use client'; - -import Link from 'next/link'; -import { motion } from 'framer-motion'; -import { Container } from '@/shared/layout'; -import { PostCard } from '@/features/blog/ui/components/PostCard'; // Fixed import path based on file structure -import { FeedData } from '@/domains/post/model/types'; -import { Button } from '@/shared/ui'; - -interface RecentPostsSectionProps { - posts: FeedData[]; -} - -export default function RecentPostsSection({ posts }: RecentPostsSectionProps) { - return ( -
- -
-
- - Recent Posts - -

- 최근 작성한 글 -

-

- 기술적 고민과 배운 점들을 기록합니다. -

-
- - 전체 글 보기 - - - - -
- - - {posts.map((post) => ( - - - - ))} - - -
- -
-
-
- ); -} diff --git a/src/features/home/ui/sections/ResumePreviewSection.tsx b/src/features/home/ui/sections/ResumePreviewSection.tsx deleted file mode 100644 index dc17181d..00000000 --- a/src/features/home/ui/sections/ResumePreviewSection.tsx +++ /dev/null @@ -1,136 +0,0 @@ -'use client'; - -import { useRef } from 'react'; -import Link from 'next/link'; -import { motion, useScroll, useTransform } from 'framer-motion'; -import { Container } from '@/shared/layout'; - -export default function ResumePreviewSection() { - const containerRef = useRef(null); - const { scrollYProgress } = useScroll({ - target: containerRef, - offset: ['start end', 'end start'], - }); - - const y = useTransform(scrollYProgress, [0, 1], [50, -50]); - - return ( -
- -
- - - About Me - -

- 저를 한마디로 표현하면 -
- - Problem Solver - {' '} - 입니다. -

-

- 사용자 가치 중심의 문제 해결에 몰두하며, 안정적이고 확장 가능한 - 시스템을 설계하고 구현합니다. 데이터 기반의 의사결정으로 난제를 - 극복하고, 동료와 함께 문제를 해결하며 성장하는 엔지니어입니다. -

- -
-

- Main Skills -

-
- {[ - 'Java', - 'Spring Boot', - 'MySQL', - 'AWS', - 'Kafka', - 'Flink', - 'ClickHouse', - 'JPA', - ].map((skill, index) => ( - - {skill} - - ))} -
-
-
- -
- {/* Timeline / Experience Card Preview */} - -

- 🏢 Experience -

- -
-
-
-

- Software Engineer -

-

- @9.81park (Monolith) -

-

- 2021.05 - Present -

-
    -
  • 테마파크 IoT 서버 시스템 주담당
  • -
  • 건강한 사내문화 주도 개선
  • -
-
-
- -
- - + View more experience - -
-
- - {/* Decoration */} - -
-
-
-
- ); -} diff --git a/src/shared/layout/AppShell/AppShell.tsx b/src/shared/layout/AppShell/AppShell.tsx index d304fdc5..6be430cf 100644 --- a/src/shared/layout/AppShell/AppShell.tsx +++ b/src/shared/layout/AppShell/AppShell.tsx @@ -327,7 +327,7 @@ export default function AppShell({ children, posts }: AppShellProps) { -
+
{children}
diff --git a/src/shared/layout/Header/Header.test.tsx b/src/shared/layout/Header/Header.test.tsx deleted file mode 100644 index c0df0598..00000000 --- a/src/shared/layout/Header/Header.test.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { act, render, screen } from '@testing-library/react'; -import { createElement, type ReactNode } from 'react'; -import { describe, expect, it, vi } from 'vitest'; -import { pathnameState, setMockPathname } from '@/shared/testing/route-mocks'; -import Header from './Header'; -import { useScrollVisibility } from './useScrollVisibility'; - -const mockToggle = vi.fn(); - -const mockUseScrollVisibility = vi.mocked(useScrollVisibility); - -vi.mock('next/link', () => ({ - default: ({ - href, - children, - ...props - }: { - href: string; - children: ReactNode; - }) => ( - - {children} - - ), -})); - -vi.mock('kbar', () => ({ - useKBar: () => ({ - query: { - toggle: mockToggle, - }, - }), -})); - -vi.mock('./useScrollVisibility', () => ({ - useScrollVisibility: vi.fn(), -})); - -vi.mock('./MobileBottomNav', () => ({ - default: ({ pathname, visible }: { pathname: string; visible: boolean }) => ( -
- ), -})); - -vi.mock('@/shared/ui/ThemeToggle', () => ({ - default: () => , -})); - -vi.mock('@/shared/ui/Logo', () => ({ - default: () =>
Logo
, -})); - -vi.mock('framer-motion', () => ({ - motion: { - header: ({ - children, - ...props - }: { - children: ReactNode; - initial?: unknown; - animate?: unknown; - transition?: unknown; - [key: string]: unknown; - }) => { - const { - initial: _initial, - animate: _animate, - transition: _transition, - ...domProps - } = props; - return createElement('header', domProps, children); - }, - div: ({ - children, - ...props - }: { - children: ReactNode; - initial?: unknown; - animate?: unknown; - exit?: unknown; - transition?: unknown; - [key: string]: unknown; - }) => { - const { - initial: _initial, - animate: _animate, - exit: _exit, - transition: _transition, - ...domProps - } = props; - return createElement('div', domProps, children); - }, - }, -})); - -describe('Header', () => { - it('passes pathname and visibility state to MobileBottomNav', () => { - setMockPathname('/engineering'); - pathnameState.value = '/engineering'; - mockUseScrollVisibility.mockReturnValue({ - topHeaderVisible: true, - bottomBarVisible: false, - }); - - const { getByTestId } = render(
); - - const nav = getByTestId('mobile-bottom-nav'); - expect(nav.dataset.pathname).toBe('/engineering'); - expect(nav.dataset.visible).toBe('false'); - }); - - it('toggles pointer-events class when top header hides', () => { - setMockPathname('/engineering'); - pathnameState.value = '/engineering'; - mockUseScrollVisibility.mockReturnValue({ - topHeaderVisible: false, - bottomBarVisible: true, - }); - - const { container } = render(
); - const mobileHeader = container.querySelector('header.md\\:hidden'); - - expect(mobileHeader).toHaveClass('pointer-events-none'); - }); - - it('renders desktop search button and calls kbar toggle on click', async () => { - setMockPathname('/engineering'); - pathnameState.value = '/engineering'; - mockUseScrollVisibility.mockReturnValue({ - topHeaderVisible: true, - bottomBarVisible: true, - }); - - render(
); - - act(() => { - screen.getByRole('button', { name: '검색 열기' }).click(); - }); - - expect(mockToggle).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/shared/layout/Header/Header.tsx b/src/shared/layout/Header/Header.tsx deleted file mode 100644 index 7889e119..00000000 --- a/src/shared/layout/Header/Header.tsx +++ /dev/null @@ -1,143 +0,0 @@ -'use client'; - -import Link from 'next/link'; -import { usePathname } from 'next/navigation'; -import { motion } from 'framer-motion'; -import { useKBar } from 'kbar'; -import { clsx } from 'clsx'; -import Logo from '@/shared/ui/Logo'; -import ThemeToggle from '@/shared/ui/ThemeToggle'; -import { AnalyticsEvents, trackEvent } from '@/shared/analytics/lib/analytics'; -import MobileBottomNav from './MobileBottomNav'; -import { useScrollVisibility } from './useScrollVisibility'; - -const navItems = [ - { href: '/engineering', label: 'Engineering' }, - { href: '/life', label: 'Life' }, - { href: '/resume', label: 'Resume' }, -]; - -export default function Header() { - const pathname = usePathname(); - const { topHeaderVisible, bottomBarVisible } = useScrollVisibility(pathname); - - return ( - <> - - - -
- - trackEvent(AnalyticsEvents.click, { - target: 'mobile_top_logo', - destination: '/', - }) - } - > - - eunu.log - - -
- -
-
-
- - - - ); -} - -function DesktopHeader() { - return ( -
-
- - - eunu.log - - -
- - -
- - -
-
-
-
- ); -} - -function SearchButton() { - const { query } = useKBar(); - - return ( - - ); -} diff --git a/src/shared/layout/Header/index.ts b/src/shared/layout/Header/index.ts deleted file mode 100644 index 5653319d..00000000 --- a/src/shared/layout/Header/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as Header } from './Header'; diff --git a/src/shared/layout/Header/useScrollVisibility.test.tsx b/src/shared/layout/Header/useScrollVisibility.test.tsx deleted file mode 100644 index 9f9f8d78..00000000 --- a/src/shared/layout/Header/useScrollVisibility.test.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import { act, render } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; -import { useEffect, useState } from 'react'; -import { isBlogPostPath, useScrollVisibility } from './useScrollVisibility'; -import { - resetDomState, - setWindowScrollY, - setupMatchMediaMock, -} from '@/shared/testing/dom-mocks'; - -function HookProbe({ pathname }: { pathname: string }) { - const [state, setState] = useState({ top: true, bottom: true }); - - const visibility = useScrollVisibility(pathname); - - useEffect(() => { - setState({ - top: visibility.topHeaderVisible, - bottom: visibility.bottomBarVisible, - }); - }, [visibility.topHeaderVisible, visibility.bottomBarVisible]); - - return ( -
- {state.top ? 'T' : 'F'}-{state.bottom ? 'T' : 'F'} -
- ); -} - -describe('isBlogPostPath', () => { - it('returns true only for strict /blog/:slug format', () => { - expect(isBlogPostPath('/blog/hello-world')).toBe(true); - expect(isBlogPostPath('/blog')).toBe(false); - expect(isBlogPostPath('/')).toBe(false); - expect(isBlogPostPath('/blog/a/b')).toBe(false); - }); -}); - -describe('useScrollVisibility', () => { - it('shows bottom bar on home at top area (0~40px)', async () => { - resetDomState(); - setWindowScrollY(0); - const { findByTestId } = render(); - - const stateText = await findByTestId('visibility-state'); - - expect(stateText).toHaveTextContent('T-T'); - }); - - it('hides bottom bar on home after crossing reveal threshold', async () => { - resetDomState(); - setWindowScrollY(0); - const { findByTestId } = render(); - let stateText = await findByTestId('visibility-state'); - expect(stateText).toHaveTextContent('T-T'); - - act(() => { - setWindowScrollY(50); - window.dispatchEvent(new Event('scroll')); - }); - - stateText = await findByTestId('visibility-state'); - expect(stateText).toHaveTextContent('F-F'); - }); - - it('keeps top header visible within small top scroll area', async () => { - resetDomState(); - setWindowScrollY(5); - const { findByTestId } = render(); - - const stateText = await findByTestId('visibility-state'); - expect(stateText).toHaveTextContent('T-T'); - }); - - it('hides mobile bars on blog detail when scrolling down', async () => { - resetDomState(); - const { findByTestId } = render(); - let stateText = await findByTestId('visibility-state'); - expect(stateText).toHaveTextContent('T-T'); - - act(() => { - setWindowScrollY(80); - window.dispatchEvent(new Event('scroll')); - }); - - stateText = await findByTestId('visibility-state'); - expect(stateText).toHaveTextContent('F-F'); - }); - - it('shows bars again when scrolling upward on blog detail', async () => { - resetDomState(); - const { findByTestId } = render(); - let stateText = await findByTestId('visibility-state'); - expect(stateText).toHaveTextContent('T-T'); - - act(() => { - setWindowScrollY(120); - window.dispatchEvent(new Event('scroll')); - }); - stateText = await findByTestId('visibility-state'); - expect(stateText).toHaveTextContent('F-F'); - - act(() => { - setWindowScrollY(100); - window.dispatchEvent(new Event('scroll')); - }); - stateText = await findByTestId('visibility-state'); - expect(stateText).toHaveTextContent('T-T'); - }); - - it('does not animate on non-mobile screens', async () => { - resetDomState(); - setupMatchMediaMock(false); - - setWindowScrollY(200); - const { findByTestId } = render(); - const stateText = await findByTestId('visibility-state'); - - expect(stateText).toHaveTextContent('T-T'); - }); - - it('hides/reveals bottom bar at 40/41px boundary only on home', async () => { - resetDomState(); - setWindowScrollY(40); - const { findByTestId } = render(); - - const stateText = await findByTestId('visibility-state'); - expect(stateText).toHaveTextContent('T-T'); - - act(() => { - setWindowScrollY(41); - window.dispatchEvent(new Event('scroll')); - }); - - expect(await findByTestId('visibility-state')).toHaveTextContent('T-F'); - - act(() => { - setWindowScrollY(40); - window.dispatchEvent(new Event('scroll')); - }); - - expect(await findByTestId('visibility-state')).toHaveTextContent('T-T'); - }); - - it('syncs mobile bottom offset CSS variable with bottom visibility', async () => { - resetDomState(); - setWindowScrollY(0); - const { findByTestId } = render(); - let stateText = await findByTestId('visibility-state'); - expect(stateText).toHaveTextContent('T-T'); - - expect( - document.body.style.getPropertyValue('--mobile-bottom-nav-offset') - ).toBe('var(--mobile-bottom-nav-height)'); - - act(() => { - setWindowScrollY(120); - window.dispatchEvent(new Event('scroll')); - }); - stateText = await findByTestId('visibility-state'); - expect(stateText).toHaveTextContent('F-F'); - expect( - document.body.style.getPropertyValue('--mobile-bottom-nav-offset') - ).toBe('0px'); - - act(() => { - setWindowScrollY(0); - window.dispatchEvent(new Event('scroll')); - }); - stateText = await findByTestId('visibility-state'); - expect(stateText).toHaveTextContent('T-T'); - expect( - document.body.style.getPropertyValue('--mobile-bottom-nav-offset') - ).toBe('var(--mobile-bottom-nav-height)'); - }); -}); diff --git a/src/shared/layout/Header/useScrollVisibility.ts b/src/shared/layout/Header/useScrollVisibility.ts deleted file mode 100644 index 31538ef3..00000000 --- a/src/shared/layout/Header/useScrollVisibility.ts +++ /dev/null @@ -1,132 +0,0 @@ -'use client'; - -import { useEffect, useRef, useState } from 'react'; - -export interface VisibilityState { - topHeaderVisible: boolean; - bottomBarVisible: boolean; -} - -const HOME_NAV_REVEAL_SCROLL_Y = 40; - -const INITIAL_VISIBILITY: VisibilityState = { - topHeaderVisible: true, - bottomBarVisible: true, -}; - -export function isBlogPostPath(pathname: string): boolean { - return /^\/blog\/[^/]+$/.test(pathname); -} - -export function useScrollVisibility(pathname: string): VisibilityState { - const [visibility, setVisibility] = - useState(INITIAL_VISIBILITY); - const visibilityRef = useRef(INITIAL_VISIBILITY); - - useEffect(() => { - document.body.style.setProperty( - '--mobile-bottom-nav-offset', - visibility.bottomBarVisible ? 'var(--mobile-bottom-nav-height)' : '0px' - ); - - return () => { - document.body.style.removeProperty('--mobile-bottom-nav-offset'); - }; - }, [visibility.bottomBarVisible]); - - useEffect(() => { - const mediaQuery = window.matchMedia('(max-width: 767px)'); - const isHomePath = pathname === '/'; - const shouldHideBottomOnScroll = - isBlogPostPath(pathname) || isHomePath; - let lastScrollY = window.scrollY; - - const updateVisibility = (next: VisibilityState) => { - if ( - visibilityRef.current.topHeaderVisible === next.topHeaderVisible && - visibilityRef.current.bottomBarVisible === next.bottomBarVisible - ) { - return; - } - - visibilityRef.current = next; - setVisibility(next); - }; - - const resolveHomeBottomVisibility = (currentScrollY: number) => - isHomePath ? currentScrollY <= HOME_NAV_REVEAL_SCROLL_Y : null; - - const showAll = () => { - const homeBottomVisible = resolveHomeBottomVisibility(window.scrollY); - - updateVisibility({ - topHeaderVisible: true, - bottomBarVisible: homeBottomVisible ?? true, - }); - }; - - showAll(); - - const handleScroll = () => { - const currentScrollY = window.scrollY; - - if (!mediaQuery.matches) { - lastScrollY = currentScrollY; - showAll(); - return; - } - - if (currentScrollY <= 12) { - lastScrollY = currentScrollY; - const homeBottomVisible = resolveHomeBottomVisibility(currentScrollY); - updateVisibility({ - topHeaderVisible: true, - bottomBarVisible: homeBottomVisible ?? true, - }); - return; - } - - const homeBottomVisible = resolveHomeBottomVisibility(currentScrollY); - const delta = currentScrollY - lastScrollY; - if (Math.abs(delta) < 6) { - if ( - homeBottomVisible !== null && - homeBottomVisible !== visibilityRef.current.bottomBarVisible - ) { - updateVisibility({ - ...visibilityRef.current, - bottomBarVisible: homeBottomVisible, - }); - } - return; - } - - const scrollingDown = delta > 0; - const bottomBarVisible = - homeBottomVisible ?? (shouldHideBottomOnScroll ? !scrollingDown : true); - - updateVisibility({ - topHeaderVisible: !scrollingDown, - bottomBarVisible, - }); - - lastScrollY = currentScrollY; - }; - - const handleResize = () => { - if (!mediaQuery.matches) { - showAll(); - } - }; - - window.addEventListener('scroll', handleScroll, { passive: true }); - window.addEventListener('resize', handleResize); - - return () => { - window.removeEventListener('scroll', handleScroll); - window.removeEventListener('resize', handleResize); - }; - }, [pathname]); - - return visibility; -} diff --git a/src/shared/layout/index.ts b/src/shared/layout/index.ts index 3b41d3fa..0502b4d3 100644 --- a/src/shared/layout/index.ts +++ b/src/shared/layout/index.ts @@ -1,5 +1,4 @@ // Layout Components -export { Header } from './Header'; export { Footer } from './Footer'; export { Container } from './Container'; export { AppShell } from './AppShell'; diff --git a/src/shared/motion/ui/MotionModeToggle.test.tsx b/src/shared/motion/ui/MotionModeToggle.test.tsx deleted file mode 100644 index 485f3d46..00000000 --- a/src/shared/motion/ui/MotionModeToggle.test.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { act, fireEvent, render, screen } from '@testing-library/react'; -import { createElement, type ReactNode } from 'react'; -import { describe, expect, it, vi } from 'vitest'; -import MotionModeToggle from './MotionModeToggle'; - -const mockSetMotionMode = vi.fn(); -const mockTrackEvent = vi.fn(); - -vi.mock('@/shared/motion/model/motion-mode', () => ({ - getNextMotionMode: (mode: 'auto' | 'reduced' | 'off') => - mode === 'auto' ? 'reduced' : mode === 'reduced' ? 'off' : 'auto', - useMotionMode: () => ({ - motionMode: 'auto', - effectiveMotionMode: 'full', - setMotionMode: mockSetMotionMode, - }), -})); - -vi.mock('@/shared/analytics/lib/analytics', () => ({ - AnalyticsEvents: { - motion: 'motion_mode_changed', - }, - trackEvent: (...args: unknown[]) => mockTrackEvent(...args), -})); - -vi.mock('framer-motion', () => ({ - motion: { - button: ({ - children, - ...props - }: { - children: ReactNode; - whileHover?: unknown; - whileTap?: unknown; - [key: string]: unknown; - }) => { - const { - whileHover: _whileHover, - whileTap: _whileTap, - ...domProps - } = props; - return createElement('button', domProps, children); - }, - }, -})); - -describe('MotionModeToggle', () => { - it('cycles mode and tracks changes', async () => { - render(); - - const button = screen.getByRole('button', { - name: '모션 모드 자동 (다음: 축소)', - }); - - await act(async () => { - fireEvent.click(button); - }); - - expect(mockSetMotionMode).toHaveBeenCalledWith('reduced'); - expect(mockTrackEvent).toHaveBeenCalledWith('motion_mode_changed', { - from_mode: 'auto', - to_mode: 'reduced', - effective_mode: 'full', - surface: 'header', - }); - }); -}); diff --git a/src/shared/motion/ui/MotionModeToggle.tsx b/src/shared/motion/ui/MotionModeToggle.tsx deleted file mode 100644 index fa673910..00000000 --- a/src/shared/motion/ui/MotionModeToggle.tsx +++ /dev/null @@ -1,63 +0,0 @@ -'use client'; - -import { motion } from 'framer-motion'; -import { AnalyticsEvents, trackEvent } from '@/shared/analytics/lib/analytics'; -import { - getNextMotionMode, - type MotionMode, - useMotionMode, -} from '@/shared/motion/model/motion-mode'; - -const modeLabel: Record = { - auto: '자동', - reduced: '축소', - off: '끔', -}; - -const nextModeLabel: Record = { - auto: '축소', - reduced: '끔', - off: '자동', -}; - -const modeIcon: Record = { - auto: 'A', - reduced: 'R', - off: 'O', -}; - -export default function MotionModeToggle() { - const { motionMode, effectiveMotionMode, setMotionMode } = useMotionMode(); - - const handleToggle = () => { - const nextMode = getNextMotionMode(motionMode); - - setMotionMode(nextMode); - trackEvent(AnalyticsEvents.motion, { - from_mode: motionMode, - to_mode: nextMode, - effective_mode: effectiveMotionMode, - surface: 'header', - }); - }; - - return ( - - - 모션 {modeLabel[motionMode]} - - ); -} diff --git a/src/shared/testing/route-mocks.ts b/src/shared/testing/route-mocks.ts deleted file mode 100644 index 467ba743..00000000 --- a/src/shared/testing/route-mocks.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { vi } from 'vitest'; - -export const pathnameState: { value: string } = { value: '/' }; -let searchParams = new URLSearchParams(); - -export const routerState = { - push: vi.fn(), - replace: vi.fn(), - refresh: vi.fn(), - back: vi.fn(), - forward: vi.fn(), - prefetch: vi.fn(), -}; - -export function setMockPathname(pathname: string) { - pathnameState.value = pathname; -} - -export function setMockSearchParams(next: string) { - searchParams = new URLSearchParams(next); -} - -vi.mock('next/navigation', () => ({ - usePathname: vi.fn(() => pathnameState.value), - useSearchParams: vi.fn(() => searchParams), - useRouter: vi.fn(() => routerState), -})); - diff --git a/src/shared/testing/test-utils.tsx b/src/shared/testing/test-utils.tsx deleted file mode 100644 index 9dabc42b..00000000 --- a/src/shared/testing/test-utils.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { ReactElement } from 'react'; -import { render, RenderOptions } from '@testing-library/react'; - -export function renderWithProviders(ui: ReactElement, options?: Omit) { - return render(ui, options); -} - diff --git a/src/shared/ui/Logo.test.tsx b/src/shared/ui/Logo.test.tsx deleted file mode 100644 index 575fea89..00000000 --- a/src/shared/ui/Logo.test.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { createElement } from 'react'; -import { describe, expect, it, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import Logo from './Logo'; - -vi.mock('next/image', () => ({ - default: ({ - src, - alt, - fill: _fill, - priority: _priority, - ...props - }: { - src: string; - alt: string; - fill?: boolean; - priority?: boolean; - [key: string]: unknown; - }) => createElement('img', { src, alt, ...props }), -})); - -describe('Logo', () => { - it('renders logo image with expected source', () => { - render(); - - const image = screen.getByRole('img', { name: 'Logo' }); - - expect(image).toHaveAttribute('src', '/logo.png'); - }); -}); diff --git a/src/shared/ui/Logo.tsx b/src/shared/ui/Logo.tsx deleted file mode 100644 index 4b700ab1..00000000 --- a/src/shared/ui/Logo.tsx +++ /dev/null @@ -1,18 +0,0 @@ -'use client'; - -import Image from 'next/image'; - -export default function Logo() { - return ( -
- Logo -
- ); -} diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index cac08819..5809de7f 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -11,5 +11,3 @@ export type { export { RouteError } from './RouteState'; export type { RouteErrorProps } from './RouteState'; - -export { default as Logo } from './Logo'; diff --git a/src/styles/globals.test.ts b/src/styles/globals.test.ts index 05397721..6de22972 100644 --- a/src/styles/globals.test.ts +++ b/src/styles/globals.test.ts @@ -8,9 +8,10 @@ const tokensPath = path.resolve(process.cwd(), 'src/styles/tokens.css'); const tokensContent = fs.readFileSync(tokensPath, 'utf8'); describe('globals styles', () => { - it('uses variable based mobile bottom offset', () => { - expect(globalsContent).toContain('padding-bottom: calc(var(--mobile-bottom-nav-offset) + env(safe-area-inset-bottom));'); - expect(globalsContent).toContain('--mobile-bottom-nav-offset: var(--mobile-bottom-nav-height);'); + it('defines mobile navigation state variables at root', () => { + expect(globalsContent).toContain(':root {'); + expect(globalsContent).toContain('--mobile-bottom-nav-height: 0px;'); + expect(globalsContent).toContain('--mobile-bottom-nav-offset: 0px;'); }); it('defines mobile-nav related z-index custom properties', () => { diff --git a/tests/e2e/layout/safe-area.spec.ts b/tests/e2e/layout/safe-area.spec.ts index d701ada3..1885ff84 100644 --- a/tests/e2e/layout/safe-area.spec.ts +++ b/tests/e2e/layout/safe-area.spec.ts @@ -1,69 +1,39 @@ -import { test, expect } from '@playwright/test'; +import { expect, test } from '@playwright/test'; -test.describe('Safe area / bottom padding', () => { - test('하단 빈 공간이 nav 가시성에 맞춰 정리되는지', async ({ +test.describe('Safe area / mobile drawer layout', () => { + test('본문 래퍼에 과한 하단 inset이 남지 않아요', async ({ page, }, testInfo) => { - const isDesktopProject = testInfo.project.name === 'desktop-chrome'; + test.skip( + testInfo.project.name === 'desktop-chrome', + '모바일 레이아웃 전용 시나리오' + ); await page.goto('/'); await page.waitForLoadState('networkidle'); - await page.evaluate(() => { - document.body.style.setProperty('--mobile-bottom-nav-offset', '84px'); - }); - - const visibleOffset = await page.evaluate( - () => getComputedStyle(document.body).paddingBottom - ); - - await page.evaluate(() => { - document.body.style.setProperty('--mobile-bottom-nav-offset', '0px'); + const bottomPadding = await page.locator('main').evaluate((element) => { + return getComputedStyle(element).paddingBottom; }); - const hiddenOffset = await page.evaluate( - () => getComputedStyle(document.body).paddingBottom - ); - - expect(visibleOffset.length).toBeGreaterThan(0); - expect(hiddenOffset.length).toBeGreaterThan(0); - - if (isDesktopProject) { - expect(visibleOffset).toEqual(hiddenOffset); - expect(hiddenOffset).toBe('0px'); - return; - } - - expect(visibleOffset).not.toEqual(hiddenOffset); + expect(parseFloat(bottomPadding)).toBeLessThanOrEqual(40); }); - test('mobile route에서 홈 진입 직후 inset 적용이 남지 않도록', async ({ + test('모바일 드로어를 열면 배경 스크롤이 잠겨요', async ({ page, }, testInfo) => { test.skip( testInfo.project.name === 'desktop-chrome', - '모바일 하단 네비 전용 시나리오' + '모바일 드로어 전용 시나리오' ); await page.goto('/'); - const nav = page.getByRole('navigation', { - name: '모바일 하단 네비게이션', - }); + await page.getByRole('button', { name: '메뉴 열기' }).click(); - await page.evaluate(() => { - window.scrollTo(0, 160); - window.dispatchEvent(new Event('scroll')); + const bodyOverflow = await page.evaluate(() => { + return getComputedStyle(document.body).overflow; }); - await page.waitForTimeout(250); - const navTransform = await nav.evaluate( - (el) => getComputedStyle(el).transform - ); - - expect(navTransform).toContain('matrix('); - const bottomPadding = await page.evaluate( - () => getComputedStyle(document.body).paddingBottom - ); - expect(bottomPadding).not.toMatch(/84px/); + expect(bodyOverflow).toBe('hidden'); }); }); diff --git a/tests/e2e/regression/theme-regression.spec.ts b/tests/e2e/regression/theme-regression.spec.ts index c3ca68df..a1c6bfc6 100644 --- a/tests/e2e/regression/theme-regression.spec.ts +++ b/tests/e2e/regression/theme-regression.spec.ts @@ -1,29 +1,29 @@ -import { test, expect } from '@playwright/test'; +import { expect, test } from '@playwright/test'; test.describe('Theme regression', () => { - test('테마 토글 후 모바일 nav 스타일 토큰이 유지되는지', async ({ + test('테마 토글 후 모바일 드로어 라벨과 링크가 유지되는지', async ({ page, }, testInfo) => { test.skip( testInfo.project.name === 'desktop-chrome', - '모바일 하단 네비 전용 시나리오' + '모바일 드로어 전용 시나리오' ); await page.goto('/'); const themeButton = page.getByRole('button', { name: /모드로 전환/ }); await expect(themeButton).toBeVisible(); - const classBefore = await page.evaluate( - () => document.documentElement.className - ); + const classBefore = await page.evaluate(() => { + return document.documentElement.className; + }); const themeLabelBefore = await themeButton.getAttribute('aria-label'); await themeButton.click(); await page.waitForTimeout(400); - const classAfter = await page.evaluate( - () => document.documentElement.className - ); + const classAfter = await page.evaluate(() => { + return document.documentElement.className; + }); const themeLabelAfter = await themeButton.getAttribute('aria-label'); expect(classAfter.length).toBeGreaterThanOrEqual(0); @@ -31,21 +31,29 @@ test.describe('Theme regression', () => { classAfter !== classBefore || themeLabelBefore !== themeLabelAfter ).toBeTruthy(); - const activeItem = page.getByRole('link', { name: '홈' }); - await expect(activeItem).toBeVisible(); + await page.getByRole('button', { name: '메뉴 열기' }).click(); + + const homeItem = page + .getByLabel('모바일 네비게이션') + .getByRole('link', { name: /Home/ }); + await expect(homeItem).toBeVisible(); }); - test('focus visible outline class가 라우트 전환 후에도 존재', async ({ + test('모바일 드로어 링크에 focus-visible 클래스가 유지돼요', async ({ page, }, testInfo) => { test.skip( testInfo.project.name === 'desktop-chrome', - '모바일 하단 네비 전용 시나리오' + '모바일 드로어 전용 시나리오' ); await page.goto('/'); + await page.getByRole('button', { name: '메뉴 열기' }).click(); + + const homeItem = page + .getByLabel('모바일 네비게이션') + .getByRole('link', { name: /Home/ }); - const homeItem = page.getByRole('link', { name: '홈' }); await homeItem.focus(); const focusedClass = await homeItem.getAttribute('class'); diff --git a/tests/e2e/resilience/session-storage-fallback.spec.ts b/tests/e2e/resilience/session-storage-fallback.spec.ts index 5f1eb289..82e5fe1d 100644 --- a/tests/e2e/resilience/session-storage-fallback.spec.ts +++ b/tests/e2e/resilience/session-storage-fallback.spec.ts @@ -17,8 +17,8 @@ test.describe('Storage fallback', () => { }); await page.goto('/'); - await expect(page.getByText('안녕하세요, 우연입니다')).toBeVisible(); + await expect(page.getByRole('button', { name: /All/ })).toBeVisible(); + await expect(page.getByRole('button', { name: '최신순' })).toBeVisible(); expect(errors.join('\n')).not.toContain('Failed to set the value'); }); }); - diff --git a/tests/e2e/smoke/home-renewal.smoke.spec.ts b/tests/e2e/smoke/home-renewal.smoke.spec.ts index a6fbc9a5..4e9d2249 100644 --- a/tests/e2e/smoke/home-renewal.smoke.spec.ts +++ b/tests/e2e/smoke/home-renewal.smoke.spec.ts @@ -1,69 +1,31 @@ import { expect, test } from '@playwright/test'; -test.describe('Home Renewal', () => { - test('@smoke 데스크톱 홈에서 아티클 5개 페이징과 우측 패널이 보여요', async ({ +test.describe('Home feed', () => { + test('@smoke 데스크톱 홈에서 카테고리 필터와 정렬이 보여요', async ({ page, }) => { await page.goto('/'); - await expect( - page.getByRole('heading', { name: '안녕하세요, 우연입니다' }) - ).toBeVisible(); + await expect(page.getByRole('button', { name: /All/ })).toBeVisible(); + await expect(page.getByRole('button', { name: /Tech/ })).toBeVisible(); + await expect(page.getByRole('button', { name: /Life/ })).toBeVisible(); + await expect(page.getByRole('button', { name: '최신순' })).toBeVisible(); + await expect(page.getByRole('button', { name: '인기순' })).toBeVisible(); - await page.getByRole('button', { name: '전체 아티클 보기' }).click(); - - const articleCards = page.locator('#home-article-list a[href^="/blog/"]'); - await expect(articleCards).toHaveCount(5); - - await expect(page.getByRole('heading', { name: '인기 글' })).toBeVisible(); - await expect( - page.getByRole('heading', { name: '아티클 시리즈' }) - ).toBeVisible(); + const articleCards = page.locator('main a[href^="/blog/"]'); + await expect(articleCards.first()).toBeVisible(); }); - test('@smoke 홈 시리즈 카드에서 시리즈 상세로 이동해요', async ({ page }) => { + test('@smoke 홈 카테고리 필터가 리스트를 좁혀요', async ({ page }) => { await page.goto('/'); - await page.getByRole('button', { name: '전체 아티클 보기' }).click(); - - const firstSeriesLink = page - .locator('aside a[href^="/engineering/series/"]') - .first(); - await expect(firstSeriesLink).toBeVisible(); - await firstSeriesLink.scrollIntoViewIfNeeded(); - const targetHref = await firstSeriesLink.getAttribute('href'); - expect(targetHref).toMatch(/^\/engineering\/series\/.+/); - - if (!targetHref) { - throw new Error('시리즈 카드 링크 href를 찾지 못했어요.'); - } - - const response = await page.request.get(targetHref); - expect(response.ok()).toBeTruthy(); - - await page.goto(targetHref); - await expect(page).toHaveURL(new RegExp(`${targetHref}$`)); - - await expect( - page.getByRole('link', { name: 'Engineering으로 돌아가기' }) - ).toBeVisible(); - }); - - test('@smoke 시리즈 상세에서 에피소드를 눌러 글 상세로 이동해요', async ({ - page, - }) => { - await page.goto('/engineering/series/redis-deep-dive'); - - const firstEpisodeLink = page.locator('main ol a[href^="/blog/"]').first(); - await expect(firstEpisodeLink).toBeVisible(); - const href = await firstEpisodeLink.getAttribute('href'); - expect(href).toMatch(/^\/blog\/.+/); - - if (!href) { - throw new Error('시리즈 상세에서 글 링크를 찾지 못했어요.'); - } + await page.getByRole('button', { name: /Life/ }).click(); + await expect(page.getByRole('button', { name: /Life/ })).toHaveAttribute( + 'aria-pressed', + 'true' + ); - await page.goto(href); - await expect(page).toHaveURL(/\/blog\/.+/); + const cards = page.locator('main a[href^="/blog/"]'); + await expect(cards.first()).toBeVisible(); }); }); diff --git a/tests/e2e/smoke/mobile-nav.smoke.spec.ts b/tests/e2e/smoke/mobile-nav.smoke.spec.ts index 1df4727c..3cb4912e 100644 --- a/tests/e2e/smoke/mobile-nav.smoke.spec.ts +++ b/tests/e2e/smoke/mobile-nav.smoke.spec.ts @@ -1,96 +1,57 @@ -import { test, expect } from '@playwright/test'; +import { expect, test } from '@playwright/test'; test.describe('Mobile navigation', () => { test.beforeEach(({}, testInfo) => { test.skip(!testInfo.project.use.isMobile, '모바일 프로젝트 전용 테스트예요.'); }); - test('@smoke 홈 진입 즉시 하단 내비 표시', async ({ page }) => { + test('@smoke 홈 진입 시 메뉴 버튼으로 모바일 드로어를 열 수 있어요', async ({ + page, + }) => { await page.goto('/'); - const nav = page.getByRole('navigation', { - name: '모바일 하단 네비게이션', - }); - await expect(nav).toBeVisible(); + const menuButton = page.getByRole('button', { name: '메뉴 열기' }); + await expect(menuButton).toBeVisible(); - const rectBefore = await nav.boundingBox(); - expect(rectBefore).not.toBeNull(); - expect(rectBefore?.y).toBeGreaterThan(0); + await menuButton.click(); - const transform = await nav.evaluate((el) => getComputedStyle(el).transform); - expect(transform === 'none' || transform.includes('matrix')).toBeTruthy(); + const nav = page.getByLabel('모바일 네비게이션'); + await expect(nav).toBeVisible(); + await expect(nav.getByRole('link', { name: /Home/ })).toBeVisible(); + await expect(nav.getByRole('link', { name: /Tech/ })).toBeVisible(); + await expect(nav.getByRole('link', { name: /Life/ })).toBeVisible(); + await expect(nav.getByRole('link', { name: /Resume/ })).toBeVisible(); }); - test('스크롤 0/20/31/100에서 하단바 상태가 일관되게 전환되는지', async ({ - page, - }) => { + test('모바일 드로어는 닫기 버튼으로 닫혀요', async ({ page }) => { await page.goto('/'); - const nav = page.getByRole('navigation', { - name: '모바일 하단 네비게이션', - }); - - const getBottomOffset = () => - page.evaluate(() => - document.body.style.getPropertyValue('--mobile-bottom-nav-offset').trim() - ); - - await page.evaluate(() => { - window.scrollTo(0, 0); - window.dispatchEvent(new Event('scroll')); - }); - await expect.poll(getBottomOffset).toBe('var(--mobile-bottom-nav-height)'); + await page.getByRole('button', { name: '메뉴 열기' }).click(); - await page.evaluate(() => { - window.scrollTo(0, 20); - window.dispatchEvent(new Event('scroll')); - }); - await expect.poll(getBottomOffset).toBe('var(--mobile-bottom-nav-height)'); + const nav = page.locator('#mobile-nav-drawer'); + await expect(nav).toHaveClass(/translate-x-0/); - await page.evaluate(() => { - window.scrollTo(0, 31); - window.dispatchEvent(new Event('scroll')); - }); - await expect.poll(getBottomOffset).toBe('var(--mobile-bottom-nav-height)'); + await nav.getByRole('button', { name: '메뉴 닫기' }).click(); + await expect(nav).toHaveClass(/-translate-x-full/); + }); - await page.evaluate(() => { - window.scrollTo(0, 100); - window.dispatchEvent(new Event('scroll')); - }); - await expect.poll(getBottomOffset).toBe('0px'); + test('모바일 드로어에서 이동하면 자동으로 닫혀요', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: '메뉴 열기' }).click(); - await page.evaluate(() => { - window.scrollTo(0, 0); - window.dispatchEvent(new Event('scroll')); - }); - await expect.poll(getBottomOffset).toBe('var(--mobile-bottom-nav-height)'); + const nav = page.locator('#mobile-nav-drawer'); + await nav.getByRole('link', { name: /Tech/ }).click(); - const finalTransform = await nav.evaluate((el) => getComputedStyle(el).transform); - expect(finalTransform).toMatch(/none|matrix/); + await expect(page).toHaveURL(/\/engineering/); + await expect(nav).toHaveClass(/-translate-x-full/); }); - test('스크롤 동작 후 라우트 복귀해도 바텀바 규칙이 재적용되는지', async ({ page }) => { + test('모바일에서 홈 본문 하단 여백이 과하게 남지 않아요', async ({ page }) => { await page.goto('/'); - const getBottomOffset = () => - page.evaluate(() => - document.body.style.getPropertyValue('--mobile-bottom-nav-offset').trim() - ); - await page.evaluate(() => { - window.scrollTo(0, 120); - window.dispatchEvent(new Event('scroll')); + const bottomPadding = await page.locator('main').evaluate((element) => { + return getComputedStyle(element).paddingBottom; }); - await expect.poll(getBottomOffset).toBe('0px'); - - await page.goto('/engineering'); - await page.waitForLoadState('networkidle'); - await expect.poll(getBottomOffset).toBe('var(--mobile-bottom-nav-height)'); - await page.goto('/life'); - await page.waitForLoadState('networkidle'); - await expect.poll(getBottomOffset).toBe('var(--mobile-bottom-nav-height)'); - - await page.goto('/'); - await page.waitForLoadState('networkidle'); - await expect.poll(getBottomOffset).toBe('var(--mobile-bottom-nav-height)'); + expect(parseFloat(bottomPadding)).toBeLessThanOrEqual(40); }); }); diff --git a/tests/e2e/smoke/navigation-ia.smoke.spec.ts b/tests/e2e/smoke/navigation-ia.smoke.spec.ts index 61c087fd..f05ad5ed 100644 --- a/tests/e2e/smoke/navigation-ia.smoke.spec.ts +++ b/tests/e2e/smoke/navigation-ia.smoke.spec.ts @@ -5,49 +5,82 @@ async function warmRoute(page: Page, path: string) { expect(response.ok()).toBeTruthy(); } +async function openDrawer(page: Page) { + await page.getByRole('button', { name: '메뉴 열기' }).click(); + await expect(page.locator('#mobile-nav-drawer')).toHaveClass(/translate-x-0/); +} + test.describe('Navigation IA', () => { - test('@smoke 모바일 하단 네비 4탭이 동작해요', async ({ page }, testInfo) => { + test('@smoke 모바일 드로어에서 Tech로 이동해요', async ({ + page, + }, testInfo) => { test.skip( !testInfo.project.use.isMobile, '모바일 프로젝트 전용 테스트예요.' ); await page.goto('/'); - await expect( - page.getByRole('navigation', { name: '모바일 하단 네비게이션' }) - ).toBeVisible(); - - const nav = page.getByRole('navigation', { - name: '모바일 하단 네비게이션', - }); + await openDrawer(page); - const engineeringTab = nav.getByRole('link', { - name: 'Engineering', - exact: true, + const techTab = page.getByLabel('모바일 네비게이션').getByRole('link', { + name: /Tech/, }); - await expect(engineeringTab).toHaveAttribute('href', '/engineering'); + await expect(techTab).toHaveAttribute('href', '/engineering'); await warmRoute(page, '/engineering'); - await page.goto('/engineering'); + await techTab.click(); await expect(page).toHaveURL(/\/engineering/); + }); + + test('모바일 드로어에서 Life로 이동해요', async ({ page }, testInfo) => { + test.skip( + !testInfo.project.use.isMobile, + '모바일 프로젝트 전용 테스트예요.' + ); await page.goto('/'); - const lifeTab = nav.getByRole('link', { name: 'Life', exact: true }); + await openDrawer(page); + + const lifeTab = page.getByLabel('모바일 네비게이션').getByRole('link', { + name: /Life/, + }); await expect(lifeTab).toHaveAttribute('href', '/life'); await warmRoute(page, '/life'); - await page.goto('/life'); + await lifeTab.click(); await expect(page).toHaveURL(/\/life/); + }); + + test('모바일 드로어에서 Resume로 이동해요', async ({ page }, testInfo) => { + test.skip( + !testInfo.project.use.isMobile, + '모바일 프로젝트 전용 테스트예요.' + ); await page.goto('/'); - const resumeTab = nav.getByRole('link', { name: 'Resume', exact: true }); + await openDrawer(page); + + const resumeTab = page.getByLabel('모바일 네비게이션').getByRole('link', { + name: /Resume/, + }); await expect(resumeTab).toHaveAttribute('href', '/resume'); await warmRoute(page, '/resume'); - await page.goto('/resume'); + await resumeTab.click(); await expect(page).toHaveURL(/\/resume/); + }); + + test('모바일 드로어에서 Home으로 이동해요', async ({ page }, testInfo) => { + test.skip( + !testInfo.project.use.isMobile, + '모바일 프로젝트 전용 테스트예요.' + ); await page.goto('/engineering'); - const homeTab = nav.getByRole('link', { name: '홈', exact: true }); + await openDrawer(page); + + const homeTab = page.getByLabel('모바일 네비게이션').getByRole('link', { + name: /Home/, + }); await expect(homeTab).toHaveAttribute('href', '/'); - await page.goto('/'); + await homeTab.click(); await expect(page).toHaveURL(/\/$/); }); @@ -77,7 +110,7 @@ test.describe('Navigation IA', () => { throw new Error('상세 글 href를 찾지 못했어요.'); } - await page.goto(href); - await expect(page).toHaveURL(/\/blog\/.+/); + const response = await page.request.get(href); + expect(response.ok()).toBeTruthy(); }); });