fix(gateway): 모바일 서버 목록을 2행으로 배치

좁은 화면의 760px 내부 가로 스크롤을 제거하고 서버 정보와 장수 동작을 두 행으로 재배치한다. Chromium 회귀 테스트로 320px, 390px와 799/800px 경계를 검증한다.
This commit is contained in:
2026-08-19 08:55:37 +00:00
parent 80e97b8e63
commit 6122cea3a4
2 changed files with 235 additions and 46 deletions
+153 -39
View File
@@ -54,6 +54,7 @@ type LobbyFixtureOptions = {
turntime?: string; turntime?: string;
lobbyBundleFailures?: number; lobbyBundleFailures?: number;
profileStatus?: 'RUNNING' | 'PREOPEN' | 'PAUSED' | 'COMPLETED' | 'STOPPED'; profileStatus?: 'RUNNING' | 'PREOPEN' | 'PAUSED' | 'COMPLETED' | 'STOPPED';
includeStoppedProfile?: boolean;
}; };
const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) => { const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) => {
@@ -80,6 +81,7 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) =>
turntime = '2026-07-30 00:05:00', turntime = '2026-07-30 00:05:00',
lobbyBundleFailures = 0, lobbyBundleFailures = 0,
profileStatus = 'RUNNING', profileStatus = 'RUNNING',
includeStoppedProfile = false,
} = options; } = options;
let remainingLobbyBundleFailures = lobbyBundleFailures; let remainingLobbyBundleFailures = lobbyBundleFailures;
const gameOperations: Array<{ operation: string; authorization: string | undefined }> = []; const gameOperations: Array<{ operation: string; authorization: string | undefined }> = [];
@@ -108,40 +110,76 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) =>
return response(''); return response('');
} }
if (operation === 'lobby.profiles') { if (operation === 'lobby.profiles') {
return response([ return response(
{ [
profileName: 'hwe:903', {
profile: 'hwe', profileName: 'hwe:903',
instanceKey: '903', profile: 'hwe',
currentScenario: '903', instanceKey: '903',
scenario: '903', currentScenario: '903',
status: profileStatus, scenario: '903',
lifecycle: { status: profileStatus,
runtimeExpected: profileStatus !== 'STOPPED', lifecycle: {
userAccessible: profileStatus !== 'STOPPED', runtimeExpected: profileStatus !== 'STOPPED',
turnsRunning: profileStatus === 'RUNNING', userAccessible: profileStatus !== 'STOPPED',
operatorResumable: profileStatus === 'PAUSED' || profileStatus === 'STOPPED', turnsRunning: profileStatus === 'RUNNING',
dataInitialized: true, operatorResumable: profileStatus === 'PAUSED' || profileStatus === 'STOPPED',
dataInitialized: true,
},
apiPort: 15015,
runtime: {
apiRunning: true,
daemonRunning: true,
auctionRunning: true,
battleSimRunning: true,
tournamentRunning: true,
},
korName: 'hwe',
color: '#ffffff',
localAccountPolicy: {
accessAllowed: true,
canCreateGeneral,
requiresKakaoVerification,
graceEndsAt: null,
specialAccess,
},
}, },
apiPort: 15015, includeStoppedProfile
runtime: { ? {
apiRunning: true, profileName: 'che:2601',
daemonRunning: true, profile: 'che',
auctionRunning: true, instanceKey: '2601',
battleSimRunning: true, currentScenario: '2601',
tournamentRunning: true, scenario: '2601',
}, status: 'STOPPED',
korName: 'hwe', lifecycle: {
color: '#ffffff', runtimeExpected: false,
localAccountPolicy: { userAccessible: false,
accessAllowed: true, turnsRunning: false,
canCreateGeneral, operatorResumable: true,
requiresKakaoVerification, dataInitialized: true,
graceEndsAt: null, },
specialAccess, apiPort: 15003,
}, runtime: {
}, apiRunning: false,
]); daemonRunning: false,
auctionRunning: false,
battleSimRunning: false,
tournamentRunning: false,
},
korName: 'che',
color: '#f59e0b',
localAccountPolicy: {
accessAllowed: false,
canCreateGeneral: false,
requiresKakaoVerification: false,
graceEndsAt: null,
specialAccess: null,
},
}
: null,
].filter((profile) => profile !== null)
);
} }
if (operation === 'auth.issueGameSession') { if (operation === 'auth.issueGameSession') {
return response({ return response({
@@ -282,9 +320,11 @@ test('automatically recovers profile details after a transient update outage', a
expect(gameOperations.filter(({ operation }) => operation === 'lobby.info')).toHaveLength(2); expect(gameOperations.filter(({ operation }) => operation === 'lobby.info')).toHaveLength(2);
}); });
test('offers a keyboard-accessible immediate retry without mobile overflow', async ({ page }, testInfo) => { test('uses two-row mobile server cards without horizontal scrolling and keeps retry keyboard-accessible', async ({
page,
}, testInfo) => {
// Keep the scheduled retry in the error state so it cannot race the manual retry after the screenshot. // Keep the scheduled retry in the error state so it cannot race the manual retry after the screenshot.
await installFixture(page, { lobbyBundleFailures: 2 }); await installFixture(page, { lobbyBundleFailures: 2, includeStoppedProfile: true });
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await page.goto('lobby'); await page.goto('lobby');
@@ -295,14 +335,27 @@ test('offers a keyboard-accessible immediate retry without mobile overflow', asy
await retry.focus(); await retry.focus();
await expect(retry).toBeFocused(); await expect(retry).toBeFocused();
const geometry = await tableScroll.evaluate((scrollElement) => { const geometry = await tableScroll.evaluate((scrollElement) => {
const row = scrollElement.querySelector('tbody tr'); const rows = [...scrollElement.querySelectorAll('tbody tr')];
const row = rows[0];
const button = row?.querySelector('button'); const button = row?.querySelector('button');
const serverCell = row?.querySelector('.profile-server-cell');
const infoCell = row?.querySelector('.profile-info-cell');
const portraitCell = row?.querySelector('.profile-portrait-cell');
const generalCell = row?.querySelector('.profile-general-cell');
const actionCell = row?.querySelector('.profile-action-cell');
if (!row) throw new Error('expected profile row'); if (!row) throw new Error('expected profile row');
if (!button) throw new Error('expected profile retry button'); if (!button) throw new Error('expected profile retry button');
if (!serverCell || !infoCell || !portraitCell || !generalCell || !actionCell) {
throw new Error('expected all profile cells');
}
const scrollRect = scrollElement.getBoundingClientRect(); const scrollRect = scrollElement.getBoundingClientRect();
const rowRect = row.getBoundingClientRect(); const rowRect = row.getBoundingClientRect();
const buttonRect = button.getBoundingClientRect(); const buttonRect = button.getBoundingClientRect();
const style = getComputedStyle(button); const style = getComputedStyle(button);
const rect = (element: Element) => {
const value = element.getBoundingClientRect();
return { top: value.top, right: value.right, bottom: value.bottom, left: value.left, width: value.width };
};
return { return {
pageScrollWidth: document.documentElement.scrollWidth, pageScrollWidth: document.documentElement.scrollWidth,
scroll: { scroll: {
@@ -312,7 +365,15 @@ test('offers a keyboard-accessible immediate retry without mobile overflow', asy
scrollWidth: scrollElement.scrollWidth, scrollWidth: scrollElement.scrollWidth,
}, },
row: { left: rowRect.left, right: rowRect.right, width: rowRect.width }, row: { left: rowRect.left, right: rowRect.right, width: rowRect.width },
rows: rows.map((element) => rect(element)),
button: { left: buttonRect.left, right: buttonRect.right, width: buttonRect.width }, button: { left: buttonRect.left, right: buttonRect.right, width: buttonRect.width },
cells: {
server: rect(serverCell),
info: rect(infoCell),
portrait: rect(portraitCell),
general: rect(generalCell),
action: rect(actionCell),
},
viewportWidth: window.innerWidth, viewportWidth: window.innerWidth,
outlineStyle: style.outlineStyle, outlineStyle: style.outlineStyle,
outlineWidth: style.outlineWidth, outlineWidth: style.outlineWidth,
@@ -320,17 +381,70 @@ test('offers a keyboard-accessible immediate retry without mobile overflow', asy
}); });
expect(geometry.pageScrollWidth).toBe(geometry.viewportWidth); expect(geometry.pageScrollWidth).toBe(geometry.viewportWidth);
expect(geometry.scroll.clientWidth).toBeLessThanOrEqual(geometry.viewportWidth); expect(geometry.scroll.clientWidth).toBeLessThanOrEqual(geometry.viewportWidth);
expect(geometry.scroll.scrollWidth).toBe(760); expect(geometry.scroll.scrollWidth).toBe(geometry.scroll.clientWidth);
expect(geometry.row.width).toBe(760); expect(geometry.row.width).toBe(geometry.scroll.clientWidth);
expect(geometry.rows).toHaveLength(2);
expect(geometry.rows[0]?.bottom).toBeLessThanOrEqual(geometry.rows[1]?.top ?? 0);
expect(geometry.rows.every((item) => item.width === geometry.scroll.clientWidth)).toBe(true);
expect(geometry.button.left).toBeGreaterThanOrEqual(geometry.scroll.left); expect(geometry.button.left).toBeGreaterThanOrEqual(geometry.scroll.left);
expect(geometry.button.right).toBeLessThanOrEqual(geometry.scroll.right); expect(geometry.button.right).toBeLessThanOrEqual(geometry.scroll.right);
expect(geometry.cells.server.top).toBeCloseTo(geometry.cells.info.top, 0);
expect(geometry.cells.server.left).toBeCloseTo(geometry.row.left, 0);
expect(geometry.cells.info.right).toBeCloseTo(geometry.row.right, 0);
expect(geometry.cells.portrait.top).toBeCloseTo(geometry.cells.general.top, 0);
expect(geometry.cells.general.top).toBeCloseTo(geometry.cells.action.top, 0);
expect(geometry.cells.portrait.top).toBeGreaterThanOrEqual(geometry.cells.server.bottom);
expect(geometry.cells.portrait.left).toBeCloseTo(geometry.row.left, 0);
expect(geometry.cells.action.right).toBeCloseTo(geometry.row.right, 0);
expect(geometry.outlineStyle).toBe('solid'); expect(geometry.outlineStyle).toBe('solid');
expect(geometry.outlineWidth).toBe('2px'); expect(geometry.outlineWidth).toBe('2px');
await page.screenshot({ path: testInfo.outputPath('gateway-profile-retry-mobile.png'), fullPage: true });
await retry.click(); await retry.click();
await expect(row).toContainText('선택장수'); await expect(row).toContainText('선택장수');
await expect(row.getByTestId('profile-info-retrying')).toHaveCount(0); await expect(row.getByTestId('profile-info-retrying')).toHaveCount(0);
const enterButton = row.getByRole('button', { name: '입장' });
await expect(enterButton).toBeVisible();
const enterBackground = await enterButton.evaluate((element) => getComputedStyle(element).backgroundColor);
await enterButton.hover();
await expect
.poll(() => enterButton.evaluate((element) => getComputedStyle(element).backgroundColor))
.not.toBe(enterBackground);
await page.screenshot({ path: testInfo.outputPath('gateway-profile-two-row-mobile.png'), fullPage: true });
await page.setViewportSize({ width: 320, height: 700 });
await expect
.poll(() =>
tableScroll.evaluate((element) => ({
documentWidth: document.documentElement.scrollWidth,
viewportWidth: window.innerWidth,
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
rowWidth: element.querySelector('tbody tr')?.getBoundingClientRect().width,
}))
)
.toEqual({ documentWidth: 320, viewportWidth: 320, clientWidth: 286, scrollWidth: 286, rowWidth: 286 });
await page.setViewportSize({ width: 799, height: 900 });
await expect
.poll(() =>
tableScroll.evaluate((element) => ({
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
rowDisplay: getComputedStyle(element.querySelector('tbody tr')!).display,
}))
)
.toEqual({ clientWidth: 765, scrollWidth: 765, rowDisplay: 'grid' });
await page.setViewportSize({ width: 800, height: 900 });
await expect
.poll(() =>
tableScroll.evaluate((element) => ({
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
rowDisplay: getComputedStyle(element.querySelector('tbody tr')!).display,
}))
)
.toEqual({ clientWidth: 766, scrollWidth: 766, rowDisplay: 'table-row' });
}); });
test('applies the signed general-acquisition policy to both create and possession actions', async ({ page }) => { test('applies the signed general-acquisition policy to both create and possession actions', async ({ page }) => {
+82 -7
View File
@@ -410,8 +410,8 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
> >
</div> </div>
<div class="overflow-x-auto" data-testid="profile-table-scroll"> <div class="profile-table-frame" data-testid="profile-table-scroll">
<table class="w-full min-w-[760px] text-sm text-left"> <table class="profile-table w-full text-sm text-left">
<thead class="bg-zinc-800 text-zinc-400 uppercase text-xs"> <thead class="bg-zinc-800 text-zinc-400 uppercase text-xs">
<tr> <tr>
<th class="px-4 py-3 border-b border-zinc-700 w-24 text-center"> </th> <th class="px-4 py-3 border-b border-zinc-700 w-24 text-center"> </th>
@@ -429,7 +429,7 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
class="hover:bg-zinc-800/50 transition-colors" class="hover:bg-zinc-800/50 transition-colors"
> >
<!-- Server Name --> <!-- Server Name -->
<td class="px-4 py-4 text-center border-r border-zinc-800"> <td class="profile-server-cell px-4 py-4 text-center border-r border-zinc-800">
<div <div
:style="{ color: profile.color }" :style="{ color: profile.color }"
class="text-lg font-bold cursor-help" class="text-lg font-bold cursor-help"
@@ -478,7 +478,7 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
</td> </td>
<!-- Server Info --> <!-- Server Info -->
<td class="px-4 py-4 border-r border-zinc-800"> <td class="profile-info-cell px-4 py-4 border-r border-zinc-800">
<template v-if="profileDetails[profile.profileName]"> <template v-if="profileDetails[profile.profileName]">
<div class="space-y-1"> <div class="space-y-1">
<div> <div>
@@ -532,7 +532,7 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
</td> </td>
<!-- Character Info --> <!-- Character Info -->
<td class="px-2 py-4 w-16 border-r border-zinc-800"> <td class="profile-portrait-cell px-2 py-4 w-16 border-r border-zinc-800">
<div <div
v-if="profileDetails[profile.profileName]?.myGeneral" v-if="profileDetails[profile.profileName]?.myGeneral"
class="w-12 h-12 mx-auto bg-zinc-800 rounded overflow-hidden border border-zinc-700" class="w-12 h-12 mx-auto bg-zinc-800 rounded overflow-hidden border border-zinc-700"
@@ -546,7 +546,7 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
/> />
</div> </div>
</td> </td>
<td class="px-4 py-4 border-r border-zinc-800 text-center"> <td class="profile-general-cell px-4 py-4 border-r border-zinc-800 text-center">
<div v-if="profileDetails[profile.profileName]?.myGeneral" class="font-medium"> <div v-if="profileDetails[profile.profileName]?.myGeneral" class="font-medium">
{{ profileDetails[profile.profileName]?.myGeneral?.name }} {{ profileDetails[profile.profileName]?.myGeneral?.name }}
</div> </div>
@@ -554,7 +554,7 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
</td> </td>
<!-- Action --> <!-- Action -->
<td class="px-4 py-4 text-center"> <td class="profile-action-cell px-4 py-4 text-center">
<template v-if="profileDetails[profile.profileName]"> <template v-if="profileDetails[profile.profileName]">
<button <button
v-if="profileDetails[profile.profileName]?.myGeneral" v-if="profileDetails[profile.profileName]?.myGeneral"
@@ -785,6 +785,14 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
padding: 10px; padding: 10px;
} }
.profile-table-frame {
overflow-x: auto;
}
.profile-table {
min-width: 760px;
}
.map-preview-tabs { .map-preview-tabs {
display: flex; display: flex;
gap: 4px; gap: 4px;
@@ -793,6 +801,73 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
padding: 0 2px; padding: 0 2px;
} }
@media (max-width: 799px) {
.profile-table-frame {
overflow-x: visible;
}
.profile-table {
display: block;
width: 100%;
min-width: 0;
}
.profile-table thead {
display: none;
}
.profile-table tbody {
display: grid;
gap: 1px;
background: #27272a;
}
.profile-table tbody tr {
display: grid;
min-width: 0;
grid-template-areas:
'server info info'
'portrait general action';
grid-template-columns: 88px minmax(0, 1fr) 104px;
background: #18181b;
}
.profile-table tbody td {
box-sizing: border-box;
width: auto;
min-width: 0;
padding: 12px 8px;
}
.profile-server-cell {
grid-area: server;
border-bottom: 1px solid #27272a;
}
.profile-server-cell > div {
overflow-wrap: anywhere;
white-space: normal;
}
.profile-info-cell {
grid-area: info;
border-right: 0;
border-bottom: 1px solid #27272a;
}
.profile-portrait-cell {
grid-area: portrait;
}
.profile-general-cell {
grid-area: general;
}
.profile-action-cell {
grid-area: action;
}
}
.map-preview-tab { .map-preview-tab {
flex: 0 0 auto; flex: 0 0 auto;
border: 1px solid #3f3f46; border: 1px solid #3f3f46;