From a51574d20c1ea5aa67b6d66a31aa0b61d25f5f3b Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 16 Sep 2026 01:05:40 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=B3=B5=EA=B5=AC=202=EB=B0=B0=EC=86=8D?= =?UTF-8?q?=20=EB=B2=A0=ED=8C=85=20=EB=A7=88=EA=B0=90=EC=9D=84=20=EA=B2=8C?= =?UTF-8?q?=EC=9E=84=20=EC=8B=9C=EA=B0=81=EC=9C=BC=EB=A1=9C=20=ED=8C=90?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../e2e/tournamentBracket.spec.ts | 66 ++++++++++++++++++- .../src/composables/useClockDisplay.ts | 3 + app/game-frontend/src/views/BettingView.vue | 4 +- 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/app/game-frontend/e2e/tournamentBracket.spec.ts b/app/game-frontend/e2e/tournamentBracket.spec.ts index 65c0c7b5..16eda4b3 100644 --- a/app/game-frontend/e2e/tournamentBracket.spec.ts +++ b/app/game-frontend/e2e/tournamentBracket.spec.ts @@ -192,6 +192,14 @@ const persistScreenshot = async (page: Page, name: string, fallbackPath: string) const installFixture = async ( page: Page, options: { + clock?: { + serverTime: string; + serverWallTime: string; + clockMode: 'realtime' | 'manual'; + clockRunning: boolean; + clockRecovery?: { startsAt: string; endsAt: string }; + }; + bettingCloseAt?: string; applicationOpen?: boolean; tournamentType?: number; tournamentStage?: number; @@ -224,7 +232,7 @@ const installFixture = async ( const results = operationNames(route).map((operation) => { options.onOperation?.(operation, route.request().headers()); if (operation === 'auth.status') return response({ ok: true }); - if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: names[0] } }); + if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: names[0] }, ...options.clock }); if (operation === 'join.getConfig') return response({}); if (operation === 'general.me') return response({ general: { id: 1, name: names[0] } }); if (operation === 'tournament.getAdminStatus') return response({ ok: false }); @@ -244,6 +252,7 @@ const installFixture = async ( openMonth: 1, termSeconds: 60, nextAt: '2026-08-02T00:00:00.000Z', + bettingCloseAt: options.bettingCloseAt, winnerId: tournamentStage === 0 ? 1 : undefined, }, participants: @@ -1213,3 +1222,58 @@ for (const width of [1365, 801, 390, 320]) { expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width); }); } + +for (const width of [1365, 390]) { + for (const displayMode of ['game', 'real']) { + test(`recovery betting uses game deadline at ${width}px in ${displayMode} display`, async ({ + page, + }, testInfo) => { + await page.setViewportSize({ width, height: 900 }); + const wallNow = new Date('2026-09-16T12:00:00Z'); + await page.clock.install({ time: wallNow }); + await page.clock.setFixedTime(wallNow); + await page.addInitScript( + ({ profile, displayMode }) => { + localStorage.setItem(`sammo-clock-display:${profile}:/${profile.split(':')[0]}/`, displayMode); + }, + { profile: gameProfile, displayMode } + ); + const { placedBets } = await installFixture(page, { + tournamentStage: 6, + bettingCloseAt: '2026-09-16T11:50:20Z', + clock: { + serverTime: '2026-09-16T11:50:00Z', + serverWallTime: wallNow.toISOString(), + clockMode: 'realtime', + clockRunning: true, + clockRecovery: { startsAt: wallNow.toISOString(), endsAt: '2026-09-16T12:10:00Z' }, + }, + }); + await page.goto('betting'); + const button = page.getByRole('button', { name: '관우에게 베팅하기', exact: true }); + await expect(button).toBeVisible(); + await button.click(); + await expect(page.getByRole('status').filter({ hasText: '10금 베팅 완료' })).toBeVisible(); + expect(placedBets).toEqual([{ targetId: 1, amount: 10 }]); + await page.clock.pauseAt(wallNow); + await page.clock.setSystemTime(wallNow); + await page.clock.runFor(9_750); + await expect(button).toBeVisible(); + await page.evaluate(() => document.fonts.ready); + const geometry = await button.evaluate((el) => ({ + rect: el.getBoundingClientRect().toJSON(), + fontSize: getComputedStyle(el).fontSize, + disabled: (el as HTMLButtonElement).disabled, + html: document.querySelector('#tournament-betting-container')?.outerHTML, + })); + expect(geometry.disabled).toBe(false); + expect(geometry.rect.width).toBeGreaterThan(0); + await writeFile(testInfo.outputPath('recovery-betting-geometry.json'), JSON.stringify(geometry)); + await page.screenshot({ path: testInfo.outputPath('recovery-betting-open.png'), fullPage: true }); + // 250ms 표시 갱신 주기의 위상과 무관하게 마감 직후를 확인한다. + await page.clock.runFor(500); + await expect(button).toHaveCount(0); + await page.screenshot({ path: testInfo.outputPath('recovery-betting-closed.png'), fullPage: true }); + }); + } +} diff --git a/app/game-frontend/src/composables/useClockDisplay.ts b/app/game-frontend/src/composables/useClockDisplay.ts index e983f2cc..985dea42 100644 --- a/app/game-frontend/src/composables/useClockDisplay.ts +++ b/app/game-frontend/src/composables/useClockDisplay.ts @@ -63,6 +63,8 @@ export const clockSampleIsStale = (): boolean => const projection = computed(() => sample.value ? projectServerClock(sample.value, haltedAt.value ?? now.value) : null ); +// 마감 판정은 표시 모드와 무관하게 서버가 투영한 GAME 시각을 사용한다. +const gameTime = computed(() => projection.value?.time ?? null); const accelerated = computed(() => projection.value?.rate === 2 && engineRunning.value !== false); const label = computed(() => (mode.value === 'real' ? '실제 시간 기준' : '게임 시간 기준')); const time = computed(() => { @@ -90,6 +92,7 @@ export const useClockDisplay = () => ({ mode, label, time, + gameTime, accelerated, toggle, projectTime, diff --git a/app/game-frontend/src/views/BettingView.vue b/app/game-frontend/src/views/BettingView.vue index 2e97bcc2..88c9a588 100644 --- a/app/game-frontend/src/views/BettingView.vue +++ b/app/game-frontend/src/views/BettingView.vue @@ -2,7 +2,7 @@ import { usePageExit } from '../composables/usePageExit'; import { useClockDisplay } from '../composables/useClockDisplay'; -const { formatTime: formatGameTime } = useClockDisplay(); +const { formatTime: formatGameTime, gameTime } = useClockDisplay(); import { storeToRefs } from 'pinia'; import { computed, onMounted, onUnmounted, ref } from 'vue'; import TournamentBracket from '../components/tournament/TournamentBracket.vue'; @@ -62,7 +62,7 @@ const bettingOpen = computed(() => { const state = snapshot.value?.state; if (!state || state.stage !== 6) return false; if (!state.bettingCloseAt) return true; - return new Date(state.bettingCloseAt).getTime() > Date.now(); + return gameTime.value !== null && new Date(state.bettingCloseAt).getTime() > gameTime.value.getTime(); }); const placeBet = async (target: TournamentBracketSlot) => {