diff --git a/app/gateway-frontend/e2e/playwright.config.mjs b/app/gateway-frontend/e2e/playwright.config.mjs index 7d73c694..186147a1 100644 --- a/app/gateway-frontend/e2e/playwright.config.mjs +++ b/app/gateway-frontend/e2e/playwright.config.mjs @@ -18,6 +18,7 @@ export default defineConfig({ 'gateway-notice-html.spec.ts', 'kakao-otp.spec.ts', 'kakao-account-recovery.spec.ts', + 'public-map-tabs.spec.ts', ], fullyParallel: false, workers: 1, diff --git a/app/gateway-frontend/e2e/public-map-tabs.spec.ts b/app/gateway-frontend/e2e/public-map-tabs.spec.ts new file mode 100644 index 00000000..17792e98 --- /dev/null +++ b/app/gateway-frontend/e2e/public-map-tabs.spec.ts @@ -0,0 +1,214 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; + +const response = (data: unknown) => ({ result: { data } }); +const operationNames = (route: Route): string[] => { + const url = new URL(route.request().url()); + return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(','); +}; + +type ProfileFixture = { + profileName: string; + profile: string; + korName: string; + color: string; + status: 'RUNNING' | 'PREOPEN' | 'STOPPED'; + apiPort: number; +}; + +const profiles: ProfileFixture[] = [ + { + profileName: 'che:2', + profile: 'che', + korName: '체', + color: '#ff8080', + status: 'RUNNING', + apiPort: 15003, + }, + { + profileName: 'hwe:2', + profile: 'hwe', + korName: '훼', + color: '#80c0ff', + status: 'PREOPEN', + apiPort: 15015, + }, + { + profileName: 'kwe:2', + profile: 'kwe', + korName: '퀘', + color: '#b0b0b0', + status: 'STOPPED', + apiPort: 15005, + }, +]; + +const fulfill = async (route: Route, results: unknown[]): Promise => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(results), + }); +}; + +const installGatewayFixture = async (page: Page, fixtureProfiles: ProfileFixture[], authenticated: boolean) => { + if (authenticated) { + await page.addInitScript(() => window.localStorage.setItem('sammo-session-token', 'map-tab-session')); + } + await page.route('**/gateway/api/trpc/**', async (route) => { + const results = operationNames(route).map((operation) => { + if (operation === 'me') { + return response( + authenticated + ? { + id: 'map-tab-user', + username: 'map-tab-user', + displayName: '지도 탭 사용자', + roles: [], + kakaoVerified: true, + createdAt: '2026-08-08T00:00:00.000Z', + } + : null + ); + } + if (operation === 'lobby.notice') return response(''); + if (operation === 'lobby.profiles') return response(fixtureProfiles); + if (operation === 'auth.issueGameSession') { + const body = route.request().postData() ?? ''; + const selected = fixtureProfiles.find((profile) => body.includes(profile.profileName)); + return response({ + profile: selected?.profileName ?? fixtureProfiles[0]?.profileName, + gameToken: 'map-tab-game-token', + expiresAt: '2026-08-08T01:00:00.000Z', + }); + } + throw new Error(`Unhandled gateway operation: ${operation}`); + }); + await fulfill(route, results); + }); +}; + +const installGameFixture = async (page: Page, profile: ProfileFixture, userCnt: number) => { + await page.route(`**/${profile.profile}/api/trpc/**`, async (route) => { + const results = operationNames(route).map((operation) => { + if (operation === 'auth.exchangeGatewayToken') { + return response({ + accessToken: `${profile.profile}-access-token`, + profile: profile.profileName, + expiresAt: '2026-08-08T01:00:00.000Z', + }); + } + if (operation === 'lobby.info') { + return response({ + year: 200, + month: profile.profile === 'che' ? 1 : 2, + userCnt, + maxUserCnt: 500, + npcCnt: 0, + nationCnt: profile.profile === 'che' ? 2 : 3, + turnTerm: 10, + fictionMode: '가상', + starttime: '2026-08-08 00:00:00', + opentime: '2026-08-08 00:00:00', + turntime: '2026-08-08 00:10:00', + otherTextInfo: '', + isUnited: 0, + selectionPoolEnabled: true, + npcPossessionEnabled: false, + myGeneral: null, + }); + } + if (operation === 'public.getMapLayout') return response({ mapName: profile.profile, cityList: [] }); + if (operation === 'public.getCachedMap') { + return response({ year: 200, month: 1, cityList: [], nationList: [], history: [] }); + } + throw new Error(`Unhandled ${profile.profile} operation: ${operation}`); + }); + await fulfill(route, results); + }); +}; + +test('shows one public map panel and switches it by hover, click, and keyboard', async ({ page }, testInfo) => { + await installGatewayFixture(page, profiles, true); + await installGameFixture(page, profiles[0]!, 11); + await installGameFixture(page, profiles[1]!, 22); + + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('lobby'); + + const tablist = page.getByRole('tablist', { name: '공개 지도 서버 선택' }); + const cheTab = tablist.getByRole('tab', { name: '체섭' }); + const hweTab = tablist.getByRole('tab', { name: '훼섭' }); + const panel = page.getByTestId('public-map-preview-panel'); + await expect(tablist.getByRole('tab')).toHaveCount(2); + await expect(tablist.getByRole('tab', { name: '퀘섭' })).toHaveCount(0); + await expect(panel).toHaveCount(1); + await expect(cheTab).toHaveAttribute('aria-selected', 'true'); + await expect(panel).toContainText('유저 11 / 500'); + + await hweTab.hover(); + await expect(hweTab).toHaveAttribute('aria-selected', 'true'); + await expect(panel).toContainText('유저 22 / 500'); + + await cheTab.click(); + await expect(cheTab).toHaveAttribute('aria-selected', 'true'); + await cheTab.press('ArrowRight'); + await expect(hweTab).toBeFocused(); + await expect(hweTab).toHaveAttribute('aria-selected', 'true'); + + const geometry = await panel.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + width: rect.width, + borderLeft: style.borderLeft, + borderTopWidth: style.borderTopWidth, + backgroundColor: style.backgroundColor, + }; + }); + expect(geometry.width).toBeLessThanOrEqual(992); + expect(geometry.borderLeft).toBe('1px solid rgb(63, 63, 70)'); + expect(geometry.borderTopWidth).toBe('0px'); + expect(geometry.backgroundColor).toBe('rgba(9, 9, 11, 0.498)'); + await page.screenshot({ path: testInfo.outputPath('public-map-tabs-desktop.png'), fullPage: true }); + await testInfo.attach('public-map-tabs-desktop-geometry', { + body: Buffer.from(`${JSON.stringify(geometry, null, 2)}\n`), + contentType: 'application/json', + }); +}); + +test.describe('touch navigation', () => { + test.use({ hasTouch: true }); + + test('switches the single map panel by touch-sized tab on mobile', async ({ page }, testInfo) => { + await installGatewayFixture(page, profiles, true); + await installGameFixture(page, profiles[0]!, 11); + await installGameFixture(page, profiles[1]!, 22); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('lobby'); + const hweTab = page.getByRole('tab', { name: '훼섭' }); + const box = await hweTab.boundingBox(); + expect(box?.height).toBeGreaterThanOrEqual(34); + await hweTab.tap(); + await expect(hweTab).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByTestId('public-map-preview-panel')).toContainText('유저 22 / 500'); + await page.screenshot({ path: testInfo.outputPath('public-map-tabs-mobile.png'), fullPage: true }); + }); +}); + +test('treats an all-closed profile list as a normal empty login status', async ({ page }, testInfo) => { + let gameRequestCount = 0; + await installGatewayFixture(page, [profiles[2]!], false); + await page.route('**/kwe/api/trpc/**', async (route) => { + gameRequestCount += 1; + await route.abort(); + }); + + await page.goto('/gateway/'); + const status = page.locator('#map-subframe'); + await expect(status).toContainText('서버 현황'); + await expect(status).toContainText('현재 공개 중인 서버가 없습니다.'); + await expect(status).not.toContainText('Failed to fetch'); + expect(gameRequestCount).toBe(0); + await page.screenshot({ path: testInfo.outputPath('login-no-public-server.png'), fullPage: true }); +}); diff --git a/app/gateway-frontend/src/views/HomeView.vue b/app/gateway-frontend/src/views/HomeView.vue index 1620d7e9..f02a615a 100644 --- a/app/gateway-frontend/src/views/HomeView.vue +++ b/app/gateway-frontend/src/views/HomeView.vue @@ -33,7 +33,7 @@ const info = ref(null); const mapData = ref(null); const mapLayout = ref(null); -const statusTitle = computed(() => `${profile.value?.korName ?? '체'} 현황`); +const statusTitle = computed(() => (profile.value ? `${profile.value.korName} 현황` : '서버 현황')); const dateText = computed(() => { if (!info.value) { return ''; @@ -45,15 +45,18 @@ const seasonStatus = computed(() => (info.value ? resolveServerSeasonStatus(info const loadPublicStatus = async (): Promise => { statusLoading.value = true; statusError.value = ''; + profile.value = null; + info.value = null; + mapData.value = null; + mapLayout.value = null; try { const profiles = await trpc.lobby.profiles.query(); profile.value = profiles.find((entry) => entry.status === 'RUNNING') ?? profiles.find((entry) => entry.status === 'PREOPEN') ?? - profiles[0] ?? null; if (!profile.value) { - statusError.value = '공개 중인 서버가 없습니다.'; + statusError.value = '현재 공개 중인 서버가 없습니다.'; return; } const game = createGameTrpc(profile.value.profile, profile.value.apiPort); @@ -65,8 +68,8 @@ const loadPublicStatus = async (): Promise => { info.value = nextInfo; mapLayout.value = nextLayout; mapData.value = nextMap; - } catch (error) { - statusError.value = error instanceof Error ? error.message : '서버 현황을 불러오지 못했습니다.'; + } catch { + statusError.value = '서버 현황을 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.'; } finally { statusLoading.value = false; } diff --git a/app/gateway-frontend/src/views/LobbyView.vue b/app/gateway-frontend/src/views/LobbyView.vue index 0328a03b..ad4268af 100644 --- a/app/gateway-frontend/src/views/LobbyView.vue +++ b/app/gateway-frontend/src/views/LobbyView.vue @@ -1,5 +1,5 @@