merge: Gateway 모바일 서버 목록 2행 배치 통합
This commit is contained in:
@@ -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,7 +110,8 @@ 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',
|
profileName: 'hwe:903',
|
||||||
profile: 'hwe',
|
profile: 'hwe',
|
||||||
@@ -141,7 +144,42 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) =>
|
|||||||
specialAccess,
|
specialAccess,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
includeStoppedProfile
|
||||||
|
? {
|
||||||
|
profileName: 'che:2601',
|
||||||
|
profile: 'che',
|
||||||
|
instanceKey: '2601',
|
||||||
|
currentScenario: '2601',
|
||||||
|
scenario: '2601',
|
||||||
|
status: 'STOPPED',
|
||||||
|
lifecycle: {
|
||||||
|
runtimeExpected: false,
|
||||||
|
userAccessible: false,
|
||||||
|
turnsRunning: false,
|
||||||
|
operatorResumable: true,
|
||||||
|
dataInitialized: true,
|
||||||
|
},
|
||||||
|
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 }) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user