diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index c93bf20d..8d6e45ba 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -590,6 +590,8 @@ const persistArtifact = async (page: Page, name: string) => { executionStatus: describe('.execution-status'), tournamentStatus: describe('.tournament-status'), voteStatus: describe('.vote-status'), + autoRefresh: describe('[data-bottom-menu="auto-refresh"]'), + manualRefresh: describe('[data-bottom-menu="manual-refresh"]'), commandMenu: describe('.reserved-command-editor details[open] .menu-items'), commandDividers: [ ...document.querySelectorAll('.reserved-command-editor details[open] .menu-divider'), @@ -1515,6 +1517,7 @@ test('mobile single document refreshes once and preserves tokens on lobby return generalMeCalls: 0, operations: [], }; + await installRealtimeHarness(page); await installFixture(page, state); await page.setViewportSize({ width: 500, height: 900 }); await waitForMain(page); @@ -1537,9 +1540,65 @@ test('mobile single document refreshes once and preserves tokens on lobby return await expect(page.locator(selector)).toBeVisible(); } + const autoRefresh = page.getByRole('button', { name: '자동 갱신 ON' }); + const manualRefresh = page.getByRole('button', { name: '직접 갱신' }); + await expect(autoRefresh).toHaveAttribute('aria-pressed', 'true'); + await expect(autoRefresh.locator('strong')).toHaveCSS('color', 'rgb(158, 240, 184)'); + await expect(manualRefresh).toHaveAttribute('aria-busy', 'false'); + await expect + .poll(() => page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())) + .toBe(true); + + const refreshGeometry = await page.locator('.bottom-refresh-controls').evaluate((controls) => { + const auto = controls.querySelector('[data-bottom-menu="auto-refresh"]'); + const manual = controls.querySelector('[data-bottom-menu="manual-refresh"]'); + if (!auto || !manual) throw new Error('mobile refresh controls are incomplete'); + const controlsRect = controls.getBoundingClientRect(); + const autoRect = auto.getBoundingClientRect(); + const manualRect = manual.getBoundingClientRect(); + return { + controls: { left: controlsRect.left, right: controlsRect.right, width: controlsRect.width }, + auto: { left: autoRect.left, right: autoRect.right, width: autoRect.width }, + manual: { left: manualRect.left, right: manualRect.right, width: manualRect.width }, + overflow: controls.scrollWidth - controls.clientWidth, + }; + }); + expect(refreshGeometry.controls.width).toBe(125); + expect(refreshGeometry.auto.width).toBe(85); + expect(refreshGeometry.manual.width).toBe(40); + expect(refreshGeometry.auto.left).toBe(refreshGeometry.controls.left); + expect(refreshGeometry.auto.right).toBe(refreshGeometry.manual.left); + expect(refreshGeometry.manual.right).toBe(refreshGeometry.controls.right); + expect(refreshGeometry.overflow).toBeLessThanOrEqual(0); + + await autoRefresh.focus(); + await expect(autoRefresh).toBeFocused(); + await autoRefresh.hover(); + await expect(autoRefresh).toHaveCSS('filter', 'brightness(1.14)'); + await autoRefresh.click(); + const disabledAutoRefresh = page.getByRole('button', { name: '자동 갱신 OFF' }); + await expect(disabledAutoRefresh).toHaveAttribute('aria-pressed', 'false'); + await expect(disabledAutoRefresh.locator('strong')).toHaveCSS('color', 'rgb(187, 187, 187)'); + await expect + .poll(() => page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())) + .toBe(false); + await persistArtifact(page, `${basePath.slice(1)}-mobile-auto-refresh-controls-off`); + + state.generalName = '직접갱신된장수'; const callsBeforeRefresh = state.generalMeCalls; - await page.getByRole('button', { name: '갱 신' }).click(); + await manualRefresh.click(); await expect.poll(() => state.generalMeCalls).toBeGreaterThan(callsBeforeRefresh); + await expect(page.locator('.general-title')).toContainText('직접갱신된장수'); + + const callsBeforeEnable = state.generalMeCalls; + await page.getByRole('button', { name: '자동 갱신 OFF' }).click(); + await expect(page.getByRole('button', { name: '자동 갱신 ON' })).toHaveAttribute('aria-pressed', 'true'); + await expect.poll(() => state.generalMeCalls).toBeGreaterThan(callsBeforeEnable); + await expect + .poll(() => page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())) + .toBe(true); + + await persistArtifact(page, `${basePath.slice(1)}-mobile-auto-refresh-controls`); await page.evaluate(() => { localStorage.setItem('sammo-session-token', 'session_navigation'); diff --git a/app/game-frontend/src/components/main/MainMobileBottomBar.vue b/app/game-frontend/src/components/main/MainMobileBottomBar.vue index e607497d..f361704f 100644 --- a/app/game-frontend/src/components/main/MainMobileBottomBar.vue +++ b/app/game-frontend/src/components/main/MainMobileBottomBar.vue @@ -18,10 +18,13 @@ const props = defineProps<{ tournamentStage: number; nationColor: string; npcMode: number; + realtimeEnabled: boolean; + refreshing: boolean; }>(); const emit = defineEmits<{ refresh: []; + toggleRealtime: []; lobby: []; quick: [item: QuickNavigationItem]; }>(); @@ -189,14 +192,31 @@ const onQuick = (item: QuickNavigationItem) => { - +
+ + +
@@ -221,6 +241,17 @@ const onQuick = (item: QuickNavigationItem) => { position: relative; } +.bottom-refresh-controls { + display: grid; + width: 125px; + height: 45px; + grid-template-columns: minmax(0, 1fr) 40px; +} + +.bottom-refresh-controls > .bottom-trigger { + width: auto; +} + .bottom-trigger { box-sizing: border-box; width: 125px; @@ -245,6 +276,43 @@ const onQuick = (item: QuickNavigationItem) => { background: #212529; } +.auto-refresh-trigger { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2px 1px; + font-size: 12px; + line-height: 1.15; +} + +.auto-refresh-trigger strong { + color: #bbb; + font-size: 11px; + line-height: 1; +} + +.auto-refresh-trigger.active { + background-color: #164f2c; +} + +.auto-refresh-trigger.active strong { + color: #9ef0b8; +} + +.manual-refresh-trigger { + padding: 0; + background: #212529; + font-size: 22px; + line-height: 1; +} + +.manual-refresh-trigger:disabled { + cursor: wait; + filter: grayscale(0.6); + opacity: 0.55; +} + .bottom-trigger:hover, .bottom-trigger:focus-visible, .bottom-trigger[aria-expanded='true'] { diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index 1e592f27..6b8e0c57 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -455,7 +455,10 @@ watch( :tournament-stage="tournamentStage" :nation-color="nationColor" :npc-mode="npcMode" + :realtime-enabled="realtimeEnabled" + :refreshing="refreshing" @refresh="loadMainData" + @toggle-realtime="dashboard.setRealtimeEnabled(!realtimeEnabled)" @lobby="moveLobby" @quick="moveQuick" />