fix(gateway): 일시정지 프로필을 로비에 표시

This commit is contained in:
2026-08-15 16:37:01 +00:00
parent 9eabf3003a
commit 3a6ef36d01
5 changed files with 79 additions and 17 deletions
@@ -53,6 +53,7 @@ type LobbyFixtureOptions = {
opentime?: string;
turntime?: string;
lobbyBundleFailures?: number;
profileStatus?: 'RUNNING' | 'PREOPEN' | 'PAUSED' | 'COMPLETED';
};
const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) => {
@@ -78,6 +79,7 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) =>
opentime = '2026-07-30 00:00:00',
turntime = '2026-07-30 00:05:00',
lobbyBundleFailures = 0,
profileStatus = 'RUNNING',
} = options;
let remainingLobbyBundleFailures = lobbyBundleFailures;
const gameOperations: Array<{ operation: string; authorization: string | undefined }> = [];
@@ -111,7 +113,7 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) =>
profileName: 'hwe:903',
profile: 'hwe',
scenario: '903',
status: 'RUNNING',
status: profileStatus,
apiPort: 15015,
runtime: {
apiRunning: true,
@@ -230,6 +232,23 @@ test('exchanges the gateway token before loading authenticated lobby general dat
});
});
test('loads and labels a PAUSED profile whose runtime remains available', async ({ page }, testInfo) => {
const gameOperations = await installFixture(page, { profileStatus: 'PAUSED' });
await page.setViewportSize({ width: 1365, height: 900 });
await page.goto('lobby');
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
const pausedStatus = row.getByTestId('profile-paused-status');
await expect(pausedStatus).toHaveText('턴 진행 일시정지');
await expect(pausedStatus).toHaveCSS('color', 'oklch(0.879 0.169 91.605)');
await expect(row).toContainText('선택장수');
await expect(row).not.toContainText('정보를 불러오는 중');
await expect(row.getByRole('button', { name: '입장' })).toBeVisible();
expect(gameOperations.some(({ operation }) => operation === 'lobby.info')).toBe(true);
await expect(page.getByRole('tab', { name: 'hwe섭' })).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('gateway-paused-profile-lobby.png'), fullPage: true });
});
test('automatically recovers profile details after a transient update outage', async ({ page }) => {
const gameOperations = await installFixture(page, { lobbyBundleFailures: 1 });
@@ -11,7 +11,7 @@ type ProfileFixture = {
profile: string;
korName: string;
color: string;
status: 'RUNNING' | 'PREOPEN' | 'STOPPED';
status: 'RUNNING' | 'PREOPEN' | 'PAUSED' | 'COMPLETED' | 'STOPPED';
apiPort: number;
};
@@ -29,7 +29,7 @@ const profiles: ProfileFixture[] = [
profile: 'hwe',
korName: '훼',
color: '#80c0ff',
status: 'PREOPEN',
status: 'PAUSED',
apiPort: 15015,
},
{
@@ -371,6 +371,17 @@ test('treats an all-closed profile list as a normal empty login status', async (
await page.screenshot({ path: testInfo.outputPath('login-no-public-server.png'), fullPage: true });
});
test('shows a PAUSED profile with a live runtime on the public login status', async ({ page }) => {
await installGatewayFixture(page, [profiles[1]!], false);
await installGameFixture(page, profiles[1]!, 22);
await page.goto('/gateway/');
const status = page.locator('#map-subframe');
await expect(status).toContainText('훼 현황');
await expect(status).toContainText('유저 22명');
await expect(status).not.toContainText('현재 공개 중인 서버가 없습니다.');
});
test('renders the Gateway profile order returned by the API', async ({ page }, testInfo) => {
await installGatewayFixture(page, orderedProfiles, true);
+4 -3
View File
@@ -19,6 +19,7 @@ type LobbyProfile = GatewayOutput['lobby']['profiles'][number];
type LobbyInfo = GameOutput['lobby']['info'];
type PublicMap = GameOutput['public']['getCachedMap'];
type PublicMapLayout = GameOutput['public']['getMapLayout'];
const PROFILE_PUBLIC_STATUS_ORDER: LobbyProfile['status'][] = ['RUNNING', 'PREOPEN', 'PAUSED', 'COMPLETED'];
const router = useRouter();
const username = ref('');
@@ -52,9 +53,9 @@ const loadPublicStatus = async (): Promise<void> => {
try {
const profiles = await trpc.lobby.profiles.query();
profile.value =
profiles.find((entry) => entry.status === 'RUNNING') ??
profiles.find((entry) => entry.status === 'PREOPEN') ??
null;
PROFILE_PUBLIC_STATUS_ORDER.map((status) => profiles.find((entry) => entry.status === status)).find(
(entry) => entry !== undefined
) ?? null;
if (!profile.value) {
statusError.value = '현재 공개 중인 서버가 없습니다.';
return;
+21 -5
View File
@@ -33,6 +33,7 @@ type ProfileLoadState = {
const PROFILE_REQUEST_TIMEOUT_MS = 10_000;
const PROFILE_RETRY_DELAYS_MS = [1_000, 2_000, 3_000, 5_000, 8_000, 15_000] as const;
const PROFILE_RUNTIME_STATUSES = new Set<LobbyProfile['status']>(['RUNNING', 'PREOPEN', 'PAUSED', 'COMPLETED']);
const router = useRouter();
const me = ref<MeOutput>(null);
@@ -67,7 +68,7 @@ const needsKakaoVerification = computed(
const userIconBaseUrl = configuredUserIconPublicUrl();
const sharedIconBaseUrl = configuredSharedIconPublicUrl();
const publicMapProfiles = computed(() =>
profiles.value.filter((profile) => profile.status === 'RUNNING' || profile.status === 'PREOPEN')
profiles.value.filter((profile) => PROFILE_RUNTIME_STATUSES.has(profile.status))
);
const selectedMapProfile = computed(
() => publicMapProfiles.value.find((profile) => profile.profileName === selectedMapProfileName.value) ?? null
@@ -109,6 +110,12 @@ const handleMapTabKeydown = (event: KeyboardEvent, profileName: string): void =>
const formatGraceEndsAt = (value: string | null | undefined): string => formatServerDateTime(value);
const serverSeasonStatus = (info: LobbyInfo) => resolveServerSeasonStatus(info);
const isProfileRuntimeAvailable = (profile: LobbyProfile): boolean => PROFILE_RUNTIME_STATUSES.has(profile.status);
const unavailableProfileText = (profile: LobbyProfile): string => {
if (profile.status === 'RESERVED') return '- 준 비 중 -';
if (profile.status === 'DISABLED') return '- 비 활 성 -';
return '- 폐 쇄 중 -';
};
const profileLoadState = (profileName: string): ProfileLoadState | undefined => profileLoadStates.value[profileName];
const setProfileLoadState = (profileName: string, state: ProfileLoadState): void => {
profileLoadStates.value = {
@@ -151,7 +158,7 @@ const handleGeneralPictureError = (event: Event): void => {
};
const loadProfileDetails = async (profile: LobbyProfile, sessionToken: string | null): Promise<void> => {
if (!lobbyMounted || (profile.status !== 'RUNNING' && profile.status !== 'PREOPEN')) {
if (!lobbyMounted || !isProfileRuntimeAvailable(profile)) {
return;
}
clearProfileRetry(profile.profileName);
@@ -443,6 +450,13 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
>
{{ serverSeasonStatus(profileDetails[profile.profileName]!).label }}
</div>
<div
v-if="profile.status === 'PAUSED'"
class="mt-1 whitespace-nowrap text-xs text-amber-300"
data-testid="profile-paused-status"
>
진행 일시정지
</div>
<div
v-if="profile.localAccountPolicy?.specialAccess"
class="mt-2 text-xs text-emerald-300"
@@ -494,8 +508,10 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
</div>
</div>
</template>
<template v-else-if="profile.status === 'STOPPED'">
<div class="text-center text-zinc-600 py-2">- 폐 쇄 중 -</div>
<template v-else-if="!isProfileRuntimeAvailable(profile)">
<div class="text-center text-zinc-600 py-2">
{{ unavailableProfileText(profile) }}
</div>
</template>
<template v-else-if="profileLoadState(profile.profileName)?.status === 'retrying'">
<div
@@ -598,7 +614,7 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
</template>
</div>
</template>
<template v-else-if="profile.status === 'STOPPED'">
<template v-else-if="!isProfileRuntimeAvailable(profile)">
<span class="text-zinc-700">-</span>
</template>
</td>