diff --git a/.gitignore b/.gitignore index 7553d92..938355a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ /.pnp .pnp.* .yarn/* +/.next !.yarn/patches !.yarn/plugins !.yarn/releases @@ -42,4 +43,7 @@ next-env.d.ts # Test app/test/ -app/api/test \ No newline at end of file +app/api/test + +# Idea +/.idea \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 9e46ab5..759123b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -21,5 +21,9 @@ "http", "net" ], - "discord.enabled": true + "discord.enabled": true, + "chat.tools.terminal.autoApprove": { + "npx jest": true, + "npm run lint": true + } } diff --git a/__tests__/kkuko/KkukoHome.test.tsx b/__tests__/kkuko/KkukoHome.test.tsx new file mode 100644 index 0000000..74e8343 --- /dev/null +++ b/__tests__/kkuko/KkukoHome.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from '@testing-library/react'; +import KkukoHome from '@/app/kkuko/KkukoHome'; // Adjust import path if needed + +describe('KkukoHome', () => { + it('should render the title and description', () => { + render(); + + expect(screen.getByText('끄코 정보')).toBeInTheDocument(); + expect(screen.getByText('끄투코리아의 유저정보와 랭킹을 조회 할 수 있습니다.')).toBeInTheDocument(); + }); + + it('should render the Profile section with link', () => { + render(); + + expect(screen.getByRole('heading', { name: '프로필' })).toBeInTheDocument(); + expect(screen.getByText('끄투코리아의 유저 정보와 전적 등을 확인할 수 있습니다.')).toBeInTheDocument(); + + const profileLink = screen.getByRole('link', { name: /둘러보기/i }); + expect(profileLink).toHaveAttribute('href', '/kkuko/profile'); + }); + + it('should render the Ranking section with link', () => { + render(); + + expect(screen.getByRole('heading', { name: '랭킹' })).toBeInTheDocument(); + expect(screen.getByText('각 모드별로 승리가 많은 유저들의 랭킹을 확인할 수 있습니다.')).toBeInTheDocument(); + + const rankingLink = screen.getByRole('link', { name: /구경하기/i }); + expect(rankingLink).toHaveAttribute('href', '/kkuko/ranking'); + }); +}); diff --git a/__tests__/kkuko/profile/components/ItemModal.test.tsx b/__tests__/kkuko/profile/components/ItemModal.test.tsx new file mode 100644 index 0000000..f859dbd --- /dev/null +++ b/__tests__/kkuko/profile/components/ItemModal.test.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import ItemModal from '@/app/kkuko/profile/components/ItemModal'; +import { ItemInfo, ProfileData } from '@/app/types/kkuko.types'; + +jest.mock('@/app/kkuko/shared/components/TryRenderImg', () => () =>
); +jest.mock('@/app/kkuko/profile/utils/profileHelper', () => ({ + getSlotName: (slot: string) => slot, + extractColorFromLabel: () => [], + parseDescription: (desc: string) => [{ text: desc, colorKey: null }], + getOptionName: (key: string) => key, + formatNumber: (num: number) => num.toString(), +})); +jest.mock('@/app/kkuko/shared/lib/const', () => ({ + NICKNAME_COLORS: {}, +})); + +describe('ItemModal', () => { + const mockProfileData: ProfileData = { + equipment: [ + { itemId: 'item1', slot: 'head' }, + ], + } as any; + + const mockItemsData: ItemInfo[] = [ + { + id: 'item1', + name: 'Cool Hat', + image: 'hat.png', + desc: 'A nice hat', + options: { score: 10 } + }, + ] as any; + + it('should render items in modal', () => { + const onClose = jest.fn(); + render(); + + expect(screen.getByText('장착 아이템 목록')).toBeInTheDocument(); + expect(screen.getByText('Cool Hat')).toBeInTheDocument(); + expect(screen.getByText('score:')).toBeInTheDocument(); + expect(screen.getByText('+10000')).toBeInTheDocument(); + }); + + it('should call onClose when close button is clicked', () => { + const onClose = jest.fn(); + render(); + + const closeButtons = screen.getAllByRole('button'); + // Usually the first one or the "X" button. + fireEvent.click(closeButtons[0]); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/kkuko/profile/components/ProfileHeader.test.tsx b/__tests__/kkuko/profile/components/ProfileHeader.test.tsx new file mode 100644 index 0000000..fbf1518 --- /dev/null +++ b/__tests__/kkuko/profile/components/ProfileHeader.test.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import ProfileHeader from '@/app/kkuko/profile/components/ProfileHeader'; +import { ProfileData, ItemInfo } from '@/types/kkuko.types'; +import * as profileHelper from '@/app/kkuko/profile/utils/profileHelper'; + +jest.mock('@/app/kkuko/profile/utils/profileHelper'); +jest.mock('@/app/kkuko/shared/components/ProfileAvatar', () => () =>
); +jest.mock('@/app/kkuko/shared/components/TryRenderImg', () => (props: any) => {props.alt}); + +describe('ProfileHeader', () => { + const mockProfileData: ProfileData = { + user: { + id: 'test_user', + nickname: 'Test Nick', + level: 10, + exp: 1000, + exordial: 'Hello World' + }, + equipment: [ + { id: 1, itemId: 'badge1', slot: 'pbdg', stat: {} } + ], + presence: { updatedAt: new Date().toISOString() }, + game: { + win: 10, lose: 5, draw: 0, + max_combo: 100, max_score: 50000 + } + } as any; // Partial mock + + const mockItemsData: ItemInfo[] = [ + { id: 'badge1', name: 'Best Badge', description: '', group: 'pbdg', options: {}, updatedAt: 1 } + ]; + + beforeEach(() => { + (profileHelper.getNicknameColor as jest.Mock).mockReturnValue('#000000'); + (profileHelper.formatLastSeen as jest.Mock).mockReturnValue('1분 전'); + }); + + it('should render user info', () => { + render(); + + expect(screen.getByText('Test Nick')).toBeInTheDocument(); + expect(screen.getByText('Hello World')).toBeInTheDocument(); + expect(screen.getByText('ID: test_user')).toBeInTheDocument(); + expect(screen.getByText('Lv. 10')).toBeInTheDocument(); + expect(screen.getByText('경험치 랭킹: #1')).toBeInTheDocument(); + }); + + it('should render badges', () => { + render(); + + expect(screen.getByAltText('Best Badge')).toBeInTheDocument(); + }); +}); diff --git a/__tests__/kkuko/profile/components/ProfileRecords.test.tsx b/__tests__/kkuko/profile/components/ProfileRecords.test.tsx new file mode 100644 index 0000000..d71300b --- /dev/null +++ b/__tests__/kkuko/profile/components/ProfileRecords.test.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import ProfileRecords from '@/app/kkuko/profile/components/ProfileRecords'; +import { Mode } from '@/app/types/kkuko.types'; + +// Mock helper functions +jest.mock('@/app/kkuko/profile/utils/profileHelper', () => ({ + groupRecordsByMode: jest.fn(() => ({ + kor: [ + { id: '1', modeId: 'kr_word', total: 10, win: 5, exp: 100 }, + ], + eng: [], + })), + getModeName: jest.fn(() => '한국어 끝말잇기'), + calculateWinRate: jest.fn(() => '50.0'), +})); + +describe('ProfileRecords', () => { + const mockModesData: Mode[] = [ + { id: 'kr_word', name: '한국어 끝말잇기', category: 'kor' }, + ]; + const mockProfileData: any = { + record: [ + { id: '1', modeId: 'kr_word', total: 10, win: 5, exp: 100 }, + ], + }; + + it('should render records correctly', () => { + render(); + + expect(screen.getByText('전적')).toBeInTheDocument(); + expect(screen.getByText('한국어')).toBeInTheDocument(); + expect(screen.getByText('한국어 끝말잇기')).toBeInTheDocument(); + expect(screen.getByText('10')).toBeInTheDocument(); // total + expect(screen.getByText('5')).toBeInTheDocument(); // win + expect(screen.getByText('50.0%')).toBeInTheDocument(); // rate + expect(screen.getByText('100')).toBeInTheDocument(); // exp + }); +}); diff --git a/__tests__/kkuko/profile/components/ProfileSearch.test.tsx b/__tests__/kkuko/profile/components/ProfileSearch.test.tsx new file mode 100644 index 0000000..0e69b4c --- /dev/null +++ b/__tests__/kkuko/profile/components/ProfileSearch.test.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import ProfileSearch from '@/app/kkuko/profile/components/ProfileSearch'; +import { useRouter, useSearchParams } from 'next/navigation'; + +jest.mock('next/navigation', () => ({ + useRouter: jest.fn(), + useSearchParams: jest.fn(), +})); + +describe('ProfileSearch', () => { + const mockRouter = { push: jest.fn(), replace: jest.fn() }; + const mockSearchParams = { get: jest.fn() }; + const mockOnRemoveRecentSearch = jest.fn(); + const mockOnSearch = jest.fn(); + + beforeEach(() => { + (useRouter as jest.Mock).mockReturnValue(mockRouter); + (useSearchParams as jest.Mock).mockReturnValue(mockSearchParams); + mockRouter.push.mockClear(); + mockRouter.replace.mockClear(); + mockSearchParams.get.mockClear(); + mockOnRemoveRecentSearch.mockClear(); + mockOnSearch.mockClear(); + }); + + it('should render search input and button', () => { + const { container } = render(); + expect(screen.getByPlaceholderText('유저 검색...')).toBeInTheDocument(); + // search button check + const button = container.querySelector('button.bg-blue-500'); + expect(button).toBeInTheDocument(); + }); + + it('should change search type', () => { + render(); + // By default 'nick' is selected (label usually '닉네임') + // const select = screen.getByRole('combobox'); // If it is a select + // Or buttons if it's a toggle. + // Let's assume there is a select element based on typical UI or check code more deeply if needed. + // Reading code... `const [searchType, setSearchType] = useState<'nick' | 'id'>('nick');` + // It likely uses a select dropdown. + const select = screen.getByRole('combobox'); + fireEvent.change(select, { target: { value: 'id' } }); + expect(select).toHaveValue('id'); + }); + + it('should submit search', () => { + const { container } = render(); + const input = screen.getByPlaceholderText('유저 검색...'); + fireEvent.change(input, { target: { value: 'testuser' } }); + + const button = container.querySelector('button.bg-blue-500'); + if (button) { + fireEvent.click(button); + } else { + throw new Error('Search button not found'); + } + + expect(mockOnSearch).toHaveBeenCalledWith('testuser', 'nick'); + }); + + it('should show recent searches on focus', () => { + const recentSearches = [{ query: 'past', type: 'nick', timestamp: 123 } as any]; + render(); + + const input = screen.getByPlaceholderText('유저 검색...'); + fireEvent.focus(input); + + expect(screen.getByText('past')).toBeInTheDocument(); + }); +}); diff --git a/__tests__/kkuko/profile/components/ProfileStats.test.tsx b/__tests__/kkuko/profile/components/ProfileStats.test.tsx new file mode 100644 index 0000000..a927a1e --- /dev/null +++ b/__tests__/kkuko/profile/components/ProfileStats.test.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import ProfileStats from '@/app/kkuko/profile/components/ProfileStats'; +import { ItemInfo } from '@/types/kkuko.types'; +import * as profileHelper from '@/app/kkuko/profile/utils/profileHelper'; + +jest.mock('@/app/kkuko/profile/utils/profileHelper'); + +describe('ProfileStats', () => { + const mockItemsData: ItemInfo[] = [ + { id: '1', name: 'Item 1', description: '', group: '', options: { str: 10 }, updatedAt: 1 } + ]; + const mockOnShowDetail = jest.fn(); + + beforeEach(() => { + (profileHelper.calculateTotalOptions as jest.Mock).mockReturnValue({ + 'str': 10000, + 'gEXP': 5000 + }); + (profileHelper.getOptionName as jest.Mock).mockImplementation((key) => key); + (profileHelper.formatNumber as jest.Mock).mockImplementation((val) => (val / 1000).toString()); + }); + + it('should render total options correctly', () => { + render(); + + // STR: 10000 -> +10 + expect(screen.getByText('str')).toBeInTheDocument(); + expect(screen.getByText('+10')).toBeInTheDocument(); + + // gEXP: 5000 -> +5%p + expect(screen.getByText('gEXP')).toBeInTheDocument(); + expect(screen.getByText('+5%p')).toBeInTheDocument(); + }); + + it('should call onShowDetail when button clicked', () => { + render(); + + const button = screen.getByRole('button', { name: '보기' }); + fireEvent.click(button); + + expect(mockOnShowDetail).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/kkuko/profile/hooks/useKkukoProfile.test.ts b/__tests__/kkuko/profile/hooks/useKkukoProfile.test.ts new file mode 100644 index 0000000..6e20ec3 --- /dev/null +++ b/__tests__/kkuko/profile/hooks/useKkukoProfile.test.ts @@ -0,0 +1,90 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useKkukoProfile } from '@/app/kkuko/profile/hooks/useKkukoProfile'; +import * as api from '@/app/kkuko/shared/lib/api'; + +// Mock API +jest.mock('@/app/kkuko/shared/lib/api', () => ({ + fetchModes: jest.fn(), + fetchTotalUsers: jest.fn(), + fetchProfile: jest.fn(), + fetchItems: jest.fn(), + fetchExpRank: jest.fn() +})); + +// Mock useRecentSearches +const mockSaveToRecentSearches = jest.fn(); +const mockRemoveFromRecentSearches = jest.fn(); +jest.mock('@/app/kkuko/profile/hooks/useRecentSearches', () => ({ + useRecentSearches: jest.fn(() => ({ + recentSearches: [], + saveToRecentSearches: mockSaveToRecentSearches, + removeFromRecentSearches: mockRemoveFromRecentSearches + })) +})); + +describe('useKkukoProfile', () => { + beforeEach(() => { + jest.clearAllMocks(); + (api.fetchModes as jest.Mock).mockResolvedValue({ data: { status: 200, data: [] } }); + (api.fetchTotalUsers as jest.Mock).mockResolvedValue({ data: { status: 200, data: { totalUsers: 100 } } }); + }); + + it('should fetch modes and total users on mount', async () => { + const { result } = renderHook(() => useKkukoProfile()); + + await waitFor(() => { + expect(api.fetchModes).toHaveBeenCalled(); + expect(api.fetchTotalUsers).toHaveBeenCalled(); + expect(result.current.totalUserCount).toBe(100); + }); + }); + + it('should fetch profile successfully', async () => { + const mockProfileData = { + user: { id: 'test', nickname: 'Test', level: 1, exp: 0, exordial: '' }, + equipment: [{ itemId: 'item1', slot: 'head' }], + presence: { updatedAt: new Date().toISOString() } + }; + const mockItemsData = [{ id: 'item1', name: 'Item 1' }]; + + (api.fetchProfile as jest.Mock).mockResolvedValue({ status: 200, data: { status: 200, data: mockProfileData } }); + (api.fetchItems as jest.Mock).mockResolvedValue({ data: { status: 200, data: mockItemsData } }); + (api.fetchExpRank as jest.Mock).mockResolvedValue({ data: { rank: 5 } }); + + const { result } = renderHook(() => useKkukoProfile()); + + await act(async () => { + await result.current.fetchProfile('Test', 'nick'); + }); + + expect(result.current.profileData).toEqual(mockProfileData); + expect(result.current.itemsData).toEqual(mockItemsData); + expect(result.current.expRank).toBe(5); + expect(mockSaveToRecentSearches).toHaveBeenCalledWith('Test', 'nick'); + }); + + it('should handle profile not found (status 404)', async () => { + (api.fetchProfile as jest.Mock).mockResolvedValue({ status: 404 }); + + const { result } = renderHook(() => useKkukoProfile()); + + await act(async () => { + await result.current.fetchProfile('Unknown', 'nick'); + }); + + expect(result.current.error).toBe('등록된 유저가 아닙니다.'); + expect(result.current.profileData).toBeNull(); + }); + + it('should handle API errors', async () => { + (api.fetchProfile as jest.Mock).mockRejectedValue(new Error('Network Error')); + + const { result } = renderHook(() => useKkukoProfile()); + + await act(async () => { + await result.current.fetchProfile('Test', 'nick'); + }); + + expect(result.current.error).toBe('프로필을 불러오는데 실패했습니다.'); + }); +}); diff --git a/__tests__/kkuko/profile/hooks/useRecentSearches.test.ts b/__tests__/kkuko/profile/hooks/useRecentSearches.test.ts new file mode 100644 index 0000000..f530b3a --- /dev/null +++ b/__tests__/kkuko/profile/hooks/useRecentSearches.test.ts @@ -0,0 +1,71 @@ +import { renderHook, act } from '@testing-library/react'; +import { useRecentSearches } from '@/app/kkuko/profile/hooks/useRecentSearches'; + +describe('useRecentSearches', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('should initialize with empty array if nothing in localStorage', () => { + const { result } = renderHook(() => useRecentSearches()); + expect(result.current.recentSearches).toEqual([]); + }); + + it('should load recent searches from localStorage', () => { + const initialData = [{ query: 'test', type: 'nick' }]; + localStorage.setItem('kkuko-recent-searches', JSON.stringify(initialData)); + const { result } = renderHook(() => useRecentSearches()); + expect(result.current.recentSearches).toEqual(initialData); + }); + + it('should save to recent searches', () => { + const { result } = renderHook(() => useRecentSearches()); + + act(() => { + result.current.saveToRecentSearches('test1', 'nick'); + }); + + expect(result.current.recentSearches).toHaveLength(1); + expect(result.current.recentSearches[0]).toEqual({ query: 'test1', type: 'nick' }); + expect(JSON.parse(localStorage.getItem('kkuko-recent-searches') || '[]')).toHaveLength(1); + }); + + it('should move duplicate search to top', () => { + const { result } = renderHook(() => useRecentSearches()); + + act(() => { + result.current.saveToRecentSearches('test1', 'nick'); + result.current.saveToRecentSearches('test2', 'id'); + result.current.saveToRecentSearches('test1', 'nick'); + }); + + expect(result.current.recentSearches).toHaveLength(2); + expect(result.current.recentSearches[0]).toEqual({ query: 'test1', type: 'nick' }); + }); + + it('should remove from recent searches', () => { + const { result } = renderHook(() => useRecentSearches()); + + act(() => { + result.current.saveToRecentSearches('test1', 'nick'); + result.current.removeFromRecentSearches('test1', 'nick'); + }); + + expect(result.current.recentSearches).toHaveLength(0); + expect(JSON.parse(localStorage.getItem('kkuko-recent-searches') || '[]')).toHaveLength(0); + }); + + it('should limit history to 7 items', () => { + const { result } = renderHook(() => useRecentSearches()); + + act(() => { + for (let i = 0; i < 10; i++) { + result.current.saveToRecentSearches(`test${i}`, 'nick'); + } + }); + + expect(result.current.recentSearches).toHaveLength(7); + // The last one added (test9) should be first + expect(result.current.recentSearches[0].query).toBe('test9'); + }); +}); diff --git a/__tests__/kkuko/profile/utils/profileHelper.test.ts b/__tests__/kkuko/profile/utils/profileHelper.test.ts new file mode 100644 index 0000000..cddc8d3 --- /dev/null +++ b/__tests__/kkuko/profile/utils/profileHelper.test.ts @@ -0,0 +1,219 @@ +// __tests__/kkuko/profile/utils/profileHelper.test.ts +import { + getNicknameColor, + extractColorFromLabel, + formatNumber, + calculateTotalOptions, + getModeName, + getModeGroup, + groupRecordsByMode, + calculateWinRate, + formatObservedAt, + getSlotName, + getOptionName, + formatLastSeen, + parseDescription +} from '@/app/kkuko/profile/utils/profileHelper'; +import { Equipment, ItemInfo, Mode, KkukoRecord } from '@/types/kkuko.types'; +import { NICKNAME_COLORS, SLOT_NAMES, OPTION_NAMES } from '@/app/kkuko/shared/lib/const'; + +describe('profileHelper', () => { + + describe('getNicknameColor', () => { + const isDarkTheme = false; + const equipment: Equipment[] = [ + { itemId: 'normal_item', slot: 'Mhead', userId: 'user1' }, + { itemId: 'red_name', slot: 'NIK', userId: 'user1' } + ]; + + it('should return correct color for NIK slot', () => { + const color = getNicknameColor(equipment, isDarkTheme); + expect(color).toBe(NICKNAME_COLORS['red']); + }); + + it('should return default color if no NIK slot', () => { + const color = getNicknameColor([], isDarkTheme); + expect(color).toBe('#000000'); + }); + + it('should return white default for dark theme', () => { + const color = getNicknameColor([], true); + expect(color).toBe('#FFFFFF'); + }); + + it('should return default color if color key not found', () => { + const badEquip: Equipment[] = [{ userId: 'user1', itemId: 'unknown_color', slot: 'NIK' }]; + const color = getNicknameColor(badEquip, false); + expect(color).toBe('#000000'); + }); + }); + + describe('extractColorFromLabel', () => { + it('should extract color from label', () => { + const desc = ""; + const color = extractColorFromLabel(desc, false); + expect(color).toBe(NICKNAME_COLORS['blue']); + }); + + it('should return default if no match', () => { + const desc = "Simple text"; + const color = extractColorFromLabel(desc, false); + expect(color).toBe('#000000'); + }); + }); + + describe('formatNumber', () => { + it('should divide by 1000 and return string', () => { + expect(formatNumber(1500)).toBe('1.5'); + expect(formatNumber(10000)).toBe('10'); + }); + }); + + describe('calculateTotalOptions', () => { + it('should calculate totals correctly for normal options', () => { + const items: ItemInfo[] = [ + { id: '1', name: 'Item1', description: '', group: '', options: { str: 1, dex: 2 }, updatedAt: 1 }, + { id: '2', name: 'Item2', description: '', group: '', options: { str: 3 }, updatedAt: 1 } + ]; + const result = calculateTotalOptions(items); + // 1 * 1000 + 3 * 1000 = 4000 + expect(result['str']).toBe(4000); + expect(result['dex']).toBe(2000); + }); + + it('should handle special options', () => { + const now = Date.now(); + const items: ItemInfo[] = [ + { + id: '3', name: 'Special', description: '', group: '', + options: { + date: now - 10000, // Past + before: { str: 1 }, + after: { str: 5 } + }, updatedAt: 1 + } + ]; + const result = calculateTotalOptions(items); + // Should use 'after' + expect(result['str']).toBe(5000); + }); + + it('should handle special options (before)', () => { + const now = Date.now(); + const items: ItemInfo[] = [ + { + id: '3', name: 'Special', description: '', group: '', + options: { + date: now + 10000, // Future + before: { str: 1 }, + after: { str: 5 } + }, updatedAt: 1 + } + ]; + const result = calculateTotalOptions(items); + // Should use 'before' + expect(result['str']).toBe(1000); + }); + }); + + describe('getModeName & getModeGroup', () => { + const modes: Mode[] = [ + { modeId: 'm1', modeName: 'Mode 1', group: 'kor' }, + { modeId: 'm2', modeName: 'Mode 2', group: 'eng' } + ]; + + it('should return mode name', () => { + expect(getModeName('m1', modes)).toBe('Mode 1'); + expect(getModeName('unknown', modes)).toBe('unknown'); + }); + + it('should return mode group', () => { + expect(getModeGroup('m1', modes)).toBe('kor'); + expect(getModeGroup('unknown', modes)).toBe('unknown'); + }); + }); + + describe('groupRecordsByMode', () => { + const modes: Mode[] = [ + { modeId: 'm1', modeName: 'Mode 1', group: 'kor' }, + { modeId: 'm2', modeName: 'Mode 2', group: 'eng' }, + { modeId: 'm3', modeName: 'Mode 3', group: 'event' } + ]; + const records: KkukoRecord[] = [ + { modeId: 'm1', win: 10, lose: 5, draw: 0 }, + { modeId: 'm2', win: 20, lose: 5, draw: 0 }, + { modeId: 'm3', win: 5, lose: 5, draw: 0 }, + { modeId: 'm1', win: 1, lose: 1, draw: 0 } + ] as any; + + it('should group records', () => { + const grouped = groupRecordsByMode(records, modes); + expect(grouped['kor']).toHaveLength(2); + expect(grouped['eng']).toHaveLength(1); + expect(grouped['event']).toHaveLength(1); + }); + }); + + describe('calculateWinRate', () => { + it('should calculate win rate', () => { + expect(calculateWinRate(50, 100)).toBe('50.00'); + expect(calculateWinRate(1, 3)).toBe('33.33'); + expect(calculateWinRate(0, 0)).toBe('0.00'); + }); + }); + + describe('formatObservedAt', () => { + it('should format date', () => { + const dateStr = '2023-01-01T12:00:00.000Z'; + const formatted = formatObservedAt(dateStr); + // This might depend on locale, checking for basic structure or parts + expect(formatted).toContain('2023'); + expect(formatted).toContain('01'); + }); + }); + + describe('formatLastSeen', () => { + it('should format days ago', () => { + const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(); + expect(formatLastSeen(twoDaysAgo)).toBe('2일 전'); + }); + it('should format hours ago', () => { + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); + expect(formatLastSeen(twoHoursAgo)).toBe('2시간 전'); + }); + it('should format minutes ago', () => { + const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000).toISOString(); + expect(formatLastSeen(twoMinutesAgo)).toBe('2분 전'); + }); + }); + + describe('parseDescription', () => { + it('should parse description with colors', () => { + const desc = "Hello !"; + const parts = parseDescription(desc); + expect(parts).toHaveLength(3); + expect(parts[0].text).toBe('Hello '); + expect(parts[1].text).toBe('World'); + expect(parts[1].colorKey).toBe('red'); + expect(parts[2].text).toBe('!'); + }); + + it('should parse plain text', () => { + const parts = parseDescription("Hello World"); + expect(parts).toHaveLength(1); + expect(parts[0].text).toBe("Hello World"); + }); + }); + + describe('getSlotName & getOptionName', () => { + it('should get slot name', () => { + expect(getSlotName('NIK')).toBe(SLOT_NAMES['NIK']); + expect(getSlotName('Unknown')).toBe('Unknown'); + }); + it('should get option name', () => { + expect(getOptionName('gEXP')).toBe(OPTION_NAMES['gEXP']); + expect(getOptionName('Unknown')).toBe('Unknown'); + }); + }); + +}); diff --git a/__tests__/kkuko/ranking/components/ModeSelector.test.tsx b/__tests__/kkuko/ranking/components/ModeSelector.test.tsx new file mode 100644 index 0000000..2768eb3 --- /dev/null +++ b/__tests__/kkuko/ranking/components/ModeSelector.test.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ModeSelector } from '@/app/kkuko/ranking/components/ModeSelector'; +import { Mode } from '@/app/types/kkuko.types'; + +describe('ModeSelector', () => { + const mockModes: Mode[] = [ + { id: 'kor1', name: 'Korean Mode 1', group: 'kor', category: 'kor' }, + { id: 'eng1', name: 'English Mode 1', group: 'eng', category: 'eng' }, + { id: 'event1', name: 'Event Mode 1', group: 'event', category: 'event' }, + ]; + const mockOnModeChange = jest.fn(); + + beforeEach(() => { + mockOnModeChange.mockClear(); + }); + + it('should render trigger with selected mode', () => { + // Since Select trigger shows the selected value's label, we need to see how Radix UI Select works in test. + // Or assume the trigger text reflects selected value. + // However, we pass 'selectedMode' as a string ID. The SelectValue component displays the content of SelectItem. + // Testing Radix Select is tricky without user interaction to open it. + // But we can check if it renders. + render(); + + // The trigger usually displays the selected value. + // Depending on implementation, it might show "Korean Mode 1". + // Let's check for the existence of the select trigger. + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + + // Radix UI Select interaction test requires "pointer-events: none" handling setup in JSDOM sometimes, + // but basic click + click option usually works. +}); diff --git a/__tests__/kkuko/ranking/components/Podium.test.tsx b/__tests__/kkuko/ranking/components/Podium.test.tsx new file mode 100644 index 0000000..dc2ab36 --- /dev/null +++ b/__tests__/kkuko/ranking/components/Podium.test.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { Podium } from '@/app/kkuko/ranking/components/Podium'; +import { RankingEntry, ProfileData } from '@/app/types/kkuko.types'; +import * as api from '@/app/kkuko/shared/lib/api'; + +jest.mock('@/app/kkuko/shared/lib/api'); +jest.mock('@/app/kkuko/shared/components/ProfileAvatar', () => () =>
); + +describe('Podium', () => { + const mockEntries: RankingEntry[] = [ + { + rank: 1, + userRecord: { id: 1, userId: 'u1', modeId: 'm', win: 10, total: 10, exp: 1000, playtime: 0 }, + userInfo: { id: 'u1', nickname: 'User1', level: 10, exp: 1000, exordial: 'Hi', observedAt: '' } + }, + { + rank: 2, + userRecord: { id: 2, userId: 'u2', modeId: 'm', win: 5, total: 10, exp: 500, playtime: 0 }, + userInfo: { id: 'u2', nickname: 'User2', level: 5, exp: 500, exordial: 'Hello', observedAt: '' } + }, + { + rank: 3, + userRecord: { id: 3, userId: 'u3', modeId: 'm', win: 1, total: 10, exp: 100, playtime: 0 }, + userInfo: { id: 'u3', nickname: 'User3', level: 1, exp: 100, exordial: 'Hey', observedAt: '' } + } + ]; + + const mockProfileData: ProfileData = { + equipment: [] + } as any; + + beforeEach(() => { + (api.fetchProfile as jest.Mock).mockResolvedValue({ + data: { status: 200, data: mockProfileData } + }); + (api.fetchItems as jest.Mock).mockResolvedValue({ + data: { status: 200, data: [] } + }); + }); + + it('should calculate style correctly', () => { + // Just checking rendering to ensure no crashes + render(); + // Wait for effects + }); + + it('should render top 3 users', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('User1')).toBeInTheDocument(); + expect(screen.getByText('User2')).toBeInTheDocument(); + expect(screen.getByText('User3')).toBeInTheDocument(); + }); + }); + + it('should fetch profile data on mount', async () => { + render(); + + await waitFor(() => { + expect(api.fetchProfile).toHaveBeenCalledTimes(3); + }); + }); +}); diff --git a/__tests__/kkuko/ranking/components/RankingCard.test.tsx b/__tests__/kkuko/ranking/components/RankingCard.test.tsx new file mode 100644 index 0000000..b499c0f --- /dev/null +++ b/__tests__/kkuko/ranking/components/RankingCard.test.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { RankingCard } from '@/app/kkuko/ranking/components/RankingCard'; +import { RankingEntry } from '@/app/types/kkuko.types'; + +describe('RankingCard', () => { + const mockEntry: RankingEntry = { + rank: 1, + userRecord: { + id: 1, + userId: 'user1', + modeId: 'mode1', + win: 10, + total: 20, + exp: 1000, + playtime: 0 + }, + userInfo: { + id: 'user1', + nickname: 'User One', + level: 5, + exp: 1000, + exordial: 'Hello', + observedAt: '' + } + }; + + it('should render user info', () => { + render(); + expect(screen.getByText('User One')).toBeInTheDocument(); + // User ID is not displayed + // expect(screen.getByText('user1')).toBeInTheDocument(); + expect(screen.getByText(/Lv\.\s*5\s*•\s*Hello/)).toBeInTheDocument(); + }); + + it('should display win rate when option is win', () => { + render(); + // 10/20 = 50.0% + expect(screen.getByText('50.0%')).toBeInTheDocument(); + expect(screen.getByText('10')).toBeInTheDocument(); + expect(screen.getByText('승리')).toBeInTheDocument(); + }); + + it('should display exp when option is exp', () => { + render(); + expect(screen.getByText('1,000')).toBeInTheDocument(); + expect(screen.getByText('경험치')).toBeInTheDocument(); + }); +}); diff --git a/__tests__/kkuko/ranking/components/RankingList.test.tsx b/__tests__/kkuko/ranking/components/RankingList.test.tsx new file mode 100644 index 0000000..b7ad05b --- /dev/null +++ b/__tests__/kkuko/ranking/components/RankingList.test.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { RankingList } from '@/app/kkuko/ranking/components/RankingList'; +import { RankingEntry } from '@/app/types/kkuko.types'; + +// Mock RankingCard to avoid nested complexity +jest.mock('@/app/kkuko/ranking/components/RankingCard', () => ({ + RankingCard: ({ entry }: { entry: RankingEntry }) => ( +
{entry.userInfo.nickname}
+ ) +})); + +describe('RankingList', () => { + const mockRankings: RankingEntry[] = [ + { + rank: 1, + userRecord: { userId: '1', modeId: 'm', win: 1, total: 2, exp: 100 }, + userInfo: { id: '1', nickname: 'User1', level: 1, exp: 100, exordial: '' } + }, + { + rank: 2, + userRecord: { userId: '2', modeId: 'm', win: 1, total: 2, exp: 100 }, + userInfo: { id: '2', nickname: 'User2', level: 1, exp: 100, exordial: '' } + }, + ]; + + it('should render list of cards', () => { + render(); + const cards = screen.getAllByTestId('ranking-card'); + expect(cards).toHaveLength(2); + expect(cards[0]).toHaveTextContent('User1'); + expect(cards[1]).toHaveTextContent('User2'); + }); + + it('should render empty state when no rankings', () => { + render(); + expect(screen.getByText('랭킹 데이터가 없습니다.')).toBeInTheDocument(); + }); +}); diff --git a/__tests__/kkuko/ranking/components/RankingPagination.test.tsx b/__tests__/kkuko/ranking/components/RankingPagination.test.tsx new file mode 100644 index 0000000..86e0706 --- /dev/null +++ b/__tests__/kkuko/ranking/components/RankingPagination.test.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { RankingPagination } from '@/app/kkuko/ranking/components/RankingPagination'; + +describe('RankingPagination', () => { + const mockOnPageChange = jest.fn(); + + beforeEach(() => { + mockOnPageChange.mockClear(); + }); + + it('should render current page number', () => { + render(); + expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.getByText('페이지')).toBeInTheDocument(); + }); + + it('should disable prev button on page 1', () => { + render(); + // Usually buttons inside are found by role 'button'. + // There are 2 buttons. Prev and Next. + const buttons = screen.getAllByRole('button'); + // First button is prev + expect(buttons[0]).toBeDisabled(); + expect(buttons[1]).toBeEnabled(); + }); + + it('should call onPageChange when buttons clicked', () => { + render(); + const buttons = screen.getAllByRole('button'); + + // Click prev + fireEvent.click(buttons[0]); + expect(mockOnPageChange).toHaveBeenCalledWith(1); + + // Click next + fireEvent.click(buttons[1]); + expect(mockOnPageChange).toHaveBeenCalledWith(3); + }); + + it('should disable next button if hasNextPage is false', () => { + render(); + const buttons = screen.getAllByRole('button'); + expect(buttons[1]).toBeDisabled(); + }); +}); diff --git a/__tests__/kkuko/ranking/lib/cache.test.ts b/__tests__/kkuko/ranking/lib/cache.test.ts new file mode 100644 index 0000000..e1f2332 --- /dev/null +++ b/__tests__/kkuko/ranking/lib/cache.test.ts @@ -0,0 +1,100 @@ +import { rankingCache } from '@/app/kkuko/ranking/lib/cache'; +import { RankingEntry } from '@/app/types/kkuko.types'; + +describe('RankingCache', () => { + const mockData: RankingEntry[] = [ + { + rank: 1, + userRecord: { + id: 1, + userId: 'user1', + modeId: 'mode1', + win: 10, + total: 20, + exp: 1000, + playtime: 0 + }, + userInfo: { + id: 'user1', + nickname: 'User One', + level: 5, + exp: 1000, + exordial: 'Hello', + observedAt: '' + } + } + ]; + + const cacheKey = { + mode: 'test-mode', + page: 1, + option: 'win' as const + }; + + beforeEach(() => { + rankingCache.clear(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should return null for non-existent key', () => { + expect(rankingCache.get(cacheKey)).toBeNull(); + }); + + it('should store and retrieve data', () => { + rankingCache.set(cacheKey, mockData); + expect(rankingCache.get(cacheKey)).toEqual(mockData); + }); + + it('should return null for expired data', () => { + rankingCache.set(cacheKey, mockData); + + // Fast-forward time by 5 minutes + 1 ms (TTL is 5 minutes) + jest.advanceTimersByTime(5 * 60 * 1000 + 1); + + expect(rankingCache.get(cacheKey)).toBeNull(); + }); + + it('should return data if not expired', () => { + rankingCache.set(cacheKey, mockData); + + // Fast-forward time by 4 minutes 59 seconds + jest.advanceTimersByTime(5 * 60 * 1000 - 1000); + + expect(rankingCache.get(cacheKey)).toEqual(mockData); + }); + + it('should clear all data', () => { + rankingCache.set(cacheKey, mockData); + rankingCache.clear(); + expect(rankingCache.get(cacheKey)).toBeNull(); + }); + + it('should cleanup expired entries only', () => { + const key1 = { ...cacheKey, mode: 'mode1' }; + const key2 = { ...cacheKey, mode: 'mode2' }; + + rankingCache.set(key1, mockData); + + // Advance time partially + jest.advanceTimersByTime(2 * 60 * 1000); // 2 mins elapsed + + rankingCache.set(key2, mockData); // key2 created 2 mins late + + // Advance time to expire key1 but not key2 + // key1 age: 2min + 3min + 1ms = 5min 1ms (Expired) + // key2 age: 3min + 1ms (Valid) + jest.advanceTimersByTime(3 * 60 * 1000 + 1); + + rankingCache.cleanup(); + + // Testing internal state via public get method + // get() also checks expiry, but cleanup() should have removed it from the map internally + // We can verify behavior is correct regardless + expect(rankingCache.get(key1)).toBeNull(); + expect(rankingCache.get(key2)).toEqual(mockData); + }); +}); diff --git a/__tests__/kkuko/shared/components/ProfileAvatar.test.tsx b/__tests__/kkuko/shared/components/ProfileAvatar.test.tsx new file mode 100644 index 0000000..1be27a3 --- /dev/null +++ b/__tests__/kkuko/shared/components/ProfileAvatar.test.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import ProfileAvatar from '@/app/kkuko/shared/components/ProfileAvatar'; +import { ItemInfo, ProfileData } from '@/app/types/kkuko.types'; + +jest.mock('@/app/kkuko/shared/components/TryRenderImg', () => { + return function MockTryRenderImg(props: any) { + return {props.alt}; + }; +}); + +describe('ProfileAvatar', () => { + const mockProfileData: ProfileData = { + equipment: [ + { itemId: 'item1', slot: 'head' }, + { itemId: 'item2', slot: 'hairdeco' }, + ], + } as any; + + const mockItemsData: ItemInfo[] = [ + { id: 'item1', name: 'Cool Hat', image: 'hat.png' }, + { id: 'item2', name: 'Ribbon', image: 'ribbon.png' }, + ] as any; + + it('should render avatar layers', () => { + render(); + + // Check if layers are rendered + // Note: The logic in ProfileAvatar groups items by slot and renders them in order. + // We mock TryRenderImg so we expect to see images. + const layers = screen.getAllByTestId('avatar-layer'); + expect(layers.length).toBeGreaterThan(0); + }); +}); diff --git a/__tests__/kkuko/shared/components/TryRenderImg.test.tsx b/__tests__/kkuko/shared/components/TryRenderImg.test.tsx new file mode 100644 index 0000000..e38b4a6 --- /dev/null +++ b/__tests__/kkuko/shared/components/TryRenderImg.test.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import TryRenderImg from '@/app/kkuko/shared/components/TryRenderImg'; + +// Mock next/image +jest.mock("next/image", () => ({ src, alt, onError, onLoad, ...props }: any) => ( + // eslint-disable-next-line @next/next/no-img-element + {alt} +)); + +describe('TryRenderImg', () => { + const defaultProps = { + url: 'https://example.com/image.png', + alt: 'Test Image', + width: 100, + height: 100, + maxRetries: 2, + }; + + beforeEach(() => { + jest.clearAllMocks(); + // Date.now mock to have deterministic timestamps for src verification + jest.spyOn(Date, 'now').mockReturnValue(1234567890); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should render the image initially', () => { + render(); + const img = screen.getByTestId('next-image'); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute('src', defaultProps.url); + expect(img).toHaveAttribute('alt', defaultProps.alt); + }); + + it('should call handleLoad when image loads successfully', () => { + const handleLoad = jest.fn(); + render(); + const img = screen.getByTestId('next-image'); + + fireEvent.load(img); + + expect(handleLoad).toHaveBeenCalled(); + }); + + it('should retry loading on error up to maxRetries', () => { + render(); + const img = screen.getByTestId('next-image'); + + // First error -> retry 1 + fireEvent.error(img); + + // Expect src to have changed + // url + ?r=1&ts=... + const expectedSrc1 = `${defaultProps.url}?r=1&ts=1234567890`; + expect(img).toHaveAttribute('src', expectedSrc1); + + // Second error -> retry 2 + fireEvent.error(img); + const expectedSrc2 = `${defaultProps.url}?r=2&ts=1234567890`; + expect(img).toHaveAttribute('src', expectedSrc2); + }); + + it('should show placeholder and call onFailure after maxRetries exceeded', () => { + const onFailure = jest.fn(); + const placeholderText = "Placeholder Content"; + + render( + {placeholderText}
} + /> + ); + + const img = screen.getByTestId('next-image'); + + // Retry 1 + fireEvent.error(img); + // Retry 2 + fireEvent.error(img); + + // Final error (attempt becomes 2 (which is < maxRetries if maxRetries is 3, but here props says 2?)) + // Logic check: + // attempt starts at 0. + // handleError: if (attempt < maxRetries) { ... setAttempt(attempt + 1) ... } else { fail } + // props.maxRetries = 2. + // 1. attempt 0 < 2 -> setAttempt(1), retry. + // 2. attempt 1 < 2 -> setAttempt(2), retry. + // 3. attempt 2 is NOT < 2 -> fail. + + // So we need 3 errors to fail. + fireEvent.error(img); + + expect(onFailure).toHaveBeenCalled(); + expect(screen.queryByTestId('next-image')).not.toBeInTheDocument(); + expect(screen.getByText(placeholderText)).toBeInTheDocument(); + }); + + it('should reset state when url prop changes', () => { + const { rerender } = render(); + const img = screen.getByTestId('next-image'); + + // Trigger one error to change state + fireEvent.error(img); + expect(img).toHaveAttribute('src', expect.stringContaining('?r=1')); + + // Change URL + const newUrl = 'https://example.com/new.png'; + rerender(); + + const newImg = screen.getByTestId('next-image'); + expect(newImg).toHaveAttribute('src', newUrl); + // Should not have query params yet + expect(newImg).not.toHaveAttribute('src', expect.stringContaining('?r=')); + }); +}); diff --git a/__tests__/lib/hangulUtils.test.ts b/__tests__/lib/hangulUtils.test.ts new file mode 100644 index 0000000..41fc3d8 --- /dev/null +++ b/__tests__/lib/hangulUtils.test.ts @@ -0,0 +1,92 @@ +import { duemLaw, reverDuemLaw, isHangul } from '@/lib/hangulUtils'; + +describe('duemLaw', () => { + it('should apply duem law correctly for rule 1 (ㄹ -> ㄴ)', () => { + // Rule 1: ㄹ followed by [ㅏ,ㅐ,ㅗ,ㅚ,ㅜ,ㅡ] becomes ㄴ + expect(duemLaw('락')).toBe('낙'); + expect(duemLaw('로')).toBe('노'); + expect(duemLaw('루')).toBe('누'); + expect(duemLaw('뢰')).toBe('뇌'); + }); + + it('should apply duem law correctly for rule 2 (ㄹ -> ㅇ)', () => { + // Rule 2: ㄹ followed by [ㅑ,ㅕ,ㅖ,ㅛ,ㅠ,ㅣ] becomes ㅇ + expect(duemLaw('량')).toBe('양'); + expect(duemLaw('려')).toBe('여'); + expect(duemLaw('례')).toBe('예'); + expect(duemLaw('료')).toBe('요'); + expect(duemLaw('류')).toBe('유'); + expect(duemLaw('리')).toBe('이'); + }); + + it('should apply duem law correctly for rule 3 (ㄴ -> ㅇ)', () => { + // Rule 3: ㄴ followed by [ㅕ,ㅛ,ㅠ,ㅣ] becomes ㅇ + expect(duemLaw('녀')).toBe('여'); + expect(duemLaw('뇨')).toBe('요'); + expect(duemLaw('뉴')).toBe('유'); + expect(duemLaw('니')).toBe('이'); + }); + + it('should not change characters that do not match rules', () => { + expect(duemLaw('가')).toBe('가'); + expect(duemLaw('바')).toBe('바'); + // '나' (ㄴ + ㅏ) is not in rule 3 (only ㅕ,ㅛ,ㅠ,ㅣ) + expect(duemLaw('나')).toBe('나'); + }); + + it('should throw error for non-single character input', () => { + expect(() => duemLaw('가나')).toThrow('한글자만 입력해주세요'); + }); + + it('should return input if ignoreError is true for non-single character', () => { + expect(duemLaw('가나', true)).toBe('가나'); + }); + + it('should return input if it is not hangul', () => { + expect(duemLaw('A')).toBe('A'); + }); +}); + +describe('reverDuemLaw', () => { + it('should return original and possible precedents for ㄴ -> ㄹ (rule 1)', () => { + // '낙' (ㄴ + ㅏ) can come from '락' + expect(reverDuemLaw('낙')).toEqual(['낙', '락']); + }); + + it('should return original and possible precedents for ㅇ -> ㄹ (rule 2)', () => { + // '양' (ㅇ + ㅑ) can come from '량' + expect(reverDuemLaw('양')).toEqual(['양', '량']); + }); + + it('should return original and possible precedents for ㅇ -> ㄴ (rule 3)', () => { + // '여' (ㅇ + ㅕ) can come from '녀' + expect(reverDuemLaw('여')).toEqual(['여', '려', '녀']); + }); + + it('should return only the input when no reversal applies', () => { + expect(reverDuemLaw('가')).toEqual(['가']); + }); + + it('should throw error for non-single character input', () => { + expect(() => reverDuemLaw('가나')).toThrow('한글자만 입력해주세요'); + }); + + it('should return input array if ignoreError is true for non-single character', () => { + expect(reverDuemLaw('가나', true)).toEqual(['가나']); + }); +}); + +describe('isHangul', () => { + it('should return true for Hangul characters', () => { + expect(isHangul('가')).toBe(true); + expect(isHangul('한글')).toBe(true); + expect(isHangul('ㄱ')).toBe(true); + }); + + it('should return false for non-Hangul characters', () => { + expect(isHangul('A')).toBe(false); + expect(isHangul('123')).toBe(false); + expect(isHangul('!@#')).toBe(false); + expect(isHangul('가A')).toBe(false); + }); +}); \ No newline at end of file diff --git a/__tests__/manager-tool/extract/Loop.test.tsx b/__tests__/manager-tool/extract/Loop.test.tsx index 1c4cd89..4cb390a 100644 --- a/__tests__/manager-tool/extract/Loop.test.tsx +++ b/__tests__/manager-tool/extract/Loop.test.tsx @@ -25,7 +25,7 @@ jest.mock("@/app/manager-tool/extract/components/FileContentDisplay", () => { }); // DuemLaw 함수 모킹 -jest.mock("@/app/lib/DuemLaw", () => { +jest.mock("@/app/lib/hangulUtils", () => { return jest.fn((char: string) => { // 두음법칙 매핑 const duemMap: { [key: string]: string } = { diff --git a/__tests__/mini-game/MobileUnsupported.test.tsx b/__tests__/mini-game/MobileUnsupported.test.tsx new file mode 100644 index 0000000..be5c80f --- /dev/null +++ b/__tests__/mini-game/MobileUnsupported.test.tsx @@ -0,0 +1,44 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import MobileUnsupported from '@/app/mini-game/MobileUnsupported'; +import { useRouter } from 'next/navigation'; + +// Mock next/navigation +jest.mock('next/navigation', () => ({ + useRouter: jest.fn(), +})); + +describe('MobileUnsupported', () => { + const mockBack = jest.fn(); + + beforeEach(() => { + (useRouter as jest.Mock).mockReturnValue({ + back: mockBack, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders the unsupported message correctly', () => { + render(); + + expect(screen.getByText('PC 환경에서 이용해주세요')).toBeInTheDocument(); + expect(screen.getByText(/이 게임은 모바일 환경을 지원하지 않습니다/)).toBeInTheDocument(); + }); + + it('calls router.back() when back button is clicked', () => { + render(); + + const backButton = screen.getByRole('button', { name: /뒤로가기/ }); + fireEvent.click(backButton); + + expect(mockBack).toHaveBeenCalledTimes(1); + }); + + it('has the correct CSS classes for mobile visibility', () => { + const { container } = render(); + // The outer div should have md:hidden + expect(container.firstChild).toHaveClass('md:hidden'); + }); +}); diff --git a/__tests__/mini-game/game/Game.test.tsx b/__tests__/mini-game/game/Game.test.tsx new file mode 100644 index 0000000..ece0954 --- /dev/null +++ b/__tests__/mini-game/game/Game.test.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import Game from '@/app/mini-game/game/Game'; +import { useGameState } from '@/app/mini-game/game/hooks/useGameState'; +import { soundManager } from '@/app/mini-game/game/lib/SoundManager'; + +jest.mock('@/app/mini-game/game/hooks/useGameState'); +jest.mock('@/app/mini-game/game/lib/SoundManager'); +jest.mock('@/app/mini-game/game/hooks/useChat', () => ({ + ChatProvider: ({ children }: any) =>
{children}
+})); +jest.mock('@/app/mini-game/game/components/KkutuMenu', () => () =>
Menu
); +jest.mock('@/app/mini-game/game/GameBox', () => ({ children }: any) =>
{children}
); +jest.mock('@/app/mini-game/game/GameBody', () => () =>
Head
); +jest.mock('@/app/mini-game/game/GameSetup', () => () =>
Setup
); +jest.mock('@/app/mini-game/game/GameChat', () => () =>
Chat
); + +describe('Game', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render setup when not playing', () => { + (useGameState as unknown as jest.Mock).mockReturnValue({ isPlaying: false }); + render(); + + expect(soundManager.load).toHaveBeenCalled(); + expect(screen.getByTestId('kkutu-menu')).toBeInTheDocument(); + expect(screen.getByTestId('game-setup')).toBeInTheDocument(); + expect(screen.getByTestId('game-chat')).toBeInTheDocument(); + expect(screen.queryByTestId('game-box')).not.toBeInTheDocument(); + }); + + it('should render game box when playing', () => { + (useGameState as unknown as jest.Mock).mockReturnValue({ isPlaying: true }); + render(); + + expect(screen.getByTestId('game-box')).toBeInTheDocument(); + expect(screen.getByTestId('game-body')).toBeInTheDocument(); + expect(screen.queryByTestId('game-setup')).not.toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/__tests__/mini-game/game/GameBody.test.tsx b/__tests__/mini-game/game/GameBody.test.tsx new file mode 100644 index 0000000..88877b4 --- /dev/null +++ b/__tests__/mini-game/game/GameBody.test.tsx @@ -0,0 +1,137 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import GameHead from '@/app/mini-game/game/GameBody'; +import { useChat } from '@/app/mini-game/game/hooks/useChat'; +import { useGameLogic } from '@/app/mini-game/game/hooks/useGameLogic'; + +jest.mock('@/app/mini-game/game/hooks/useChat'); +jest.mock('@/app/mini-game/game/hooks/useGameLogic'); +jest.mock('@/app/mini-game/game/components/HistoryHolder', () => () =>
History
); +jest.mock('@/app/mini-game/game/components/GameInput', () => ({ value, onChange, onKeyDown }: any) => ( + +)); +jest.mock('@/app/mini-game/game/components/GraphBar', () => ({ label }: any) =>
{label}
); +jest.mock('@/app/mini-game/game/components/GameResultModal', () => () =>
Result
); + +describe('GameHead', () => { + const mockHandleChatInputChange = jest.fn(); + const mockSendHint = jest.fn(); + const mockSetChatInput = jest.fn(); + const mockRegisterGameHandleInput = jest.fn(); + const mockSetGameInputVisible = jest.fn(); + const mockHandleInput = jest.fn(); + const mockCloseGameResult = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (useChat as jest.Mock).mockReturnValue({ + chatInput: '', + handleChatInputChange: mockHandleChatInputChange, + sendHint: mockSendHint, + setChatInput: mockSetChatInput, + registerGameHandleInput: mockRegisterGameHandleInput, + setGameInputVisible: mockSetGameInputVisible, + }); + (useGameLogic as jest.Mock).mockReturnValue({ + word: '가방', + isFail: false, + chainCount: 5, + turnTime: 3.5, + roundTime: 50.0, + missionChar: '가', + historyItems: [], + inputVisible: true, + turnInstant: false, + animatingWord: null, + visibleChars: [], + pulseOn: false, + inputRef: { current: null }, + handleInput: mockHandleInput, + TURN_TIME_LIMIT: 5, + ROUND_TIME_LIMIT: 60, + hintVisible: false, + gameResult: null, + closeGameResult: mockCloseGameResult, + }); + }); + + it('should render correctly', () => { + render(); + expect(screen.getByText('가방')).toBeInTheDocument(); + expect(screen.getByText('가')).toBeInTheDocument(); // Mission char + expect(screen.getByText('5')).toBeInTheDocument(); // Chain count + expect(screen.getByText('3.5초')).toBeInTheDocument(); // Turn time + expect(screen.getByText('50.0초')).toBeInTheDocument(); // Round time + expect(screen.getByTestId('history-holder')).toBeInTheDocument(); + expect(screen.getByTestId('game-input')).toBeInTheDocument(); + }); + + it('should handle input change', () => { + render(); + const input = screen.getByTestId('game-input'); + fireEvent.change(input, { target: { value: 'test' } }); + expect(mockHandleChatInputChange).toHaveBeenCalled(); + }); + + it('should handle enter key', () => { + (useChat as jest.Mock).mockReturnValue({ + chatInput: 'word', + handleChatInputChange: mockHandleChatInputChange, + sendHint: mockSendHint, + setChatInput: mockSetChatInput, + registerGameHandleInput: mockRegisterGameHandleInput, + setGameInputVisible: mockSetGameInputVisible, + }); + render(); + const input = screen.getByTestId('game-input'); + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }); + expect(mockHandleInput).toHaveBeenCalledWith('word'); + }); + + it('should handle hint command', () => { + (useChat as jest.Mock).mockReturnValue({ + chatInput: '/ㅍ', + handleChatInputChange: mockHandleChatInputChange, + sendHint: mockSendHint, + setChatInput: mockSetChatInput, + registerGameHandleInput: mockRegisterGameHandleInput, + setGameInputVisible: mockSetGameInputVisible, + }); + render(); + const input = screen.getByTestId('game-input'); + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }); + expect(mockSendHint).toHaveBeenCalled(); + expect(mockSetChatInput).toHaveBeenCalledWith(''); + }); + + it('should show game result modal when gameResult is present', () => { + (useGameLogic as jest.Mock).mockReturnValue({ + word: '가방', + isFail: false, + chainCount: 5, + turnTime: 3.5, + roundTime: 50.0, + missionChar: '가', + historyItems: [], + inputVisible: true, + turnInstant: false, + animatingWord: null, + visibleChars: [], + pulseOn: false, + inputRef: { current: null }, + handleInput: mockHandleInput, + TURN_TIME_LIMIT: 5, + ROUND_TIME_LIMIT: 60, + hintVisible: false, + gameResult: [], + closeGameResult: mockCloseGameResult, + }); + render(); + expect(screen.getByTestId('game-result-modal')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/__tests__/mini-game/game/GameBox.test.tsx b/__tests__/mini-game/game/GameBox.test.tsx new file mode 100644 index 0000000..e1d89b0 --- /dev/null +++ b/__tests__/mini-game/game/GameBox.test.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import GameBox from '@/app/mini-game/game/GameBox'; + +describe('GameBox', () => { + it('should render children', () => { + render(
Child
); + expect(screen.getByText('Child')).toBeInTheDocument(); + }); + + it('should have background image', () => { + const { container } = render(
Child
); + expect(container.firstChild).toHaveClass("bg-[url('/img/gamebg.png')]"); + }); +}); \ No newline at end of file diff --git a/__tests__/mini-game/game/GameChat.test.tsx b/__tests__/mini-game/game/GameChat.test.tsx new file mode 100644 index 0000000..c003cd1 --- /dev/null +++ b/__tests__/mini-game/game/GameChat.test.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import KkutuChat from '@/app/mini-game/game/GameChat'; +import { useChat } from '@/app/mini-game/game/hooks/useChat'; +import { useChatLog } from '@/app/mini-game/game/hooks/useChatLog'; + +jest.mock('@/app/mini-game/game/hooks/useChat'); +jest.mock('@/app/mini-game/game/hooks/useChatLog'); + +describe('KkutuChat', () => { + const mockHandleChatInputChange = jest.fn(); + const mockCallGameInput = jest.fn(); + const mockHandleSendMessage = jest.fn(); + const mockSendHint = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (useChat as jest.Mock).mockReturnValue({ + chatInput: '', + handleChatInputChange: mockHandleChatInputChange, + callGameInput: mockCallGameInput, + gameInputVisible: false, + }); + (useChatLog as jest.Mock).mockReturnValue({ + messages: [ + { id: 1, timestamp: '12:00', username: 'User1', message: 'Hello', isNotice: false }, + { id: 2, timestamp: '12:01', username: 'System', message: 'Welcome', isNotice: true }, + ], + chatRef: { current: null }, + handleSendMessage: mockHandleSendMessage, + sendHint: mockSendHint, + }); + }); + + it('should render correctly', () => { + render(); + expect(screen.getByText('Hello')).toBeInTheDocument(); + expect(screen.getByText('Welcome')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('메시지를 입력하세요...')).toBeInTheDocument(); + }); + + it('should handle input change', () => { + render(); + const input = screen.getByPlaceholderText('메시지를 입력하세요...'); + fireEvent.change(input, { target: { value: 'test' } }); + expect(mockHandleChatInputChange).toHaveBeenCalled(); + }); + + it('should handle send message on button click', () => { + render(); + const button = screen.getByRole('button'); // Send button + fireEvent.click(button); + expect(mockHandleSendMessage).toHaveBeenCalled(); + }); + + it('should handle send message on Enter key when game input is not visible', () => { + render(); + const input = screen.getByPlaceholderText('메시지를 입력하세요...'); + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }); + expect(mockHandleSendMessage).toHaveBeenCalled(); + }); + + it('should call game input on Enter key when game input is visible', () => { + (useChat as jest.Mock).mockReturnValue({ + chatInput: 'word', + handleChatInputChange: mockHandleChatInputChange, + callGameInput: mockCallGameInput, + gameInputVisible: true, + }); + render(); + const input = screen.getByPlaceholderText('메시지를 입력하세요...'); + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }); + expect(mockCallGameInput).toHaveBeenCalledWith('word'); + expect(mockHandleSendMessage).not.toHaveBeenCalled(); + }); + + it('should send hint on Enter key when input is /ㅍ', () => { + (useChat as jest.Mock).mockReturnValue({ + chatInput: '/ㅍ', + handleChatInputChange: mockHandleChatInputChange, + callGameInput: mockCallGameInput, + gameInputVisible: true, + }); + render(); + const input = screen.getByPlaceholderText('메시지를 입력하세요...'); + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }); + expect(mockSendHint).toHaveBeenCalled(); + expect(mockCallGameInput).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/__tests__/mini-game/game/GameSetup.test.tsx b/__tests__/mini-game/game/GameSetup.test.tsx new file mode 100644 index 0000000..05aee5c --- /dev/null +++ b/__tests__/mini-game/game/GameSetup.test.tsx @@ -0,0 +1,189 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor, act, within } from '@testing-library/react'; +import GameSetup from '@/app/mini-game/game/GameSetup'; +import * as wordDB from '@/app/mini-game/game/lib/wordDB'; +import gameManager from '@/app/mini-game/game/lib/GameManager'; + +jest.mock('@/app/mini-game/game/lib/wordDB'); +jest.mock('@/app/mini-game/game/lib/GameManager'); +jest.mock('@/app/mini-game/game/lib/SoundManager'); +jest.mock('@/app/mini-game/game/components/WordManagerModal', () => ({ onClose }: any) => ( +
+)); +jest.mock('@/app/mini-game/game/components/ConfirmModal', () => ({ onConfirm, onCancel }: any) => ( +
+ + +
+)); +jest.mock('@/app/mini-game/game/components/StartCharModal', () => ({ onClose, onSave }: any) => ( +
+ + +
+)); + +describe('GameSetup', () => { + const mockUpdateSetting = jest.fn(); + const mockLoadWordDB = jest.fn(); + const mockGameStart = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock localStorage + const localStorageMock = (function() { + let store: any = {}; + return { + getItem: jest.fn((key) => store[key] || null), + setItem: jest.fn((key, value) => { + store[key] = value.toString(); + }), + clear: jest.fn(() => { + store = {}; + }), + removeItem: jest.fn((key) => { + delete store[key]; + }) + }; + })(); + Object.defineProperty(window, 'localStorage', { value: localStorageMock, writable: true }); + + // Mock GameManager + (gameManager.getSetting as jest.Mock).mockReturnValue({ + roundTime: 60000, + notAgainSameChar: false, + lang: 'ko', + mode: 'normal', + hintMode: 'auto', + wantStartChar: new Set(['가']), + }); + gameManager.updateSetting = mockUpdateSetting; + gameManager.loadWordDB = mockLoadWordDB; + gameManager.gameStart = mockGameStart; + gameManager.clearDB = jest.fn(); + + // Mock wordDB + (wordDB.hasWords as jest.Mock).mockResolvedValue(false); + (wordDB.getAllWords as jest.Mock).mockResolvedValue([]); + (wordDB.loadWordsFromFile as jest.Mock).mockResolvedValue(100); + (wordDB.clearAllWords as jest.Mock).mockResolvedValue(undefined); + }); + + it('should render correctly', async () => { + await act(async () => { + render(); + }); + expect(screen.getByText(/게임 설정/i)).toBeInTheDocument(); + expect(screen.getByText(/단어 데이터베이스 설정/i)).toBeInTheDocument(); + }); + + it('should load existing words on mount', async () => { + (wordDB.hasWords as jest.Mock).mockResolvedValue(true); + (wordDB.getAllWords as jest.Mock).mockResolvedValue([{ word: '사과', theme: 'fruit' }]); + + await act(async () => { + render(); + }); + + await waitFor(() => { + expect(wordDB.hasWords).toHaveBeenCalled(); + expect(wordDB.getAllWords).toHaveBeenCalled(); + expect(mockLoadWordDB).toHaveBeenCalled(); + expect(screen.getByText(/저장된 단어가 1개 있습니다/i)).toBeInTheDocument(); + }); + }); + + it('should handle file upload', async () => { + await act(async () => { + render(); + }); + + const file = new File(['test'], 'test.txt', { type: 'text/plain' }); + const input = screen.getByLabelText(/단어 파일 업로드/i); + + await act(async () => { + fireEvent.change(input, { target: { files: [file] } }); + }); + + expect(screen.getByText(/선택된 파일: test.txt/i)).toBeInTheDocument(); + + const uploadButton = screen.getByText('단어 불러오기'); + await act(async () => { + fireEvent.click(uploadButton); + }); + + await waitFor(() => { + expect(wordDB.loadWordsFromFile).toHaveBeenCalledWith(file); + expect(screen.getByText(/100개의 단어를 성공적으로 불러왔습니다/i)).toBeInTheDocument(); + }); + }); + + it('should handle setting changes', async () => { + await act(async () => { + render(); + }); + + // Find select by display value since label is not associated + const roundTimeSelect = screen.getByDisplayValue('60초'); + fireEvent.change(roundTimeSelect, { target: { value: '30' } }); + + expect(mockUpdateSetting).toHaveBeenCalledWith(expect.objectContaining({ + roundTime: 30000 + })); + }); + + it('should open and close word manager modal', async () => { + await act(async () => { + render(); + }); + + fireEvent.click(screen.getByText(/단어 목록 조회/i)); + const modal = screen.getByTestId('word-manager-modal'); + expect(modal).toBeInTheDocument(); + + fireEvent.click(within(modal).getByText('Close')); + expect(screen.queryByTestId('word-manager-modal')).not.toBeInTheDocument(); + }); + + it('should handle clear words', async () => { + (wordDB.hasWords as jest.Mock).mockResolvedValue(true); + (wordDB.getAllWords as jest.Mock).mockResolvedValue([{ word: '사과', theme: 'fruit' }]); + + await act(async () => { + render(); + }); + + await waitFor(() => { + expect(screen.getByText('모든 단어 삭제')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('모든 단어 삭제')); + expect(screen.getByTestId('confirm-modal')).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByText('Confirm')); + }); + + expect(wordDB.clearAllWords).toHaveBeenCalled(); + expect(gameManager.clearDB).toHaveBeenCalled(); + expect(screen.getByText('모든 단어가 삭제되었습니다.')).toBeInTheDocument(); + }); + + it('should handle start char modal', async () => { + await act(async () => { + render(); + }); + + fireEvent.click(screen.getByRole('button', { name: '제시어 설정' })); + expect(screen.getByTestId('start-char-modal')).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByText('Save')); + }); + + expect(mockUpdateSetting).toHaveBeenCalledWith(expect.objectContaining({ + wantStartChar: new Set(['가', '나', '다']) + })); + }); +}); \ No newline at end of file diff --git a/__tests__/mini-game/game/components/ConfirmModal.test.tsx b/__tests__/mini-game/game/components/ConfirmModal.test.tsx new file mode 100644 index 0000000..b83e5aa --- /dev/null +++ b/__tests__/mini-game/game/components/ConfirmModal.test.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import ConfirmModal from '@/app/mini-game/game/components/ConfirmModal'; + +describe('ConfirmModal', () => { + it('should render message', () => { + render( {}} onCancel={() => {}} />); + expect(screen.getByText('Are you sure?')).toBeInTheDocument(); + }); + + it('should call onConfirm when confirm button is clicked', () => { + const onConfirm = jest.fn(); + render( {}} />); + // Use regex or exact match depending on button text + fireEvent.click(screen.getByRole('button', { name: '확인' })); + expect(onConfirm).toHaveBeenCalled(); + }); + + it('should call onCancel when cancel button is clicked', () => { + const onCancel = jest.fn(); + render( {}} onCancel={onCancel} />); + fireEvent.click(screen.getByRole('button', { name: '취소' })); + expect(onCancel).toHaveBeenCalled(); + }); + + it('should call onCancel when clicking backdrop', () => { + const onCancel = jest.fn(); + const { container } = render( {}} onCancel={onCancel} />); + // The backdrop is the outer div + fireEvent.click(container.firstChild as Element); + expect(onCancel).toHaveBeenCalled(); + }); + + it('should not call onCancel when clicking modal content', () => { + const onCancel = jest.fn(); + render( {}} onCancel={onCancel} />); + // Click on the message div which is inside the modal + fireEvent.click(screen.getByText('Test')); + expect(onCancel).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/__tests__/mini-game/game/components/DictionaryModal.test.tsx b/__tests__/mini-game/game/components/DictionaryModal.test.tsx new file mode 100644 index 0000000..86adbbe --- /dev/null +++ b/__tests__/mini-game/game/components/DictionaryModal.test.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import DictionaryModal from '@/app/mini-game/game/components/DictionaryModal'; +import gameManager from '@/app/mini-game/game/lib/GameManager'; + +jest.mock('@/app/mini-game/game/lib/GameManager'); + +describe('DictionaryModal', () => { + it('should render correctly', () => { + render( {}} />); + expect(screen.getByText('사전 검색')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('단어를 입력하세요')).toBeInTheDocument(); + }); + + it('should search for a word', () => { + (gameManager.getWordTheme as jest.Mock).mockReturnValue(['자유']); + render( {}} />); + + const input = screen.getByPlaceholderText('단어를 입력하세요'); + fireEvent.change(input, { target: { value: '가방' } }); + fireEvent.click(screen.getByText('검색')); + + expect(gameManager.getWordTheme).toHaveBeenCalledWith('가방'); + expect(screen.getByText('주제: 자유')).toBeInTheDocument(); + }); + + it('should show message for unknown word', () => { + (gameManager.getWordTheme as jest.Mock).mockReturnValue([]); + render( {}} />); + + const input = screen.getByPlaceholderText('단어를 입력하세요'); + fireEvent.change(input, { target: { value: '없는단어' } }); + fireEvent.click(screen.getByText('검색')); + + expect(screen.getByText('없는 단어입니다')).toBeInTheDocument(); + }); + + it('should handle enter key', () => { + (gameManager.getWordTheme as jest.Mock).mockReturnValue(['자유']); + render( {}} />); + + const input = screen.getByPlaceholderText('단어를 입력하세요'); + fireEvent.change(input, { target: { value: '가방' } }); + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }); + + expect(gameManager.getWordTheme).toHaveBeenCalledWith('가방'); + }); + + it('should close when close button is clicked', () => { + const onClose = jest.fn(); + render(); + fireEvent.click(screen.getByText('닫기')); + expect(onClose).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/__tests__/mini-game/game/components/GameInput.test.tsx b/__tests__/mini-game/game/components/GameInput.test.tsx new file mode 100644 index 0000000..a140c17 --- /dev/null +++ b/__tests__/mini-game/game/components/GameInput.test.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import GameInput from '@/app/mini-game/game/components/GameInput'; + +describe('GameInput', () => { + it('should render correctly', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input).toBeInTheDocument(); + expect(input).toHaveAttribute('placeholder', 'Your turn - Input chat'); + }); + + it('should handle value change', () => { + const handleChange = jest.fn(); + render(); + const input = screen.getByRole('textbox'); + expect(input).toHaveValue('test'); + fireEvent.change(input, { target: { value: 'new value' } }); + expect(handleChange).toHaveBeenCalled(); + }); + + it('should handle key down', () => { + const handleKeyDown = jest.fn(); + render(); + const input = screen.getByRole('textbox'); + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }); + expect(handleKeyDown).toHaveBeenCalled(); + }); + + it('should apply custom class name', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('custom-class'); + }); + + it('should be readonly when readonly prop is true', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input).toHaveAttribute('readonly'); + }); + + it('should forward ref', () => { + const ref = React.createRef(); + render(); + expect(ref.current).toBeInstanceOf(HTMLInputElement); + }); +}); \ No newline at end of file diff --git a/__tests__/mini-game/game/components/GameResultModal.test.tsx b/__tests__/mini-game/game/components/GameResultModal.test.tsx new file mode 100644 index 0000000..2d31eff --- /dev/null +++ b/__tests__/mini-game/game/components/GameResultModal.test.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import GameResultModal from '@/app/mini-game/game/components/GameResultModal'; + +describe('GameResultModal', () => { + const mockUsedWords = [ + { char: '가', word: '가방', missionChar: null, useHintCount: 0 }, + { char: '방', word: '방구', missionChar: '구', useHintCount: 1 }, + { char: '구', word: '구슬', missionChar: null, useHintCount: 0, isFailed: true } + ]; + + it('should render correctly', () => { + render( {}} />); + expect(screen.getByText('🎮 게임 결과')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); // Total count + expect(screen.getByText('가방')).toBeInTheDocument(); + expect(screen.getByText('방구')).toBeInTheDocument(); + expect(screen.getByText('구슬')).toBeInTheDocument(); + expect(screen.getByText('미션: 구')).toBeInTheDocument(); + expect(screen.getByText('힌트 1회')).toBeInTheDocument(); + expect(screen.getByText('입력 실패')).toBeInTheDocument(); + }); + + it('should handle minimize/maximize', () => { + render( {}} />); + + // Minimize + const minimizeBtn = screen.getByTitle('최소화'); + fireEvent.click(minimizeBtn); + + expect(screen.queryByText('가방')).not.toBeInTheDocument(); + expect(screen.getByText('총 3개의 단어 사용')).toBeInTheDocument(); + + // Maximize + const maximizeBtn = screen.getByTitle('펼치기'); + fireEvent.click(maximizeBtn); + + expect(screen.getByText('가방')).toBeInTheDocument(); + }); + + it('should close when close button is clicked', () => { + const onClose = jest.fn(); + render(); + fireEvent.click(screen.getByTitle('닫기')); + expect(onClose).toHaveBeenCalled(); + }); + + it('should show empty message if no words used', () => { + render( {}} />); + expect(screen.getByText('사용한 단어가 없습니다.')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/__tests__/mini-game/game/components/GraphBar.test.tsx b/__tests__/mini-game/game/components/GraphBar.test.tsx new file mode 100644 index 0000000..53ed6d8 --- /dev/null +++ b/__tests__/mini-game/game/components/GraphBar.test.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import GraphBar from '@/app/mini-game/game/components/GraphBar'; + +describe('GraphBar', () => { + it('should render correctly', () => { + render(); + const bar = screen.getByText('50%'); + expect(bar).toBeInTheDocument(); + expect(bar).toHaveStyle('width: 50%'); + }); + + it('should handle custom background color', () => { + render(); + const innerDiv = screen.getByText('test'); + expect(innerDiv.style.backgroundColor).toBe('blue'); + }); + + it('should clamp percentage between 0 and 100', () => { + const { rerender } = render(); + expect(screen.getByText('Low')).toHaveStyle({ width: '0%' }); + + rerender(); + expect(screen.getByText('High')).toHaveStyle({ width: '100%' }); + }); + + it('should handle noTransition prop', () => { + render(); + const innerDiv = screen.getByText('test-trans'); + expect(innerDiv).toHaveStyle({ transition: 'none' }); + }); +}); \ No newline at end of file diff --git a/__tests__/mini-game/game/components/HelpModal.test.tsx b/__tests__/mini-game/game/components/HelpModal.test.tsx new file mode 100644 index 0000000..1d55d94 --- /dev/null +++ b/__tests__/mini-game/game/components/HelpModal.test.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import HelpModal from '@/app/mini-game/game/components/HelpModal'; + +describe('HelpModal', () => { + it('should render correctly', () => { + render( {}} />); + expect(screen.getByText('📖 도움말')).toBeInTheDocument(); + expect(screen.getByText('🎮 게임시작 관련')).toBeInTheDocument(); + expect(screen.getByText('⚙️ 설정 관련')).toBeInTheDocument(); + expect(screen.getByText('🎯 게임중/종료 관련')).toBeInTheDocument(); + expect(screen.getByText('💡 기타 도움말')).toBeInTheDocument(); + }); + + it('should close when close button is clicked', () => { + const onClose = jest.fn(); + render(); + + // Header button (×) + const headerClose = screen.getByText('×'); + fireEvent.click(headerClose); + expect(onClose).toHaveBeenCalledTimes(1); + + // Footer button + const footerClose = screen.getByText('닫기'); + fireEvent.click(footerClose); + expect(onClose).toHaveBeenCalledTimes(2); + }); + + it('should close when clicking backdrop', () => { + const onClose = jest.fn(); + const { container } = render(); + fireEvent.click(container.firstChild as Element); + expect(onClose).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/__tests__/mini-game/game/components/HistoryHolder.test.tsx b/__tests__/mini-game/game/components/HistoryHolder.test.tsx new file mode 100644 index 0000000..77ac004 --- /dev/null +++ b/__tests__/mini-game/game/components/HistoryHolder.test.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { render, screen, fireEvent, act, within } from '@testing-library/react'; +import HistoryHolder from '@/app/mini-game/game/components/HistoryHolder'; + +describe('HistoryHolder', () => { + const mockItems = [ + { word: '사과', meaning: '사과나무의 열매', theme: ['fruit'], wordType: 'm1' as const }, + { word: '바나나', meaning: '바나나나무의 열매', theme: ['fruit'], wordType: 'm2' as const }, + ]; + + it('should render correctly', () => { + render(); + expect(screen.getByText('사과')).toBeInTheDocument(); + expect(screen.getByText('바나나')).toBeInTheDocument(); + }); + + it('should show tooltip on hover', async () => { + render(); + const item = screen.getByText('사과'); + + await act(async () => { + fireEvent.mouseEnter(item, { clientX: 100, clientY: 100 }); + }); + + // Tooltip content + const tooltipRoot = document.getElementById('history-tooltip-root'); + expect(tooltipRoot).toBeInTheDocument(); + + // Use within to check inside the tooltip + const tooltipContent = within(tooltipRoot!).getByText('사과나무의 열매'); + expect(tooltipContent).toBeInTheDocument(); + + await act(async () => { + fireEvent.mouseLeave(item); + }); + + expect(within(tooltipRoot!).queryByText('사과나무의 열매')).not.toBeInTheDocument(); + }); + + it('should handle empty items', () => { + const { container } = render(); + expect(container.firstChild).toBeInTheDocument(); + expect(screen.queryByText('사과')).not.toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/__tests__/mini-game/game/components/KkutuMenu.test.tsx b/__tests__/mini-game/game/components/KkutuMenu.test.tsx new file mode 100644 index 0000000..74d834e --- /dev/null +++ b/__tests__/mini-game/game/components/KkutuMenu.test.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import KkutuMenu from '@/app/mini-game/game/components/KkutuMenu'; +import { useGameState } from '@/app/mini-game/game/hooks/useGameState'; + +jest.mock('@/app/mini-game/game/hooks/useGameState'); +jest.mock('@/app/mini-game/game/components/HelpModal', () => () =>
Help Modal
); +jest.mock('@/app/mini-game/game/components/SettingsModal', () => () =>
Settings Modal
); +jest.mock('@/app/mini-game/game/components/DictionaryModal', () => () =>
Dictionary Modal
); +jest.mock('@/app/mini-game/game/components/ConfirmModal', () => ({ message, onConfirm }: any) => ( +
+ {message} + +
+)); + +describe('KkutuMenu', () => { + const mockRequestStart = jest.fn(); + const mockExitGame = jest.fn(); + const mockDismissStartBlocked = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (useGameState as unknown as jest.Mock).mockReturnValue({ + isPlaying: false, + requestStart: mockRequestStart, + exitGame: mockExitGame, + startBlocked: false, + startBlockedMessage: null, + dismissStartBlocked: mockDismissStartBlocked, + }); + }); + + it('should render buttons correctly when not playing', () => { + render(); + expect(screen.getByText('도움말')).toBeInTheDocument(); + expect(screen.getByText('설정')).toBeInTheDocument(); + expect(screen.getByText('사전')).toBeInTheDocument(); + expect(screen.getByText('시작')).toBeInTheDocument(); + expect(screen.queryByText('나가기')).not.toBeInTheDocument(); + }); + + it('should render buttons correctly when playing', () => { + (useGameState as unknown as jest.Mock).mockReturnValue({ + isPlaying: true, + requestStart: mockRequestStart, + exitGame: mockExitGame, + startBlocked: false, + startBlockedMessage: null, + dismissStartBlocked: mockDismissStartBlocked, + }); + render(); + expect(screen.queryByText('시작')).not.toBeInTheDocument(); + expect(screen.getByText('나가기')).toBeInTheDocument(); + }); + + it('should open help modal', () => { + render(); + fireEvent.click(screen.getByText('도움말')); + expect(screen.getByTestId('help-modal')).toBeInTheDocument(); + }); + + it('should open settings modal', () => { + render(); + fireEvent.click(screen.getByText('설정')); + expect(screen.getByTestId('settings-modal')).toBeInTheDocument(); + }); + + it('should open dictionary modal', () => { + render(); + fireEvent.click(screen.getByText('사전')); + expect(screen.getByTestId('dict-modal')).toBeInTheDocument(); + }); + + it('should call requestStart when start button is clicked', () => { + render(); + fireEvent.click(screen.getByText('시작')); + expect(mockRequestStart).toHaveBeenCalled(); + }); + + it('should call exitGame when exit button is clicked', () => { + (useGameState as unknown as jest.Mock).mockReturnValue({ + isPlaying: true, + requestStart: mockRequestStart, + exitGame: mockExitGame, + startBlocked: false, + startBlockedMessage: null, + dismissStartBlocked: mockDismissStartBlocked, + }); + render(); + fireEvent.click(screen.getByText('나가기')); + expect(mockExitGame).toHaveBeenCalled(); + }); + + it('should show confirm modal when start is blocked', () => { + (useGameState as unknown as jest.Mock).mockReturnValue({ + isPlaying: false, + requestStart: mockRequestStart, + exitGame: mockExitGame, + startBlocked: true, + startBlockedMessage: 'Blocked', + dismissStartBlocked: mockDismissStartBlocked, + }); + render(); + expect(screen.getByTestId('confirm-modal')).toBeInTheDocument(); + expect(screen.getByText('Blocked')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Confirm')); + expect(mockDismissStartBlocked).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/__tests__/mini-game/game/components/SettingsModal.test.tsx b/__tests__/mini-game/game/components/SettingsModal.test.tsx new file mode 100644 index 0000000..4380717 --- /dev/null +++ b/__tests__/mini-game/game/components/SettingsModal.test.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import SettingsModal from '@/app/mini-game/game/components/SettingsModal'; +import { soundManager } from '@/app/mini-game/game/lib/SoundManager'; + +jest.mock('@/app/mini-game/game/lib/SoundManager'); + +describe('SettingsModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + Object.defineProperty(window, 'localStorage', { + value: { + getItem: jest.fn().mockReturnValue(null), + setItem: jest.fn(), + }, + writable: true + }); + }); + + it('should render correctly', () => { + render( {}} />); + expect(screen.getByText('설정')).toBeInTheDocument(); + expect(screen.getByText('효과음/배경음 볼륨')).toBeInTheDocument(); + }); + + it('should load volume from localStorage', () => { + (window.localStorage.getItem as jest.Mock).mockReturnValue('50'); + render( {}} />); + expect(screen.getByText('50%')).toBeInTheDocument(); + }); + + it('should change volume', () => { + render( {}} />); + const slider = screen.getByRole('slider'); + fireEvent.change(slider, { target: { value: '80' } }); + expect(screen.getByText('80%')).toBeInTheDocument(); + expect(soundManager.setAllVolume).toHaveBeenCalledWith(0.8); + }); + + it('should save volume and close', () => { + const onClose = jest.fn(); + render(); + const slider = screen.getByRole('slider'); + fireEvent.change(slider, { target: { value: '80' } }); + + fireEvent.click(screen.getByText('저장')); + + expect(window.localStorage.setItem).toHaveBeenCalledWith('kkutuVolume', '80'); + expect(soundManager.setAllVolume).toHaveBeenCalledWith(0.8); + expect(onClose).toHaveBeenCalled(); + }); + + it('should close without saving when cancel is clicked', () => { + const onClose = jest.fn(); + render(); + fireEvent.click(screen.getByText('취소')); + expect(onClose).toHaveBeenCalled(); + expect(window.localStorage.setItem).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/__tests__/mini-game/game/components/StartCharModal.test.tsx b/__tests__/mini-game/game/components/StartCharModal.test.tsx new file mode 100644 index 0000000..4d09fd4 --- /dev/null +++ b/__tests__/mini-game/game/components/StartCharModal.test.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import StartCharModal from '@/app/mini-game/game/components/StartCharModal'; + +describe('StartCharModal', () => { + it('should not render when open is false', () => { + render( {}} onChange={() => {}} onSave={() => {}} />); + expect(screen.queryByText('제시어 시작글자 설정')).not.toBeInTheDocument(); + }); + + it('should render correctly when open is true', () => { + render( {}} onChange={() => {}} onSave={() => {}} />); + expect(screen.getByText('제시어 시작글자 설정')).toBeInTheDocument(); + expect(screen.getByDisplayValue('가나다')).toBeInTheDocument(); + }); + + it('should call onChange when typing', () => { + const onChange = jest.fn(); + render( {}} onChange={onChange} onSave={() => {}} />); + const textarea = screen.getByPlaceholderText('예: 가나다'); + fireEvent.change(textarea, { target: { value: '라' } }); + expect(onChange).toHaveBeenCalledWith('라'); + }); + + it('should call onSave when save button is clicked', () => { + const onSave = jest.fn(); + render( {}} onChange={() => {}} onSave={onSave} />); + fireEvent.click(screen.getByText('저장')); + expect(onSave).toHaveBeenCalled(); + }); + + it('should call onClose when cancel button is clicked', () => { + const onClose = jest.fn(); + render( {}} onSave={() => {}} />); + fireEvent.click(screen.getByText('취소')); + expect(onClose).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/__tests__/mini-game/game/components/WordManagerModal.test.tsx b/__tests__/mini-game/game/components/WordManagerModal.test.tsx new file mode 100644 index 0000000..9deecc5 --- /dev/null +++ b/__tests__/mini-game/game/components/WordManagerModal.test.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import WordManagerModal from '@/app/mini-game/game/components/WordManagerModal'; +import * as wordDB from '@/app/mini-game/game/lib/wordDB'; +import gameManager from '@/app/mini-game/game/lib/GameManager'; + +jest.mock('@/app/mini-game/game/lib/wordDB'); +jest.mock('@/app/mini-game/game/lib/GameManager'); +jest.mock('@/app/mini-game/game/components/ConfirmModal', () => ({ message, onConfirm, onCancel }: any) => ( +
+ {message} + + +
+)); + +// Mock useVirtualizer +jest.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: ({ count, getScrollElement }: any) => ({ + getVirtualItems: () => Array.from({ length: count }).map((_, i) => ({ + index: i, + start: i * 56, + size: 56, + measureElement: jest.fn(), + })), + getTotalSize: () => count * 56, + }), +})); + +describe('WordManagerModal', () => { + const mockWords = [ + { word: '가방', theme: '자유' }, + { word: '나비', theme: '자유' }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + (wordDB.getAllWords as jest.Mock).mockResolvedValue(mockWords); + (wordDB.searchWordsByPrefix as jest.Mock).mockResolvedValue([mockWords[0]]); + }); + + it('should render and load words', async () => { + await act(async () => { + render( {}} />); + }); + + expect(screen.getByText('단어 목록 관리')).toBeInTheDocument(); + expect(wordDB.getAllWords).toHaveBeenCalled(); + expect(screen.getByText('가방')).toBeInTheDocument(); + expect(screen.getByText('나비')).toBeInTheDocument(); + }); + + it('should search words', async () => { + jest.useFakeTimers(); + await act(async () => { + render( {}} />); + }); + + const searchInput = screen.getByPlaceholderText('예: 끝말, 게임...'); + fireEvent.change(searchInput, { target: { value: '가' } }); + + act(() => { + jest.advanceTimersByTime(300); + }); + + await waitFor(() => { + expect(wordDB.searchWordsByPrefix).toHaveBeenCalledWith('가'); + }); + + jest.useRealTimers(); + }); + + it('should add a word', async () => { + await act(async () => { + render( {}} />); + }); + + const addInput = screen.getByPlaceholderText('새 단어 추가...'); + fireEvent.change(addInput, { target: { value: '다람쥐' } }); + + await act(async () => { + fireEvent.click(screen.getByText('추가')); + }); + + expect(wordDB.addWord).toHaveBeenCalledWith('다람쥐'); + expect(gameManager.addWordToDB).toHaveBeenCalledWith('다람쥐', ['자유']); + expect(wordDB.getAllWords).toHaveBeenCalledTimes(2); // Initial + after add + }); + + it('should delete a word', async () => { + await act(async () => { + render( {}} />); + }); + + // Find delete button for '가방' + const deleteBtns = screen.getAllByText('삭제'); + fireEvent.click(deleteBtns[0]); // First one + + expect(screen.getByTestId('confirm-modal')).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByText('Confirm')); + }); + + expect(wordDB.deleteWord).toHaveBeenCalledWith('가방'); + expect(gameManager.deleteWordFromDB).toHaveBeenCalledWith('가방'); + }); + + it('should edit a word', async () => { + await act(async () => { + render( {}} />); + }); + + const editBtns = screen.getAllByText('수정'); + fireEvent.click(editBtns[0]); // '가방' + + const editInput = screen.getByDisplayValue('가방'); + fireEvent.change(editInput, { target: { value: '가방끈' } }); + + await act(async () => { + fireEvent.click(screen.getByText('저장')); + }); + + expect(wordDB.updateWord).toHaveBeenCalledWith('가방', '가방끈'); + expect(gameManager.editWordInDB).toHaveBeenCalledWith('가방', '가방끈'); + }); +}); \ No newline at end of file diff --git a/__tests__/mini-game/game/hooks/useChat.test.tsx b/__tests__/mini-game/game/hooks/useChat.test.tsx new file mode 100644 index 0000000..1883451 --- /dev/null +++ b/__tests__/mini-game/game/hooks/useChat.test.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { renderHook, act } from '@testing-library/react'; +import { ChatProvider, useChat } from '@/app/mini-game/game/hooks/useChat'; + +describe('useChat', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + it('should provide initial state', () => { + const { result } = renderHook(() => useChat(), { wrapper }); + expect(result.current.chatInput).toBe(''); + expect(result.current.messages.length).toBeGreaterThan(0); // Initial notice + expect(result.current.gameInputVisible).toBe(false); + }); + + it('should update chat input', () => { + const { result } = renderHook(() => useChat(), { wrapper }); + act(() => { + result.current.setChatInput('hello'); + }); + expect(result.current.chatInput).toBe('hello'); + }); + + it('should handle chat input change event', () => { + const { result } = renderHook(() => useChat(), { wrapper }); + const event = { target: { value: 'world' } } as React.ChangeEvent; + act(() => { + result.current.handleChatInputChange(event); + }); + expect(result.current.chatInput).toBe('world'); + }); + + it('should register and call game input handler', () => { + const { result } = renderHook(() => useChat(), { wrapper }); + const handler = jest.fn(); + + act(() => { + result.current.registerGameHandleInput(handler); + }); + + act(() => { + result.current.callGameInput('test'); + }); + + expect(handler).toHaveBeenCalledWith('test'); + }); + + it('should register and call send hint', () => { + const { result } = renderHook(() => useChat(), { wrapper }); + const handler = jest.fn(); + + act(() => { + result.current.registerSendHint(handler); + }); + + act(() => { + result.current.sendHint(); + }); + + expect(handler).toHaveBeenCalled(); + }); + + it('should clear messages and show start notice', () => { + const { result } = renderHook(() => useChat(), { wrapper }); + act(() => { + result.current.setMessages([]); + result.current.setChatInput('some input'); + }); + + act(() => { + result.current.clearMessagesAndShowStartNotice(); + }); + + expect(result.current.messages.length).toBe(1); + expect(result.current.messages[0].message).toBe('게임을 시작합니다!'); + expect(result.current.chatInput).toBe(''); + }); +}); \ No newline at end of file diff --git a/__tests__/mini-game/game/hooks/useChatLog.test.tsx b/__tests__/mini-game/game/hooks/useChatLog.test.tsx new file mode 100644 index 0000000..fbe55c8 --- /dev/null +++ b/__tests__/mini-game/game/hooks/useChatLog.test.tsx @@ -0,0 +1,148 @@ +import { renderHook, act } from '@testing-library/react'; +import { useChatLog } from '@/app/mini-game/game/hooks/useChatLog'; +import { useChat } from '@/app/mini-game/game/hooks/useChat'; +import { useGameState } from '@/app/mini-game/game/hooks/useGameState'; +import gameManager from '@/app/mini-game/game/lib/GameManager'; + +jest.mock('@/app/mini-game/game/hooks/useChat'); +jest.mock('@/app/mini-game/game/hooks/useGameState'); +jest.mock('@/app/mini-game/game/lib/GameManager'); +describe('useChatLog', () => { + const mockSetMessages = jest.fn(); + const mockSetChatInput = jest.fn(); + const mockCallGameInput = jest.fn(); + const mockRegisterSendHint = jest.fn(); + const mockRequestStart = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (useChat as jest.Mock).mockReturnValue({ + messages: [], + setMessages: mockSetMessages, + chatInput: '', + setChatInput: mockSetChatInput, + callGameInput: mockCallGameInput, + registerSendHint: mockRegisterSendHint, + chatRef: { current: null }, + }); + (useGameState as unknown as jest.Mock).mockImplementation((selector) => { + const state = { + requestStart: mockRequestStart, + isPlaying: false, + }; + return selector ? selector(state) : state; + }); + }); + + it('should handle send message (normal)', () => { + (useChat as jest.Mock).mockReturnValue({ + messages: [], + setMessages: mockSetMessages, + chatInput: 'hello', + setChatInput: mockSetChatInput, + callGameInput: mockCallGameInput, + registerSendHint: mockRegisterSendHint, + chatRef: { current: null }, + }); + + const { result } = renderHook(() => useChatLog()); + + act(() => { + result.current.handleSendMessage(); + }); + + expect(mockSetMessages).toHaveBeenCalled(); + expect(mockSetChatInput).toHaveBeenCalledWith(''); + }); + + it('should handle start command when not playing', () => { + (useChat as jest.Mock).mockReturnValue({ + messages: [], + setMessages: mockSetMessages, + chatInput: '/시작', + setChatInput: mockSetChatInput, + callGameInput: mockCallGameInput, + registerSendHint: mockRegisterSendHint, + chatRef: { current: null }, + }); + + const { result } = renderHook(() => useChatLog()); + + act(() => { + result.current.handleSendMessage(); + }); + + expect(mockRequestStart).toHaveBeenCalled(); + expect(mockSetMessages).toHaveBeenCalled(); // Notice message + }); + + it('should handle start command when playing', () => { + (useChat as jest.Mock).mockReturnValue({ + messages: [], + setMessages: mockSetMessages, + chatInput: '/시작', + setChatInput: mockSetChatInput, + callGameInput: mockCallGameInput, + registerSendHint: mockRegisterSendHint, + chatRef: { current: null }, + }); + (useGameState as unknown as jest.Mock).mockImplementation((selector) => { + const state = { + requestStart: mockRequestStart, + isPlaying: true, + }; + return selector ? selector(state) : state; + }); + + const { result } = renderHook(() => useChatLog()); + + act(() => { + result.current.handleSendMessage(); + }); + + expect(mockCallGameInput).toHaveBeenCalledWith('/시작'); + }); + + it('should handle gg command', () => { + (useChat as jest.Mock).mockReturnValue({ + messages: [], + setMessages: mockSetMessages, + chatInput: '/gg', + setChatInput: mockSetChatInput, + callGameInput: mockCallGameInput, + registerSendHint: mockRegisterSendHint, + chatRef: { current: null }, + }); + + const { result } = renderHook(() => useChatLog()); + + act(() => { + result.current.handleSendMessage(); + }); + + expect(mockCallGameInput).toHaveBeenCalledWith('/gg'); + expect(mockSetMessages).toHaveBeenCalled(); // Notice message + }); + + it('should handle hint command', () => { + (useChat as jest.Mock).mockReturnValue({ + messages: [], + setMessages: mockSetMessages, + chatInput: '/ㅍ', + setChatInput: mockSetChatInput, + callGameInput: mockCallGameInput, + registerSendHint: mockRegisterSendHint, + chatRef: { current: null }, + }); + (gameManager.getHintWord as jest.Mock).mockReturnValue('힌트단어'); + + const { result } = renderHook(() => useChatLog()); + + act(() => { + result.current.handleSendMessage(); + }); + + expect(mockSetMessages).toHaveBeenCalled(); + expect(mockSetChatInput).toHaveBeenCalledWith(''); + }); +}); \ No newline at end of file diff --git a/__tests__/mini-game/game/hooks/useGameLogic.test.tsx b/__tests__/mini-game/game/hooks/useGameLogic.test.tsx new file mode 100644 index 0000000..394a620 --- /dev/null +++ b/__tests__/mini-game/game/hooks/useGameLogic.test.tsx @@ -0,0 +1,189 @@ +import { renderHook, act } from '@testing-library/react'; +import { useGameLogic } from '@/app/mini-game/game/hooks/useGameLogic'; +import { useChat } from '@/app/mini-game/game/hooks/useChat'; +import { soundManager } from '@/app/mini-game/game/lib/SoundManager'; +import gameManager from '@/app/mini-game/game/lib/GameManager'; +import { useGameState } from '@/app/mini-game/game/hooks/useGameState'; + +jest.mock('@/app/mini-game/game/hooks/useChat'); +jest.mock('@/app/mini-game/game/lib/SoundManager'); +jest.mock('@/app/mini-game/game/lib/GameManager'); +jest.mock('@/app/mini-game/game/hooks/useGameState'); + +describe('useGameLogic', () => { + const mockSetChatInput = jest.fn(); + const mockClearMessagesAndShowStartNotice = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + + (useChat as jest.Mock).mockReturnValue({ + chatInput: '', + setChatInput: mockSetChatInput, + clearMessagesAndShowStartNotice: mockClearMessagesAndShowStartNotice, + }); + + (useGameState as unknown as jest.Mock).mockImplementation((selector) => { + const state = { + pendingStart: false, + clearPendingStart: jest.fn(), + blockStart: jest.fn(), + }; + return selector ? selector(state) : state; + }); + + (gameManager.getSetting as jest.Mock).mockReturnValue({ roundTime: 60000 }); + (gameManager.canGameStart as jest.Mock).mockReturnValue(true); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should initialize correctly', () => { + const { result } = renderHook(() => useGameLogic()); + expect(result.current.word).toBe("/시작을 입력하면 게임시작!"); + expect(result.current.isGameStarted).toBe(false); + }); + + it('should handle start command', () => { + const { result } = renderHook(() => useGameLogic()); + + (gameManager.gameStart as jest.Mock).mockReturnValue({ + startChar: '가', + missionChar: null, + turnTime: 5000, + turnSpeed: 5 + }); + (gameManager.getCurrentState as jest.Mock).mockReturnValue({ + startChar: '가', + missionChar: null + }); + + act(() => { + result.current.handleInput('/시작'); + }); + + expect(mockClearMessagesAndShowStartNotice).toHaveBeenCalled(); + expect(soundManager.playWithEnd).toHaveBeenCalledWith('game_start', expect.any(Function)); + + // Trigger callback manually + const call = (soundManager.playWithEnd as jest.Mock).mock.calls.find(c => c[0] === 'game_start'); + if (call) { + act(() => { + call[1](); + }); + } + + // Trigger round_start callback + const call2 = (soundManager.playWithEnd as jest.Mock).mock.calls.find(c => c[0] === 'round_start'); + if (call2) { + act(() => { + call2[1](); + }); + } + + expect(result.current.isGameStarted).toBe(true); + expect(result.current.word).toBe('가'); + }); + + it('should handle valid word submission', () => { + const { result } = renderHook(() => useGameLogic()); + + // Start game first + (gameManager.gameStart as jest.Mock).mockReturnValue({ + startChar: '가', + missionChar: null, + turnTime: 5000, + turnSpeed: 5 + }); + (gameManager.getCurrentState as jest.Mock).mockReturnValue({ + startChar: '가', + missionChar: null + }); + + act(() => { + result.current.handleInput('/시작'); + }); + + // Trigger callbacks + const call = (soundManager.playWithEnd as jest.Mock).mock.calls.find(c => c[0] === 'game_start'); + if (call) act(() => { call[1](); }); + const call2 = (soundManager.playWithEnd as jest.Mock).mock.calls.find(c => c[0] === 'round_start'); + if (call2) act(() => { call2[1](); }); + + // Submit word + (gameManager.submitWord as jest.Mock).mockReturnValue({ + ok: true, + wordEntry: { theme: ['자유'] }, + nextChar: '나', + nextMissionChar: null, + turnSpeed: 5, + trunTime: 5000 + }); + (gameManager.getCurrentState as jest.Mock).mockReturnValue({ + startChar: '나', + missionChar: null + }); + + act(() => { + result.current.handleInput('가방'); + }); + + expect(gameManager.submitWord).toHaveBeenCalledWith('가방', expect.any(Number)); + expect(result.current.chainCount).toBe(1); + + // Animation logic uses setTimeout + act(() => { + jest.advanceTimersByTime(5000); + }); + + expect(result.current.word).toBe('나'); + }); + + it('should handle invalid word submission', () => { + const { result } = renderHook(() => useGameLogic()); + + // Start game + (gameManager.gameStart as jest.Mock).mockReturnValue({ + startChar: '가', + missionChar: null, + turnTime: 5000, + turnSpeed: 5 + }); + (gameManager.getCurrentState as jest.Mock).mockReturnValue({ + startChar: '가', + missionChar: null + }); + + act(() => { + result.current.handleInput('/시작'); + }); + + // Trigger callbacks + const call = (soundManager.playWithEnd as jest.Mock).mock.calls.find(c => c[0] === 'game_start'); + if (call) act(() => { call[1](); }); + const call2 = (soundManager.playWithEnd as jest.Mock).mock.calls.find(c => c[0] === 'round_start'); + if (call2) act(() => { call2[1](); }); + + // Submit invalid word + (gameManager.submitWord as jest.Mock).mockReturnValue({ + ok: false, + reason: 'Invalid' + }); + + act(() => { + result.current.handleInput('다람쥐'); + }); + + expect(result.current.isFail).toBe(true); + expect(soundManager.play).toHaveBeenCalledWith('fail'); + + act(() => { + jest.advanceTimersByTime(2500); + }); + + expect(result.current.isFail).toBe(false); + }); +}); \ No newline at end of file diff --git a/__tests__/mini-game/game/hooks/useGameState.test.tsx b/__tests__/mini-game/game/hooks/useGameState.test.tsx new file mode 100644 index 0000000..171b4de --- /dev/null +++ b/__tests__/mini-game/game/hooks/useGameState.test.tsx @@ -0,0 +1,109 @@ +import { renderHook, act } from '@testing-library/react'; +import { useGameState } from '@/app/mini-game/game/hooks/useGameState'; +import gameManager from '@/app/mini-game/game/lib/GameManager'; +import * as hooks from '@/app/mini-game/game/store/hooks'; +import { setPlaying, setPendingStart, setStartBlocked, resetGame } from '@/app/mini-game/game/store/gameSlice'; + +jest.mock('@/app/mini-game/game/lib/GameManager', () => ({ + __esModule: true, + default: { + canGameStart: jest.fn(), + }, +})); + +jest.mock('@/app/mini-game/game/store/hooks'); + +describe('useGameState', () => { + const mockDispatch = jest.fn(); + const mockSelector = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (hooks.useAppDispatch as jest.Mock).mockReturnValue(mockDispatch); + (hooks.useAppSelector as jest.Mock).mockImplementation((selector) => mockSelector(selector)); + + // Default selector behavior + mockSelector.mockImplementation((selector) => selector({ + game: { + isPlaying: false, + pendingStart: false, + startBlocked: false, + startBlockedMessage: null, + } + })); + }); + + it('should return current state', () => { + const { result } = renderHook(() => useGameState()); + + expect(result.current.isPlaying).toBe(false); + expect(result.current.pendingStart).toBe(false); + expect(result.current.startBlocked).toBe(false); + expect(result.current.startBlockedMessage).toBeNull(); + }); + + it('should request start successfully', () => { + (gameManager.canGameStart as jest.Mock).mockReturnValue(true); + + const { result } = renderHook(() => useGameState()); + + act(() => { + result.current.requestStart(); + }); + + expect(mockDispatch).toHaveBeenCalledWith(setPlaying(true)); + expect(mockDispatch).toHaveBeenCalledWith(setPendingStart(true)); + }); + + it('should block start if gameManager says no', () => { + (gameManager.canGameStart as jest.Mock).mockReturnValue(false); + + const { result } = renderHook(() => useGameState()); + + act(() => { + result.current.requestStart(); + }); + + expect(mockDispatch).toHaveBeenCalledWith(setStartBlocked({ blocked: true, message: '게임을 시작할 수 없습니다.' })); + }); + + it('should clear pending start', () => { + const { result } = renderHook(() => useGameState()); + + act(() => { + result.current.clearPendingStart(); + }); + + expect(mockDispatch).toHaveBeenCalledWith(setPendingStart(false)); + }); + + it('should exit game', () => { + const { result } = renderHook(() => useGameState()); + + act(() => { + result.current.exitGame(); + }); + + expect(mockDispatch).toHaveBeenCalledWith(resetGame()); + }); + + it('should dismiss start blocked', () => { + const { result } = renderHook(() => useGameState()); + + act(() => { + result.current.dismissStartBlocked(); + }); + + expect(mockDispatch).toHaveBeenCalledWith(setStartBlocked({ blocked: false, message: null })); + }); + + it('should block start with message', () => { + const { result } = renderHook(() => useGameState()); + + act(() => { + result.current.blockStart('Custom message'); + }); + + expect(mockDispatch).toHaveBeenCalledWith(setStartBlocked({ blocked: true, message: 'Custom message' })); + }); +}); \ No newline at end of file diff --git a/__tests__/mini-game/game/lib/GameLogic.test.ts b/__tests__/mini-game/game/lib/GameLogic.test.ts new file mode 100644 index 0000000..6acae76 --- /dev/null +++ b/__tests__/mini-game/game/lib/GameLogic.test.ts @@ -0,0 +1,132 @@ +import { GameLogic } from '@/app/mini-game/game/lib/GameLogic'; +import { WordService } from '@/app/mini-game/game/services/WordService'; +import { GameSetting, CurrentState } from '@/app/mini-game/game/types/game.types'; + +// Mock WordService +const mockWordService = { + hasWord: jest.fn(), + NormalStartCharSet: new Set(['가', '나', '다']), + MissionStartCharSet: new Set([['가', '나'], ['다', '라']]), + NormalEngStartCharSet: new Set(['a', 'b', 'c']), + MissionEngStartCharSet: new Set([['a', 'b'], ['c', 'd']]), +} as unknown as WordService; + +describe('GameLogic', () => { + describe('getTurnSpeed', () => { + it('should return correct speed based on round time', () => { + expect(GameLogic.getTurnSpeed(0)).toBe(0); + expect(GameLogic.getTurnSpeed(4000)).toBe(10); + expect(GameLogic.getTurnSpeed(10000)).toBe(9); + expect(GameLogic.getTurnSpeed(17000)).toBe(8); + expect(GameLogic.getTurnSpeed(25000)).toBe(7); + expect(GameLogic.getTurnSpeed(34000)).toBe(6); + expect(GameLogic.getTurnSpeed(44000)).toBe(5); + expect(GameLogic.getTurnSpeed(55000)).toBe(4); + expect(GameLogic.getTurnSpeed(67000)).toBe(3); + expect(GameLogic.getTurnSpeed(80000)).toBe(2); + expect(GameLogic.getTurnSpeed(94000)).toBe(1); + expect(GameLogic.getTurnSpeed(100000)).toBe(0); + }); + }); + + describe('isValidWord', () => { + const nowState: CurrentState = { startChar: '가', missionChar: null }; + + it('should return false if nowState is null', () => { + expect(GameLogic.isValidWord('가방', null, mockWordService)).toBe(false); + }); + + it('should return false if word length is <= 1', () => { + expect(GameLogic.isValidWord('가', nowState, mockWordService)).toBe(false); + }); + + it('should return false if word is not in DB', () => { + (mockWordService.hasWord as jest.Mock).mockReturnValue(false); + expect(GameLogic.isValidWord('가방', nowState, mockWordService)).toBe(false); + }); + + it('should return false if start char does not match', () => { + (mockWordService.hasWord as jest.Mock).mockReturnValue(true); + expect(GameLogic.isValidWord('나비', nowState, mockWordService)).toBe(false); + }); + + it('should return true if word is valid', () => { + (mockWordService.hasWord as jest.Mock).mockReturnValue(true); + expect(GameLogic.isValidWord('가방', nowState, mockWordService)).toBe(true); + }); + + it('should handle duem law', () => { + // Assuming duemLaw('리') returns '이' (or similar logic if implemented) + // But here we test if it checks against validStartChars + // Let's assume '리' -> '이' mapping exists in duemLaw implementation + // Since we are using real duemLaw from import, we rely on its behavior. + // If duemLaw is simple, we can test it. + // For now, let's just test basic start char match. + const state: CurrentState = { startChar: '리', missionChar: null }; + (mockWordService.hasWord as jest.Mock).mockReturnValue(true); + + // If '이발소' is input for '리' start char, it should be valid if duemLaw works + // But we need to know what duemLaw returns. + // Let's stick to basic matching for now. + expect(GameLogic.isValidWord('리본', state, mockWordService)).toBe(true); + }); + }); + + describe('getStartChar', () => { + it('should return a valid start char for Korean Normal mode', () => { + const setting: GameSetting = { + lang: 'ko', + mode: 'normal', + notAgainSameChar: false, + wantStartChar: new Set(), + // ... other props + } as unknown as GameSetting; + + const result = GameLogic.getStartChar(setting, mockWordService); + expect(['가', '나', '다']).toContain(result.startChar); + expect(result.missionChar).toBeNull(); + }); + + it('should return a valid start char for Korean Mission mode', () => { + const setting: GameSetting = { + lang: 'ko', + mode: 'mission', + notAgainSameChar: false, + wantStartChar: new Set(), + } as unknown as GameSetting; + + const result = GameLogic.getStartChar(setting, mockWordService); + // MissionStartCharSet has [['가', '나'], ['다', '라']] + // So startChar should be '가' or '다' + expect(['가', '다']).toContain(result.startChar); + expect(result.missionChar).not.toBeNull(); + }); + + it('should return a valid start char for English Normal mode', () => { + const setting: GameSetting = { + lang: 'en', + mode: 'normal', + notAgainSameChar: false, + wantStartChar: new Set(), + } as unknown as GameSetting; + + const result = GameLogic.getStartChar(setting, mockWordService); + expect(['a', 'b', 'c']).toContain(result.startChar); + expect(result.missionChar).toBeNull(); + }); + + it('should respect exclusion list in Normal mode', () => { + const setting: GameSetting = { + lang: 'ko', + mode: 'normal', + notAgainSameChar: true, + wantStartChar: new Set(['가', '나', '다']), + } as unknown as GameSetting; + + const exclusion = new Set(['가', '나']); + const result = GameLogic.getStartChar(setting, mockWordService, exclusion); + + expect(result.startChar).toBe('다'); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/mini-game/game/lib/GameManager.test.ts b/__tests__/mini-game/game/lib/GameManager.test.ts new file mode 100644 index 0000000..b627ff4 --- /dev/null +++ b/__tests__/mini-game/game/lib/GameManager.test.ts @@ -0,0 +1,190 @@ +import gameManager from '@/app/mini-game/game/lib/GameManager'; + +describe('GameManager', () => { + beforeEach(() => { + gameManager.clearDB(); + gameManager.updateSetting({ + lang: 'ko', + mode: 'normal', + hintMode: 'auto', + notAgainSameChar: false, + roundTime: 60000, + wantStartChar: new Set() + }); + jest.restoreAllMocks(); + }); + + it('should be a singleton', () => { + expect(gameManager).toBeDefined(); + }); + + it('should load word DB correctly', () => { + const words = [ + { word: '가방', theme: ['자유'] }, + { word: '나비', theme: ['자유'] }, + { word: 'apple', theme: ['english'] } + ]; + gameManager.loadWordDB(words, {}); + + gameManager.updateSetting({ lang: 'ko', mode: 'normal' }); + expect(gameManager.canGameStart()).toBe(true); + + gameManager.updateSetting({ lang: 'en', mode: 'normal' }); + expect(gameManager.canGameStart()).toBe(true); + }); + + it('should handle game flow (normal mode)', () => { + const words = [ + { word: '가방', theme: ['자유'] }, + { word: '방구', theme: ['자유'] } + ]; + gameManager.loadWordDB(words, { lang: 'ko', mode: 'normal' }); + + const startState = gameManager.gameStart(); + expect(['가', '방']).toContain(startState.startChar); + + let wordToSubmit = ''; + if (startState.startChar === '가') wordToSubmit = '가방'; + else if (startState.startChar === '방') wordToSubmit = '방구'; + + const result = gameManager.submitWord(wordToSubmit, 50000); + if (result.ok) { + expect(result.ok).toBe(true); + expect(typeof result.nextChar).toBe('string'); + expect(result.nextChar.length).toBeGreaterThan(0); + } else { + fail('Submit word failed'); + } + }); + + it('should handle duem law', () => { + // '리본' starts with '리'. '이발' starts with '이'. + // '리' -> '이' via duem law. + const words = [ + { word: '리본', theme: ['자유'] }, + { word: '이발', theme: ['자유'] } + ]; + gameManager.loadWordDB(words, { lang: 'ko', mode: 'normal' }); + + // Force start char to '리' by mocking Math.random if needed, + // or just retry until we get '리' (but that's flaky). + // Better: clear DB and load only '리본' first to force start char '리', + // then add '이발' dynamically? No, gameStart picks from existing DB. + + // Let's mock Math.random to pick the index corresponding to '리'. + // We don't know the order in Set. + // But we can check the start char and if it is '리', submit '이발'. + // If it is '이', submit '리본' (Wait, '이' -> '리' is not duem law, duem law is one way usually? + // Actually duemLaw function converts '리' to '이'. + // isValidWord checks: [startChar, duemLaw(startChar)].includes(word[0]) + // So if startChar is '리', word can start with '리' or '이'. + // If startChar is '이', word can start with '이' or duemLaw('이')='이'. + + // So we need startChar='리'. + + // Hack: Load only '리본'. Start game. Then add '이발'. + gameManager.clearDB(); + gameManager.loadWordDB([{ word: '리본', theme: ['자유'] }], { lang: 'ko', mode: 'normal' }); + const startState = gameManager.gameStart(); + expect(startState.startChar).toBe('리'); + + gameManager.addWordToDB('이발', ['자유']); + + const result = gameManager.submitWord('이발', 50000); + expect(result.ok).toBe(true); + }); + + it('should add/edit/delete words', () => { + gameManager.addWordToDB('테스트', ['테마']); + + // Check if added by trying to start game with it + gameManager.updateSetting({ lang: 'ko', mode: 'normal' }); + const startState = gameManager.gameStart(); + expect(startState.startChar).toBe('테'); + + // Edit + gameManager.editWordInDB('테스트', '수정된'); + // Now start char should be '수' (if we restart game or check DB) + // Note: gameStart resets state based on current DB. + const startState2 = gameManager.gameStart(); + expect(startState2.startChar).toBe('수'); + + // Delete + gameManager.deleteWordFromDB('수정된'); + expect(gameManager.canGameStart()).toBe(false); + }); + + it('should handle mission mode', () => { + // Mission mode: start char and mission char. + // Word must include mission char. + const words = [ + { word: '가방', theme: ['자유'] } // Contains '가', '방' + ]; + gameManager.loadWordDB(words, { lang: 'ko', mode: 'mission' }); + + const startState = gameManager.gameStart(); + expect(startState.startChar).toBe('가'); + expect(startState.missionChar).toMatch(/[가방]/); + + const result = gameManager.submitWord('가방', 50000); + expect(result.ok).toBe(true); + }); + + it('should fail if mission char is missing in mission mode', () => { + const words = [ + { word: '가방', theme: ['자유'] }, // Contains '가', '방' + { word: '가위', theme: ['자유'] } // Contains '가', '위' + ]; + // We want startChar='가', missionChar='방'. + // '가위' does not have '방'. + + // Force mission char to be '방'. + // We can filter DB to only have words with '방' to force it? + // No, MissionStartCharSet contains [start, mission] pairs. + // If we have '가방', pairs are ('가', '가'), ('가', '방'). + + // Let's just check the result reason if we fail. + gameManager.loadWordDB(words, { lang: 'ko', mode: 'mission' }); + + // Try until we get a mission char that is NOT in '가위' (e.g. '방') + // Or just mock Math.random. + + // Let's try to submit '가위' when mission char is '방'. + // We can manually set `nowState` if we could, but it's private. + // We can loop gameStart until we get desired state? + + let found = false; + for(let i=0; i<100; i++) { + const state = gameManager.gameStart(); + if (state.startChar === '가' && state.missionChar === '방') { + found = true; + break; + } + } + + if (found) { + const result = gameManager.submitWord('가위', 50000); + expect(result.ok).toBe(false); + if (!result.ok) { // Type guard + expect(result.reason).toBe('미션 글자 미포함!'); + } + } + }); + + it('should provide hints', () => { + const words = [ + { word: '가방', theme: ['자유'] } + ]; + gameManager.loadWordDB(words, { lang: 'ko', mode: 'normal', hintMode: 'auto' }); + gameManager.gameStart(); + + const hint = gameManager.getHint(); + expect(hint).toBe('가방'); + + const hintWord = gameManager.getHintWord(); + // hintStack 0 -> disassembled first char? No, loop over word length. + // hintWord is '가방'. + // hintStack 0: disassemble('가')[0] + disassemble('방')[0] = 'ㄱ' + 'ㅂ' + expect(hintWord).toBe('ㄱㅂ'); + }); +}); \ No newline at end of file diff --git a/__tests__/mini-game/game/lib/SoundManager.test.ts b/__tests__/mini-game/game/lib/SoundManager.test.ts new file mode 100644 index 0000000..f2dd499 --- /dev/null +++ b/__tests__/mini-game/game/lib/SoundManager.test.ts @@ -0,0 +1,109 @@ +import { soundManager } from '@/app/mini-game/game/lib/SoundManager'; +import { Howl } from 'howler'; + +jest.mock('howler'); + +describe('SoundManager', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Ensure sounds are loaded for each test if needed, or just once. + // Since soundManager is a singleton, we might want to reset it or just call load() again. + // The load method overwrites properties in `sounds` object, so calling it again is fine. + }); + + it('should load sounds correctly', () => { + soundManager.load(); + expect(Howl).toHaveBeenCalled(); + const sounds = (soundManager as any).sounds; + expect(Object.keys(sounds).length).toBeGreaterThan(0); + }); + + it('should play a sound', () => { + soundManager.load(); + const soundName = 'game_start'; + soundManager.play(soundName); + + const sounds = (soundManager as any).sounds; + expect(sounds[soundName]).toBeDefined(); + expect(sounds[soundName].stop).toHaveBeenCalled(); + expect(sounds[soundName].play).toHaveBeenCalled(); + }); + + it('should playOnce correctly', () => { + soundManager.load(); + const soundName = 'fail'; + const sounds = (soundManager as any).sounds; + + // Mock Date.now + let now = 1000; + jest.spyOn(Date, 'now').mockImplementation(() => now); + + // First play + soundManager.playOnce(soundName); + expect(sounds[soundName].play).toHaveBeenCalledTimes(1); + + // Immediate second play (should be ignored) + now += 10; // +10ms + soundManager.playOnce(soundName); + expect(sounds[soundName].play).toHaveBeenCalledTimes(1); + + // Play after delay + now += 100; // +100ms (total 110ms > 80ms) + soundManager.playOnce(soundName); + expect(sounds[soundName].play).toHaveBeenCalledTimes(2); + + jest.restoreAllMocks(); + }); + + it('should stop a sound', () => { + soundManager.load(); + const soundName = 'game_start'; + soundManager.stop(soundName); + const sounds = (soundManager as any).sounds; + expect(sounds[soundName].stop).toHaveBeenCalled(); + }); + + it('should set volume', () => { + soundManager.load(); + const soundName = 'game_start'; + soundManager.setVolume(soundName, 0.5); + const sounds = (soundManager as any).sounds; + expect(sounds[soundName].volume).toHaveBeenCalledWith(0.5); + }); + + it('should set all volume', () => { + soundManager.load(); + soundManager.setAllVolume(0.5); + const sounds = (soundManager as any).sounds; + Object.values(sounds).forEach((s: any) => { + expect(s.volume).toHaveBeenCalledWith(0.5); + }); + }); + + it('should stop all sounds', () => { + soundManager.load(); + soundManager.stopAllSounds(); + const sounds = (soundManager as any).sounds; + Object.values(sounds).forEach((s: any) => { + expect(s.stop).toHaveBeenCalled(); + }); + }); + + it('should playWithEnd and trigger callback', () => { + soundManager.load(); + const soundName = 'game_start'; + const cb = jest.fn(); + soundManager.playWithEnd(soundName, cb); + + const sounds = (soundManager as any).sounds; + expect(sounds[soundName].play).toHaveBeenCalled(); + expect(sounds[soundName].once).toHaveBeenCalledWith('end', expect.any(Function)); + + // Manually trigger the callback + const call = sounds[soundName].once.mock.calls.find((c: any) => c[0] === 'end'); + if (call) { + call[1](); // Execute the callback passed to 'once' + } + expect(cb).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/__tests__/mini-game/game/lib/fileUtils.test.ts b/__tests__/mini-game/game/lib/fileUtils.test.ts new file mode 100644 index 0000000..00ca523 --- /dev/null +++ b/__tests__/mini-game/game/lib/fileUtils.test.ts @@ -0,0 +1,42 @@ +import { parseWordsFromFile } from '@/app/mini-game/game/lib/fileUtils'; + +describe('fileUtils', () => { + describe('parseWordsFromFile', () => { + it('should throw error if file size exceeds 1MB', async () => { + const file = new File(['a'.repeat(1024 * 1024 + 1)], 'test.txt', { type: 'text/plain' }); + await expect(parseWordsFromFile(file)).rejects.toThrow('파일 크기는 1MB를 초과할 수 없습니다.'); + }); + + it('should throw error if file extension is not .txt', async () => { + const file = new File(['content'], 'test.csv', { type: 'text/csv' }); + await expect(parseWordsFromFile(file)).rejects.toThrow('txt 파일만 업로드 가능합니다.'); + }); + + it('should parse words correctly', async () => { + const content = 'Apple\nBanana\nC\nHello World!'; + const file = new File([content], 'words.txt', { type: 'text/plain' }); + // Mock text() method since jsdom File might not support it + Object.defineProperty(file, 'text', { + value: async () => content + }); + + const result = await parseWordsFromFile(file); + + // "C" is filtered out because length <= 1 + // "Hello World!" -> "helloworld" (special chars removed, lowercase) + expect(result).toEqual(['apple', 'banana', 'helloworld']); + }); + + it('should remove special characters and convert to lowercase', async () => { + const content = 'Ap@#ple\nBa_na-na'; + const file = new File([content], 'words.txt', { type: 'text/plain' }); + Object.defineProperty(file, 'text', { + value: async () => content + }); + + const result = await parseWordsFromFile(file); + + expect(result).toEqual(['apple', 'banana']); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/mini-game/game/lib/wordDB.test.ts b/__tests__/mini-game/game/lib/wordDB.test.ts new file mode 100644 index 0000000..668bf00 --- /dev/null +++ b/__tests__/mini-game/game/lib/wordDB.test.ts @@ -0,0 +1,126 @@ +import * as wordDB from '@/app/mini-game/game/lib/wordDB'; +import { openDB } from 'idb'; + +jest.mock('idb'); + +describe('wordDB', () => { + const mockDB = { + transaction: jest.fn(), + getAll: jest.fn(), + put: jest.fn(), + delete: jest.fn(), + count: jest.fn(), + clear: jest.fn(), + objectStoreNames: { + contains: jest.fn(), + }, + createObjectStore: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + (openDB as jest.Mock).mockResolvedValue(mockDB); + }); + + it('should load words from file', async () => { + const file = new File(['word1\nword2'], 'test.txt', { type: 'text/plain' }); + Object.defineProperty(file, 'text', { + value: () => Promise.resolve('word1\nword2') + }); + + const mockTx = { + objectStore: jest.fn().mockReturnValue({ + put: jest.fn(), + }), + done: Promise.resolve(), + }; + mockDB.transaction.mockReturnValue(mockTx); + + const count = await wordDB.loadWordsFromFile(file); + + expect(openDB).toHaveBeenCalled(); + expect(mockDB.transaction).toHaveBeenCalledWith('words', 'readwrite'); + expect(mockTx.objectStore).toHaveBeenCalledWith('words'); + // word1, word2 are valid. + expect(mockTx.objectStore().put).toHaveBeenCalledTimes(2); + expect(count).toBe(2); + }); + + it('should throw error if file is too large', async () => { + const file = new File([''], 'test.txt', { type: 'text/plain' }); + Object.defineProperty(file, 'size', { value: 1024 * 1024 + 1 }); + + await expect(wordDB.loadWordsFromFile(file)).rejects.toThrow('파일 크기는 1MB를 초과할 수 없습니다.'); + }); + + it('should throw error if file is not txt', async () => { + const file = new File(['content'], 'test.png', { type: 'image/png' }); + await expect(wordDB.loadWordsFromFile(file)).rejects.toThrow('txt 파일만 업로드 가능합니다.'); + }); + + it('should get all words', async () => { + const mockWords = [ + { word: '나비', theme: '자유' }, + { word: '가방', theme: '자유' }, + ]; + mockDB.getAll.mockResolvedValue(mockWords); + + const words = await wordDB.getAllWords(); + + expect(mockDB.getAll).toHaveBeenCalledWith('words'); + expect(words[0].word).toBe('가방'); // Sorted + expect(words[1].word).toBe('나비'); + }); + + it('should search words by prefix', async () => { + const mockWords = [ + { word: '가방', theme: '자유' }, + { word: '가위', theme: '자유' }, + { word: '나비', theme: '자유' }, + ]; + mockDB.getAll.mockResolvedValue(mockWords); + + const results = await wordDB.searchWordsByPrefix('가'); + expect(results.length).toBe(2); + expect(results[0].word).toBe('가방'); + expect(results[1].word).toBe('가위'); + }); + + it('should update word', async () => { + const mockTx = { + objectStore: jest.fn().mockReturnValue({ + delete: jest.fn(), + put: jest.fn(), + }), + done: Promise.resolve(), + }; + mockDB.transaction.mockReturnValue(mockTx); + + await wordDB.updateWord('old', 'new'); + + expect(mockTx.objectStore().delete).toHaveBeenCalledWith('old'); + expect(mockTx.objectStore().put).toHaveBeenCalledWith({ word: 'new', theme: '자유' }); + }); + + it('should delete word', async () => { + await wordDB.deleteWord('word'); + expect(mockDB.delete).toHaveBeenCalledWith('words', 'word'); + }); + + it('should add word', async () => { + await wordDB.addWord('word'); + expect(mockDB.put).toHaveBeenCalledWith('words', { word: 'word', theme: '자유' }); + }); + + it('should check if has words', async () => { + mockDB.count.mockResolvedValue(1); + const has = await wordDB.hasWords(); + expect(has).toBe(true); + expect(mockDB.count).toHaveBeenCalledWith('words'); + }); + + it('should clear all words', async () => { + await wordDB.clearAllWords(); + expect(mockDB.clear).toHaveBeenCalledWith('words'); + }); +}); \ No newline at end of file diff --git a/__tests__/mini-game/game/services/services.test.ts b/__tests__/mini-game/game/services/services.test.ts new file mode 100644 index 0000000..0a79c74 --- /dev/null +++ b/__tests__/mini-game/game/services/services.test.ts @@ -0,0 +1,117 @@ +import { WordService } from "@/app/mini-game/game/services/WordService"; + +describe('WordService', () => { + let wordService: WordService; + + beforeEach(() => { + wordService = WordService.getInstance(); + wordService.clearDB(); + }); + + it('should be a singleton', () => { + const instance1 = WordService.getInstance(); + const instance2 = WordService.getInstance(); + expect(instance1).toBe(instance2); + }); + + describe('loadWordDB', () => { + it('should load words correctly', () => { + const data = [ + { word: 'apple', theme: ['fruit'] }, + { word: 'banana', theme: ['fruit'] }, + { word: '가방', theme: ['object'] } + ]; + wordService.loadWordDB(data); + + expect(wordService.hasWord('apple')).toBe(true); + expect(wordService.hasWord('banana')).toBe(true); + expect(wordService.hasWord('가방')).toBe(true); + expect(wordService.hasWord('grape')).toBe(false); + }); + + it('should filter out invalid words', () => { + const data = [ + { word: 'a', theme: [] }, // too short + { word: '!', theme: [] }, // special char only + { word: 'valid', theme: [] } + ]; + wordService.loadWordDB(data); + + expect(wordService.hasWord('a')).toBe(false); + expect(wordService.hasWord('!')).toBe(false); + expect(wordService.hasWord('valid')).toBe(true); + }); + }); + + describe('addWordToDB', () => { + it('should add a new word', () => { + const result = wordService.addWordToDB('newword', ['test']); + expect(result).toBe(true); + expect(wordService.hasWord('newword')).toBe(true); + }); + + it('should not add duplicate word', () => { + wordService.addWordToDB('word', ['test']); + const result = wordService.addWordToDB('word', ['test']); + expect(result).toBe(false); + }); + + it('should not add invalid word', () => { + const result = wordService.addWordToDB('a', ['test']); + expect(result).toBe(false); + }); + }); + + describe('editWordInDB', () => { + beforeEach(() => { + wordService.addWordToDB('oldword', ['test']); + }); + + it('should edit an existing word', () => { + const result = wordService.editWordInDB('oldword', 'newword'); + expect(result).toBe(true); + expect(wordService.hasWord('oldword')).toBe(false); + expect(wordService.hasWord('newword')).toBe(true); + }); + + it('should fail if old word does not exist', () => { + const result = wordService.editWordInDB('nonexistent', 'newword'); + expect(result).toBe(false); + }); + + it('should fail if new word is invalid', () => { + const result = wordService.editWordInDB('oldword', 'a'); + expect(result).toBe(false); + }); + }); + + describe('deleteWordFromDB', () => { + beforeEach(() => { + wordService.addWordToDB('todelete', ['test']); + }); + + it('should delete an existing word', () => { + const result = wordService.deleteWordFromDB('todelete'); + expect(result).toBe(true); + expect(wordService.hasWord('todelete')).toBe(false); + }); + + it('should fail if word does not exist', () => { + const result = wordService.deleteWordFromDB('nonexistent'); + expect(result).toBe(false); + }); + }); + + describe('getWordTheme', () => { + it('should return themes for a word', () => { + wordService.addWordToDB('themedword', ['theme1', 'theme2']); + const themes = wordService.getWordTheme('themedword'); + expect(themes).toEqual(['theme1', 'theme2']); + }); + + it('should return empty array for nonexistent word', () => { + const themes = wordService.getWordTheme('nonexistent'); + expect(themes).toEqual([]); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/mini-game/page.test.tsx b/__tests__/mini-game/page.test.tsx new file mode 100644 index 0000000..1f696c3 --- /dev/null +++ b/__tests__/mini-game/page.test.tsx @@ -0,0 +1,38 @@ +import { render, screen } from '@testing-library/react'; +import MiniGamePage from '@/app/mini-game/page'; + +// Mock child components +jest.mock('@/app/mini-game/game/Game', () => { + return function MockGame() { + return
Game Component
; + }; +}); + +jest.mock('@/app/mini-game/MobileUnsupported', () => { + return function MockMobileUnsupported() { + return
MobileUnsupported Component
; + }; +}); + +jest.mock('@/app/mini-game/providers', () => { + return function MockProviders({ children }: { children: React.ReactNode }) { + return
{children}
; + }; +}); + +describe('MiniGamePage', () => { + it('renders Game and MobileUnsupported components wrapped in Providers', () => { + render(); + + expect(screen.getByTestId('mock-providers')).toBeInTheDocument(); + expect(screen.getByTestId('mock-game')).toBeInTheDocument(); + expect(screen.getByTestId('mock-mobile-unsupported')).toBeInTheDocument(); + }); + + it('renders Game with desktop visibility classes', () => { + render(); + const gameContainer = screen.getByTestId('mock-game').parentElement; + expect(gameContainer).toHaveClass('hidden'); + expect(gameContainer).toHaveClass('md:flex'); + }); +}); diff --git a/app/AutoLogin.tsx b/app/AutoLogin.tsx index db9cfb3..8d30791 100644 --- a/app/AutoLogin.tsx +++ b/app/AutoLogin.tsx @@ -14,15 +14,15 @@ const AutoLogin = () => { if (!data || !data.session || error) return; - const { data: ddata, error: err } = await SCM.get().userById(data.session.user.id); + const { data: dbdata, error: err } = await SCM.get().userById(data.session.user.id); - if (err || !ddata) return; + if (err || !dbdata) return; dispatch( userAction.setInfo({ - username: ddata.nickname, - role: ddata.role ?? "guest", - uuid: ddata.id, + username: dbdata.nickname, + role: dbdata.role ?? "guest", + uuid: dbdata.id, }) ); } diff --git a/app/ErrorPage.tsx b/app/ErrorPage.tsx index 5a1054b..1ac6b1e 100644 --- a/app/ErrorPage.tsx +++ b/app/ErrorPage.tsx @@ -6,7 +6,7 @@ import type { ErrorMessage } from "./types/type"; import { useRouter } from "next/navigation"; const ErrorPage:React.FC<{e:ErrorMessage}> = ({e}) => { - const [errork,setError] = useState(null); + const [error,setError] = useState(null); const router = useRouter(); const goBack = () => { @@ -19,7 +19,7 @@ const ErrorPage:React.FC<{e:ErrorMessage}> = ({e}) => { return (
- {errork && setError(null)} /> } + {error && setError(null)} /> } + +

API Server 관리

+

API 서버 관리 도구

+ +
+ {sections.map((section) => ( + +

{section.title}

+

{section.description}

+ + ))} +
+
+ ); +} \ No newline at end of file diff --git a/app/admin/api-server/api.ts b/app/admin/api-server/api.ts new file mode 100644 index 0000000..3980133 --- /dev/null +++ b/app/admin/api-server/api.ts @@ -0,0 +1,86 @@ +// API Server Admin API Functions +import axios from 'axios'; +import type { CrawlerHealthResponse, SaveSessionRequest, SaveSessionResponse, RestartCrawlerResponse } from './types'; +import { SCM } from '@/app/lib/supabaseClient'; +import zlib from 'zlib'; + +const BASE_URL = 'https://api.solidloop-studio.xyz/api/v1'; + +// Get JWT token from Supabase session +const getAuthHeaders = async () => { + // This should be replaced with actual Supabase session retrieval + const token = await SCM.getJWT(); // TODO: Get from Supabase session + return { + Authorization: `${token}`, + }; +}; + +// Crawler APIs +export const fetchCrawlerHealth = async (): Promise => { + const headers = await getAuthHeaders(); + const response = await axios.get( + `${BASE_URL}/admin/crawler/health`, + { headers } + ); + return response.data; +}; + +export const saveCrawlerSession = async ( + data: SaveSessionRequest +): Promise => { + const headers = await getAuthHeaders(); + const response = await axios.post( + `${BASE_URL}/admin/crawler/session`, + data, + { headers } + ); + return response.data; +}; + +export const restartCrawler = async ( + channelId: string +): Promise => { + const headers = await getAuthHeaders(); + const response = await axios.post( + `${BASE_URL}/admin/crawler/restart/${channelId}`, + {}, + { headers } + ); + return response.data; +}; + +// Logs APIs +const isGzip = (u8: Uint8Array) => u8 && u8.length >= 2 && u8[0] === 0x1f && u8[1] === 0x8b; + +const arrayBufferToString = async (buf: ArrayBuffer): Promise => { + const u8 = new Uint8Array(buf); + console.log(isGzip(u8)); + if (isGzip(u8)) { + const decompressed = zlib.gunzipSync(Buffer.from(buf)); + return decompressed.toString('utf-8'); + + } + + // Not gzipped, decode as UTF-8 + return new TextDecoder().decode(buf); +}; + +export const fetchApiServerLogs = async (date?: string): Promise => { + const headers = await getAuthHeaders(); + const params = date ? { date } : {}; + const response = await axios.get( + `${BASE_URL}/admin/logs/api-server`, + { headers, params, responseType: 'arraybuffer' } + ); + return await arrayBufferToString(response.data); +}; + +export const fetchCrawlerLogs = async (date?: string): Promise => { + const headers = await getAuthHeaders(); + const params = date ? { date } : {}; + const response = await axios.get( + `${BASE_URL}/admin/logs/crawler`, + { headers, params, responseType: 'arraybuffer' } + ); + return await arrayBufferToString(response.data); +}; diff --git a/app/admin/api-server/crawler/CrawlerManager.tsx b/app/admin/api-server/crawler/CrawlerManager.tsx new file mode 100644 index 0000000..0e51f23 --- /dev/null +++ b/app/admin/api-server/crawler/CrawlerManager.tsx @@ -0,0 +1,291 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { fetchCrawlerHealth, saveCrawlerSession, restartCrawler } from '../api'; +import type { ChannelHealth } from '../types'; +import { Button } from '@/app/components/ui/button'; +import ConfirmModal from '@/app/components/ConfirmModal'; +import CompleteModal from '@/app/components/CompleteModal'; +import FailModal from '@/app/components/FailModal'; +import { ArrowLeft } from 'lucide-react'; +import Link from 'next/link'; + +export default function CrawlerManager() { + const [channels, setChannels] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [lastUpdated, setLastUpdated] = useState(null); + const [showSessionForm, setShowSessionForm] = useState(false); + const [sessionData, setSessionData] = useState({ + channelId: '', + jwtToken: '', + refreshToken: '', + }); + const [saveLoading, setSaveLoading] = useState(false); + const [saveSuccess, setSaveSuccess] = useState(false); + + const [selectedChannel, setSelectedChannel] = useState(null); + const [restartLoading, setRestartLoading] = useState(null); + + // Modal states + const [confirmOpen, setConfirmOpen] = useState(false); + const [successOpen, setSuccessOpen] = useState(false); + const [failOpen, setFailOpen] = useState(false); + const [modalMessage, setModalMessage] = useState(''); + const [targetChannel, setTargetChannel] = useState(null); + + const loadCrawlerHealth = useCallback(async () => { + try { + setLoading(true); + setError(''); + const data = await fetchCrawlerHealth(); + setChannels(data.channels); + setLastUpdated(new Date()); + } catch (err) { + setError(err instanceof Error ? err.message : '크롤러 상태를 불러오는데 실패했습니다.'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadCrawlerHealth(); + const interval = setInterval(() => { + void loadCrawlerHealth(); + }, 10 * 60 * 1000); // 10 minutes + + return () => clearInterval(interval); + }, [loadCrawlerHealth]); + + const initiateRestart = (channelId: string) => { + setTargetChannel(channelId); + setConfirmOpen(true); + }; + + const handleRestartConfirm = async () => { + if (!targetChannel) return; + + // Close confirm modal + setConfirmOpen(false); + + try { + setRestartLoading(targetChannel); + await restartCrawler(targetChannel); + await loadCrawlerHealth(); + setModalMessage(`${targetChannel} 채널 재시작 요청을 완료했습니다.`); + setSuccessOpen(true); + } catch (err) { + setModalMessage(err instanceof Error ? err.message : '재시작 요청에 실패했습니다.'); + setFailOpen(true); + } finally { + setRestartLoading(null); + setTargetChannel(null); + } + }; + + const handleSessionSave = async (e: React.FormEvent) => { + e.preventDefault(); + setSaveLoading(true); + setSaveSuccess(false); + setError(''); + + try { + await saveCrawlerSession(sessionData); + setSaveSuccess(true); + setSessionData({ channelId: '', jwtToken: '', refreshToken: '' }); + setTimeout(() => { + setShowSessionForm(false); + setSaveSuccess(false); + }, 2000); + } catch (err) { + setError(err instanceof Error ? err.message : '세션 저장에 실패했습니다.'); + } finally { + setSaveLoading(false); + } + }; + + return ( +
+ + + +
+

Crawler 관리

+

크롤러 Health 상태 및 세션 관리

+
+ + {error && ( +
+ {error} +
+ )} + + {/* Health Status Section */} +
+
+

채널 Health 상태

+
+ +
+ 마지막 갱신: {lastUpdated ? lastUpdated.toLocaleString() : '—'} +
+
+
+ + {loading ? ( +
로딩 중...
+ ) : channels.length === 0 ? ( +
채널 정보가 없습니다.
+ ) : ( +
+ {channels.map((channel) => ( +
setSelectedChannel(selectedChannel === channel.id ? null : channel.id)} + > +
+ {channel.id} + + {channel.healthy ? 'Healthy' : 'Unhealthy'} + +
+ + {selectedChannel === channel.id && ( +
+ +
+ )} +
+ ))} +
+ )} +
+ + {/* Session Management Section */} +
+
+

세션 관리

+ +
+ + {showSessionForm && ( +
+
+ + setSessionData({ ...sessionData, channelId: e.target.value })} + required + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" + /> +
+ +
+ +