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 orderedProfileData: ReadonlyArray = [ ['che', '체', 15003], ['kwe', '퀘', 15005], ['pwe', '풰', 15007], ['twe', '퉤', 15009], ['nya', '냐', 15011], ['pya', '퍄', 15013], ['hwe', '훼', 15015], ]; const orderedProfiles: ProfileFixture[] = orderedProfileData.map(([profile, korName, apiPort]) => ({ profileName: `${profile}:default`, profile, korName, color: '#b0b0b0', status: 'STOPPED', apiPort, })); const fulfill = async (route: Route, results: unknown[]): Promise => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results), }); }; const installImageFixture = async (page: Page): Promise | null> => { if (process.env.SAMMO_E2E_REAL_MAP_ASSETS === '1') { return null; } const requestedAssets = new Set(); const transparentPixel = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M/wHwAEAQH/69aQ6wAAAABJRU5ErkJggg==', 'base64' ); await page.route('https://sam-image.hided.net/game/**', async (route) => { requestedAssets.add(new URL(route.request().url()).pathname); await route.fulfill({ status: 200, contentType: 'image/png', body: transparentPixel }); }); return requestedAssets; }; 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: 'che', cityList: [ { id: 1, name: '낙양', level: 8, region: 2, x: 350, y: 250, path: [2] }, { id: 2, name: '허창', level: 1, region: 2, x: 480, y: 300, path: [1] }, ], regionMap: { 2: '중원' }, levelMap: { 1: '수', 8: '특' }, }); } if (operation === 'public.getCachedMap') { return response({ year: 200, month: 1, cityList: [ [1, 8, 41, 1, 2, 1], [2, 1, 0, 2, 2, 0], ], nationList: [ [1, '위', '#FF0000', 1], [2, '촉', '#0000FF', 2], ], 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) => { const requestedAssets = await installImageFixture(page); 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'); const roadLayer = panel.getByTestId('map-preview-road'); const castles = panel.getByTestId('map-preview-castle'); const nationBackgrounds = panel.getByTestId('map-preview-city-background'); await expect(panel.locator('.map-preview')).toHaveClass(/map-preview-detail/); await expect(roadLayer).toBeVisible(); await expect(roadLayer).toHaveCSS('background-image', /map\/che\/che_road\.png/); await expect(castles).toHaveCount(2); await expect(castles.nth(0)).toHaveAttribute('src', /\/game\/cast_8\.gif$/); await expect(castles.nth(1)).toHaveAttribute('src', /\/game\/cast_1\.gif$/); await expect(nationBackgrounds).toHaveCount(2); await expect(nationBackgrounds.nth(0)).toHaveCSS('background-image', /\/game\/bFF0000\.png/); const mapGeometry = await panel.locator('.map-preview-body').evaluate((mapBody) => { const rectOf = (element: Element | null) => { if (!element) throw new Error('expected map preview element'); const rect = element.getBoundingClientRect(); return { width: rect.width, height: rect.height }; }; return { body: rectOf(mapBody), road: rectOf(mapBody.querySelector('[data-testid="map-preview-road"]')), largeCastle: rectOf(mapBody.querySelector('[data-testid="map-preview-castle"]')), largeNationBackground: rectOf(mapBody.querySelector('[data-testid="map-preview-city-background"]')), }; }); expect(mapGeometry).toEqual({ body: { width: 700, height: 500 }, road: { width: 700, height: 500 }, largeCastle: { width: 32, height: 24 }, largeNationBackground: { width: 96, height: 72 }, }); if (requestedAssets) { await expect.poll(() => requestedAssets.has('/game/map/che/che_road.png')).toBe(true); await expect.poll(() => requestedAssets.has('/game/cast_8.gif')).toBe(true); await expect.poll(() => requestedAssets.has('/game/fFF0000.gif')).toBe(true); } else { await expect.poll(() => castles.nth(0).evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(32); await expect.poll(() => castles.nth(1).evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(16); await expect .poll(() => page.evaluate(async () => { const image = new Image(); image.src = 'https://sam-image.hided.net/game/map/che/che_road.png'; await image.decode(); return [image.naturalWidth, image.naturalHeight]; }) ) .toEqual([700, 500]); } const firstCity = panel.getByTestId('map-preview-city').first(); await expect(firstCity).not.toHaveAttribute('title'); await firstCity.hover(); const cityTooltip = panel.getByTestId('map-preview-city-tooltip'); await expect(cityTooltip).toBeVisible(); await expect(cityTooltip.locator('.tooltip-city-name')).toHaveText('【중원|특】낙양'); await expect(cityTooltip.locator('.tooltip-nation-name')).toHaveText('위'); const tooltipGeometry = await cityTooltip.evaluate((tooltip) => { const rect = tooltip.getBoundingClientRect(); const style = getComputedStyle(tooltip); return { width: rect.width, height: rect.height, backgroundColor: style.backgroundColor, fontSize: style.fontSize, lineHeight: style.lineHeight, }; }); expect(tooltipGeometry.width).toBeGreaterThanOrEqual(120); expect(tooltipGeometry.height).toBe(32); expect(tooltipGeometry.backgroundColor).toBe('rgb(30, 164, 255)'); expect(tooltipGeometry.fontSize).toBe('14px'); expect(tooltipGeometry.lineHeight).toBe('15px'); await page.screenshot({ path: testInfo.outputPath('public-map-city-hover-desktop.png'), fullPage: true }); 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.5)'); 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({ panel: geometry, map: mapGeometry, tooltip: tooltipGeometry }, 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'); const panel = page.getByTestId('public-map-preview-panel'); await expect(panel).toContainText('유저 22 / 500'); await expect(panel.getByTestId('map-preview-road')).toBeVisible(); await expect(panel.getByTestId('map-preview-castle')).toHaveCount(2); const mapBox = await panel.locator('.map-preview-body').boundingBox(); expect(mapBox?.width).toBeGreaterThan(280); expect(mapBox?.width).toBeLessThanOrEqual(366); expect(mapBox?.height).toBeCloseTo((mapBox?.width ?? 0) * (5 / 7), 0); const rightCity = panel.getByTestId('map-preview-city').nth(1); await rightCity.hover(); const tooltip = panel.getByTestId('map-preview-city-tooltip'); await expect(tooltip).toContainText('【중원|수】허창'); await expect(tooltip).toContainText('촉'); const tooltipBounds = await tooltip.evaluate((element) => { const tooltipRect = element.getBoundingClientRect(); const mapRect = element.parentElement?.getBoundingClientRect(); if (!mapRect) throw new Error('expected map preview body'); return { left: tooltipRect.left - mapRect.left, right: mapRect.right - tooltipRect.right, width: tooltipRect.width, }; }); expect(tooltipBounds.left).toBeGreaterThanOrEqual(0); expect(tooltipBounds.right).toBeGreaterThanOrEqual(0); expect(tooltipBounds.width).toBeGreaterThanOrEqual(120); 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 }); }); test('renders the Gateway profile order returned by the API', async ({ page }, testInfo) => { await installGatewayFixture(page, orderedProfiles, true); await page.setViewportSize({ width: 1200, height: 900 }); await page.goto('lobby'); const renderedProfiles = await page.locator('tbody tr td:first-child > div:first-child').allTextContents(); expect(renderedProfiles.map((name) => name.trim())).toEqual([ '체섭', '퀘섭', '풰섭', '퉤섭', '냐섭', '퍄섭', '훼섭', ]); await page.screenshot({ path: testInfo.outputPath('gateway-profile-order.png'), fullPage: true }); });