fix: 예약 오픈 공지를 간결하게 표시

This commit is contained in:
2026-08-23 17:27:23 +00:00
parent 85591c68ad
commit a5dec1394a
2 changed files with 162 additions and 80 deletions
@@ -502,7 +502,7 @@ test('does not contact a STOPPED game runtime and labels it inaccessible', async
await page.screenshot({ path: testInfo.outputPath('gateway-stopped-profile-lobby.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('gateway-stopped-profile-lobby.png'), fullPage: true });
}); });
test('shows a complete upcoming reset announcement without contacting the stopped runtime', async ({ test('shows a compact upcoming reset announcement without contacting the stopped runtime', async ({
page, page,
}, testInfo) => { }, testInfo) => {
const gameOperations = await installFixture(page, { const gameOperations = await installFixture(page, {
@@ -531,36 +531,65 @@ test('shows a complete upcoming reset announcement without contacting the stoppe
await page.goto('lobby'); await page.goto('lobby');
const row = page.locator('tbody tr').filter({ hasText: '체섭' }); const row = page.locator('tbody tr').filter({ hasText: '체섭' });
const announcement = row.getByTestId('upcoming-reset-announcement'); const announcement = row.getByTestId('upcoming-reset-announcement');
const autorun = announcement.locator('.copyable-autorun'); const marker = announcement.getByTestId('upcoming-reset-options-marker');
const tooltip = announcement.locator('.copyable-autorun-detail'); const swatch = announcement.getByTestId('upcoming-reset-options-swatch');
await expect(row.getByTestId('upcoming-reset-phase')).toHaveText('오픈 예정 · 빌드 대기'); const tooltip = announcement.getByTestId('upcoming-reset-options-tooltip');
await expect(row.getByTestId('upcoming-reset-scheduled-at')).toHaveText('- 초기화 시작 : 2026-08-27 14:00:00 -'); await expect(row.getByTestId('upcoming-reset-phase')).toHaveCount(0);
await expect(row.getByTestId('upcoming-reset-scheduled-at')).toHaveCount(0);
await expect(row.getByTestId('upcoming-reset-preopen-at')).toHaveText('- 가오픈 일시 : 2026-08-27 14:30:00 -'); await expect(row.getByTestId('upcoming-reset-preopen-at')).toHaveText('- 가오픈 일시 : 2026-08-27 14:30:00 -');
await expect(row.getByTestId('upcoming-reset-open-at')).toHaveText('- 오픈 일시 : 2026-08-27 20:00:00 -'); await expect(row.getByTestId('upcoming-reset-open-at')).toHaveText('- 오픈 일시 : 2026-08-27 20:00:00 -');
await expect(row.getByTestId('upcoming-reset-scenario-announcement')).toHaveText( await expect(row.getByTestId('upcoming-reset-scenario-title')).toHaveText('【가상】황건적의 난');
'【가상】황건적의 난 60분 턴 서버' await expect(
); row.getByTestId('upcoming-reset-scenario-announcement').locator(':scope > .text-green-400')
expect((await announcement.textContent())?.replace(/\s+/g, ' ')).toContain( ).toHaveText('60분 턴 서버');
'(상성 설정:가상), (빙의 여부:가능), (최대 스탯:70), (기타 설정:랜덤 임관, 자율행동[내정, 출병, 24시간 유효])' expect(
(await announcement.evaluate((element) => (element as HTMLElement).innerText)).replace(/\s+/g, ' ').trim()
).toBe(
'- 가오픈 일시 : 2026-08-27 14:30:00 - - 오픈 일시 : 2026-08-27 20:00:00 - 【가상】황건적의 난 60분 턴 서버'
); );
await expect(tooltip).toBeHidden();
await expect(marker).toHaveAttribute('aria-label', '초기화 옵션 보기');
await expect(swatch).toHaveCSS('background-color', 'rgb(217, 119, 6)');
await expect(row).not.toContainText('서버 중지 · 접근 불가'); await expect(row).not.toContainText('서버 중지 · 접근 불가');
await expect(row).not.toContainText('정보를 불러오는 중');
await expect(row.getByRole('button', { name: '입장' })).toHaveCount(0); await expect(row.getByRole('button', { name: '입장' })).toHaveCount(0);
await expect(page.getByRole('tab', { name: 'hwe섭' })).toHaveCount(0); await expect(page.getByRole('tab', { name: 'hwe섭' })).toHaveCount(0);
expect(gameOperations).toEqual([]); expect(gameOperations).toEqual([]);
const desktopGeometry = await announcement.evaluate((element) => ({ const desktopGeometry = await announcement.evaluate((element) => {
const marker = element.querySelector<HTMLElement>('[data-testid="upcoming-reset-options-marker"]');
const swatch = element.querySelector<HTMLElement>('[data-testid="upcoming-reset-options-swatch"]');
if (!marker || !swatch) throw new Error('expected upcoming reset option marker');
return {
announcement: element.getBoundingClientRect().toJSON(), announcement: element.getBoundingClientRect().toJSON(),
cell: element.parentElement?.getBoundingClientRect().toJSON(), cell: element.parentElement?.getBoundingClientRect().toJSON(),
marker: marker.getBoundingClientRect().toJSON(),
swatch: swatch.getBoundingClientRect().toJSON(),
documentWidth: document.documentElement.scrollWidth, documentWidth: document.documentElement.scrollWidth,
viewportWidth: window.innerWidth, viewportWidth: window.innerWidth,
})); };
});
expect(desktopGeometry.announcement.left).toBeGreaterThanOrEqual(desktopGeometry.cell?.left ?? 0); expect(desktopGeometry.announcement.left).toBeGreaterThanOrEqual(desktopGeometry.cell?.left ?? 0);
expect(desktopGeometry.announcement.right).toBeLessThanOrEqual(desktopGeometry.cell?.right ?? 0); expect(desktopGeometry.announcement.right).toBeLessThanOrEqual(desktopGeometry.cell?.right ?? 0);
await autorun.hover(); expect(desktopGeometry.marker.width).toBe(18);
expect(desktopGeometry.marker.height).toBe(18);
expect(desktopGeometry.swatch.width).toBe(8);
expect(desktopGeometry.swatch.height).toBe(8);
await marker.hover();
await expect(tooltip).toBeVisible(); await expect(tooltip).toBeVisible();
await autorun.focus(); await expect(tooltip).toContainText('초기화 옵션');
await expect(autorun).toBeFocused(); await expect(tooltip).toContainText('상성 설정: 가상 · 빙의 여부: 가능 · 최대 스탯: 70');
await expect(autorun).toHaveCSS('outline-width', '2px'); await expect(tooltip).toContainText('기타 설정: 랜덤 임관 · 자율행동: 내정, 출병, 24시간 유효');
await expect(tooltip).not.toContainText('초기화 시작');
await expect(tooltip).not.toContainText('빌드 대기');
const desktopTooltipGeometry = await tooltip.evaluate((element) => element.getBoundingClientRect().toJSON());
expect(desktopTooltipGeometry.left).toBeGreaterThanOrEqual(8);
expect(desktopTooltipGeometry.right).toBeLessThanOrEqual(desktopGeometry.viewportWidth - 8);
expect(desktopTooltipGeometry.top).toBeGreaterThanOrEqual(desktopGeometry.cell?.top ?? 0);
expect(desktopTooltipGeometry.bottom).toBeLessThanOrEqual(desktopGeometry.cell?.bottom ?? 0);
await marker.focus();
await expect(marker).toBeFocused();
await expect(marker).toHaveCSS('outline-width', '2px');
await page.screenshot({ path: testInfo.outputPath('gateway-upcoming-reset-desktop.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('gateway-upcoming-reset-desktop.png'), fullPage: true });
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
@@ -577,8 +606,12 @@ test('shows a complete upcoming reset announcement without contacting the stoppe
expect(mobileGeometry.left).toBeGreaterThanOrEqual(0); expect(mobileGeometry.left).toBeGreaterThanOrEqual(0);
expect(mobileGeometry.right).toBeLessThanOrEqual(mobileGeometry.viewportWidth); expect(mobileGeometry.right).toBeLessThanOrEqual(mobileGeometry.viewportWidth);
expect(mobileGeometry.documentWidth).toBe(mobileGeometry.viewportWidth); expect(mobileGeometry.documentWidth).toBe(mobileGeometry.viewportWidth);
await autorun.hover(); await marker.hover();
await expect(tooltip).toBeVisible(); await expect(tooltip).toBeVisible();
const mobileTooltipGeometry = await tooltip.evaluate((element) => element.getBoundingClientRect().toJSON());
expect(mobileTooltipGeometry.left).toBeGreaterThanOrEqual(8);
expect(mobileTooltipGeometry.right).toBeLessThanOrEqual(mobileGeometry.viewportWidth - 8);
expect(mobileTooltipGeometry.bottom).toBeLessThanOrEqual(844 - 8);
await page.screenshot({ path: testInfo.outputPath('gateway-upcoming-reset-mobile.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('gateway-upcoming-reset-mobile.png'), fullPage: true });
}); });
@@ -603,8 +636,12 @@ test('keeps the announcement through the RESERVED handoff after the build comple
await page.goto('lobby'); await page.goto('lobby');
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' }); const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
await expect(row.getByTestId('upcoming-reset-phase')).toHaveText('오픈 준비 완료 · 가오픈 대기'); const marker = row.getByTestId('upcoming-reset-options-marker');
await expect(row.getByTestId('upcoming-reset-phase')).toHaveCount(0);
await expect(row.getByTestId('upcoming-reset-scenario-title')).toHaveText('【가상】황건적의 난'); await expect(row.getByTestId('upcoming-reset-scenario-title')).toHaveText('【가상】황건적의 난');
await marker.hover();
await expect(row.getByTestId('upcoming-reset-options-tooltip')).toContainText('초기화 옵션');
await expect(row.getByTestId('upcoming-reset-options-tooltip')).not.toContainText('오픈 준비 완료');
await expect(row).not.toContainText('준 비 중 · 접근 불가'); await expect(row).not.toContainText('준 비 중 · 접근 불가');
await expect(row.getByRole('button', { name: '입장' })).toHaveCount(0); await expect(row.getByRole('button', { name: '입장' })).toHaveCount(0);
expect(gameOperations).toEqual([]); expect(gameOperations).toEqual([]);
+101 -56
View File
@@ -135,12 +135,8 @@ const autorunDetailText = (autorun: LobbyInfo['autorunUser']): string => {
}; };
const autorunTooltipId = (profileName: string, scope = 'current'): string => const autorunTooltipId = (profileName: string, scope = 'current'): string =>
'profile-autorun-' + scope + '-' + profileName.replaceAll(/[^a-zA-Z0-9_-]/g, '-'); 'profile-autorun-' + scope + '-' + profileName.replaceAll(/[^a-zA-Z0-9_-]/g, '-');
const upcomingResetPhaseText = (profile: LobbyProfile): string => { const upcomingResetOptionsTooltipId = (profileName: string): string =>
if (profile.upcomingReset?.phase === 'DELAYED') return '준비 지연 · 일정 확인 중'; 'upcoming-reset-options-' + profileName.replaceAll(/[^a-zA-Z0-9_-]/g, '-');
if (profile.upcomingReset?.phase === 'READY') return '오픈 준비 완료 · 가오픈 대기';
if (profile.upcomingReset?.phase === 'PREPARING') return '오픈 준비 중';
return '오픈 예정 · 빌드 대기';
};
const isProfileRuntimeAvailable = (profile: LobbyProfile): boolean => profile.lifecycle.userAccessible; const isProfileRuntimeAvailable = (profile: LobbyProfile): boolean => profile.lifecycle.userAccessible;
const unavailableProfileText = (profile: LobbyProfile): string => { const unavailableProfileText = (profile: LobbyProfile): string => {
if (!profile.lifecycle.dataInitialized) return '- DB 초기화 전 · 접근 불가 -'; if (!profile.lifecycle.dataInitialized) return '- DB 초기화 전 · 접근 불가 -';
@@ -513,17 +509,6 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
class="upcoming-reset-announcement" class="upcoming-reset-announcement"
data-testid="upcoming-reset-announcement" data-testid="upcoming-reset-announcement"
> >
<div
class="upcoming-reset-phase"
:class="{ 'is-delayed': profile.upcomingReset.phase === 'DELAYED' }"
data-testid="upcoming-reset-phase"
>
{{ upcomingResetPhaseText(profile) }}
</div>
<div data-testid="upcoming-reset-scheduled-at">
- 초기화 시작 :
{{ formatAnnouncementDate(profile.upcomingReset.scheduledAt) }} -
</div>
<div data-testid="upcoming-reset-preopen-at"> <div data-testid="upcoming-reset-preopen-at">
- 가오픈 일시 : - 가오픈 일시 :
{{ formatAnnouncementDate(profile.upcomingReset.preopenAt) }} - {{ formatAnnouncementDate(profile.upcomingReset.preopenAt) }} -
@@ -538,38 +523,41 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
<span class="text-green-400"> <span class="text-green-400">
{{ profile.upcomingReset.turnTermMinutes }}분 턴 서버 {{ profile.upcomingReset.turnTermMinutes }}분 턴 서버
</span> </span>
</div> <span
<div class="profile-announcement-settings text-xs text-zinc-500"> class="upcoming-reset-options-marker"
(상성 설정:{{ profile.upcomingReset.fictionMode }}), (빙의 여부:{{
npcModeText(profile.upcomingReset.npcMode)
}}), (최대 스탯:{{ profile.upcomingReset.defaultStatTotal }}), (기타
설정:<template v-if="profile.upcomingReset.otherTextInfo"
>{{ profile.upcomingReset.otherTextInfo
}}<template v-if="profile.upcomingReset.autorunUser"
>,
</template></template
><span
v-if="profile.upcomingReset.autorunUser"
class="copyable-autorun"
tabindex="0" tabindex="0"
:aria-describedby="autorunTooltipId(profile.profileName, 'upcoming')" aria-label="초기화 옵션 보기"
>자율행동<span :aria-describedby="upcomingResetOptionsTooltipId(profile.profileName)"
:id="autorunTooltipId(profile.profileName, 'upcoming')" data-testid="upcoming-reset-options-marker"
class="copyable-autorun-detail" >
<span
class="upcoming-reset-options-swatch"
data-testid="upcoming-reset-options-swatch"
aria-hidden="true"
></span>
<span
:id="upcomingResetOptionsTooltipId(profile.profileName)"
class="upcoming-reset-options-tooltip"
role="tooltip" role="tooltip"
><span class="copyable-autorun-bracket">[</span data-testid="upcoming-reset-options-tooltip"
><span>{{ >
<strong>초기화 옵션</strong>
<span
>상성 설정: {{ profile.upcomingReset.fictionMode }} · 빙의 여부:
{{ npcModeText(profile.upcomingReset.npcMode) }} · 최대 스탯:
{{ profile.upcomingReset.defaultStatTotal }}</span
>
<span
>기타 설정: {{ profile.upcomingReset.otherTextInfo || '없음'
}}<template v-if="profile.upcomingReset.autorunUser">
· 자율행동:
{{
autorunDetailText(profile.upcomingReset.autorunUser) autorunDetailText(profile.upcomingReset.autorunUser)
}}</span }}</template
><span class="copyable-autorun-bracket">]</span></span
></span ></span
><template >
v-if=" </span>
!profile.upcomingReset.otherTextInfo && </span>
!profile.upcomingReset.autorunUser
"
>없음</template
>)
</div> </div>
</div> </div>
<template v-if="profileDetails[profile.profileName]"> <template v-if="profileDetails[profile.profileName]">
@@ -668,8 +656,8 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
</div> </div>
</div> </div>
</template> </template>
<template v-else-if="!isProfileRuntimeAvailable(profile) && !profile.upcomingReset"> <template v-else-if="!isProfileRuntimeAvailable(profile)">
<div class="text-center text-zinc-600 py-2"> <div v-if="!profile.upcomingReset" class="text-center text-zinc-600 py-2">
{{ unavailableProfileText(profile) }} {{ unavailableProfileText(profile) }}
</div> </div>
</template> </template>
@@ -1004,20 +992,66 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
} }
.upcoming-reset-announcement { .upcoming-reset-announcement {
border-left: 2px solid #f59e0b;
padding-left: 10px;
line-height: 1.5; line-height: 1.5;
} }
.upcoming-reset-phase { .upcoming-reset-options-marker {
margin-bottom: 4px; position: relative;
color: #fbbf24; display: inline-flex;
font-size: 12px; width: 18px;
font-weight: 700; height: 18px;
align-items: center;
justify-content: center;
margin-left: 3px;
border-radius: 2px;
cursor: help;
vertical-align: text-bottom;
} }
.upcoming-reset-phase.is-delayed { .upcoming-reset-options-swatch {
color: #fca5a5; width: 8px;
height: 8px;
border: 1px solid #fbbf24;
border-radius: 1px;
background: #d97706;
box-shadow: 0 0 0 1px rgb(24 24 27 / 70%);
}
.upcoming-reset-options-tooltip {
position: absolute;
z-index: 30;
bottom: -16px;
left: calc(100% + 6px);
display: flex;
visibility: hidden;
box-sizing: border-box;
width: max-content;
max-width: min(400px, calc(100vw - 32px));
flex-direction: column;
gap: 2px;
padding: 7px 9px;
border: 1px solid #52525b;
border-radius: 4px;
background: #18181b;
box-shadow: 0 4px 12px rgb(0 0 0 / 45%);
color: #f4f4f5;
font-size: 12px;
line-height: 1.4;
opacity: 0;
pointer-events: none;
text-align: left;
white-space: normal;
}
.upcoming-reset-options-marker:hover .upcoming-reset-options-tooltip,
.upcoming-reset-options-marker:focus-visible .upcoming-reset-options-tooltip {
visibility: visible;
opacity: 1;
}
.upcoming-reset-options-marker:focus-visible {
outline: 2px solid #fdba74;
outline-offset: 1px;
} }
.copyable-autorun { .copyable-autorun {
@@ -1067,6 +1101,17 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.upcoming-reset-options-marker:hover .upcoming-reset-options-tooltip,
.upcoming-reset-options-marker:focus-visible .upcoming-reset-options-tooltip {
position: fixed;
top: auto;
right: 16px;
bottom: 16px;
left: 16px;
width: auto;
max-width: none;
}
.copyable-autorun:hover .copyable-autorun-detail, .copyable-autorun:hover .copyable-autorun-detail,
.copyable-autorun:focus-visible .copyable-autorun-detail { .copyable-autorun:focus-visible .copyable-autorun-detail {
position: fixed; position: fixed;