diff --git a/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts b/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts index e5721eb6..d3ae7b0c 100644 --- a/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts +++ b/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts @@ -240,6 +240,112 @@ test('desktop administrator sidebar follows the navbar away and then sticks to t await writeFile(testInfo.outputPath('admin-sidebar-scroll-geometry.json'), JSON.stringify(measurements, null, 2)); }); +test('keeps mobile account actions on clean rows for users and administrators', async ({ browser }, testInfo) => { + const measureAccountActions = async (page: Page) => { + const actions = page.locator('.account-actions'); + await expect(actions).toBeVisible(); + return actions.evaluate((element) => { + const rect = (target: Element) => { + const value = target.getBoundingClientRect(); + return { + top: value.top, + right: value.right, + bottom: value.bottom, + left: value.left, + width: value.width, + height: value.height, + }; + }; + const items = Array.from(element.children).map((child) => ({ + ...rect(child), + clientWidth: (child as HTMLElement).clientWidth, + scrollWidth: (child as HTMLElement).scrollWidth, + whiteSpace: getComputedStyle(child).whiteSpace, + })); + return { + container: rect(element), + documentWidth: document.documentElement.scrollWidth, + viewportWidth: window.innerWidth, + items, + }; + }); + }; + + const userContext = await browser.newContext({ viewport: { width: 500, height: 900 } }); + const userPage = await userContext.newPage(); + await installGatewayFixture(userPage, []); + await userPage.goto('lobby'); + const userGeometry = await measureAccountActions(userPage); + expect(userGeometry.documentWidth).toBe(userGeometry.viewportWidth); + expect(userGeometry.items).toHaveLength(2); + expect(userGeometry.items[0]?.top).toBeCloseTo(userGeometry.items[1]?.top ?? 0, 0); + expect(userGeometry.items[0]?.bottom).toBeCloseTo(userGeometry.items[1]?.bottom ?? 0, 0); + expect(userGeometry.items[0]?.width).toBeCloseTo(userGeometry.items[1]?.width ?? 0, 0); + expect(userGeometry.items.every(({ scrollWidth, clientWidth }) => scrollWidth <= clientWidth)).toBe(true); + await userPage.screenshot({ path: testInfo.outputPath('gateway-account-actions-user-500px.png'), fullPage: true }); + await userContext.close(); + + const adminContext = await browser.newContext({ viewport: { width: 500, height: 900 } }); + const adminPage = await adminContext.newPage(); + await installGatewayFixture(adminPage, ['superuser']); + await adminPage.goto('lobby'); + const adminGeometry = await measureAccountActions(adminPage); + expect(adminGeometry.documentWidth).toBe(adminGeometry.viewportWidth); + expect(adminGeometry.items).toHaveLength(3); + const [account, logout, admin] = adminGeometry.items; + expect(account?.top).toBeCloseTo(logout?.top ?? 0, 0); + expect(account?.bottom).toBeCloseTo(logout?.bottom ?? 0, 0); + expect(account?.width).toBeCloseTo(logout?.width ?? 0, 0); + expect(admin?.top).toBeCloseTo((logout?.bottom ?? 0) + 16, 0); + expect(admin?.left).toBeCloseTo(account?.left ?? 0, 0); + expect(admin?.right).toBeCloseTo(logout?.right ?? 0, 0); + expect(adminGeometry.items.every(({ scrollWidth, clientWidth }) => scrollWidth <= clientWidth)).toBe(true); + expect(account?.whiteSpace).toBe('nowrap'); + expect(admin?.whiteSpace).toBe('nowrap'); + + const adminLink = adminPage.getByRole('link', { name: '관리자 페이지' }); + const baseBackground = await adminLink.evaluate((element) => getComputedStyle(element).backgroundColor); + await adminLink.hover(); + await expect.poll(() => adminLink.evaluate((element) => getComputedStyle(element).backgroundColor)).not.toBe( + baseBackground + ); + await adminLink.focus(); + await expect(adminLink).toBeFocused(); + await adminPage.screenshot({ path: testInfo.outputPath('gateway-account-actions-admin-500px.png'), fullPage: true }); + + await adminPage.setViewportSize({ width: 390, height: 844 }); + const adminNarrowGeometry = await measureAccountActions(adminPage); + expect(adminNarrowGeometry.documentWidth).toBe(adminNarrowGeometry.viewportWidth); + expect(adminNarrowGeometry.items[0]?.top).toBeCloseTo(adminNarrowGeometry.items[1]?.top ?? 0, 0); + expect(adminNarrowGeometry.items[2]?.top).toBeCloseTo( + (adminNarrowGeometry.items[1]?.bottom ?? 0) + 16, + 0 + ); + expect(adminNarrowGeometry.items.every(({ scrollWidth, clientWidth }) => scrollWidth <= clientWidth)).toBe(true); + await adminPage.screenshot({ path: testInfo.outputPath('gateway-account-actions-admin-390px.png'), fullPage: true }); + + await adminPage.setViewportSize({ width: 360, height: 800 }); + const adminSmallGeometry = await measureAccountActions(adminPage); + expect(adminSmallGeometry.documentWidth).toBe(adminSmallGeometry.viewportWidth); + expect(adminSmallGeometry.items[1]?.top).toBeCloseTo((adminSmallGeometry.items[0]?.bottom ?? 0) + 16, 0); + expect(adminSmallGeometry.items[2]?.top).toBeCloseTo((adminSmallGeometry.items[1]?.bottom ?? 0) + 16, 0); + expect(adminSmallGeometry.items.every(({ scrollWidth, clientWidth }) => scrollWidth <= clientWidth)).toBe(true); + await writeFile( + testInfo.outputPath('gateway-account-actions-geometry.json'), + JSON.stringify( + { + user: userGeometry, + admin: adminGeometry, + adminNarrow: adminNarrowGeometry, + adminSmall: adminSmallGeometry, + }, + null, + 2 + ) + ); + await adminContext.close(); +}); + test('legacy server operations URL keeps query parameters and redirects to the server list', async ({ page }) => { await installGatewayFixture(page, ['superuser']); diff --git a/app/gateway-frontend/src/views/LobbyView.vue b/app/gateway-frontend/src/views/LobbyView.vue index 078c6dce..98a6c1ae 100644 --- a/app/gateway-frontend/src/views/LobbyView.vue +++ b/app/gateway-frontend/src/views/LobbyView.vue @@ -823,10 +823,10 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => { > 계 정 관 리 -
+
비밀번호 & 전콘 & 탈퇴 @@ -841,7 +841,7 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => { 관리자 페이지 @@ -855,6 +855,20 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {