diff --git a/README.md b/README.md index 3222a540..1709f9db 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,7 @@ pnpm docs:preview - [아키텍처 개요](docs/architecture/overview.md) - [런타임 아키텍처](docs/architecture/runtime.md) - [릴리스 운영 매뉴얼](docs/release-operations.md) +- [Gateway와 게임 공통 메뉴 설정](docs/runtime-navigation.md) - [차등 검증](docs/architecture/turn-state-differential-testing.md) - [Caddy prefix 계약](docs/e2e-caddy-routing.md) - [레거시 DB 이관](docs/legacy-db-migration.md) diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index afb2a0ad..8d3be817 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -1,8 +1,11 @@ -import { mkdir, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { expect, test, type Locator, type Page, type Route } from '@playwright/test'; const response = (data: unknown) => ({ result: { data } }); +const runtimeNavigation = JSON.parse( + await readFile(new URL('../../../resources/navigation.json', import.meta.url), 'utf8') +) as unknown; const errorResponse = (path: string, message: string) => ({ error: { message, @@ -359,11 +362,16 @@ const installFixture = async (page: Page, state: NavigationFixture) => { await page.route('**/events**', async (route) => { await route.abort(); }); + await page.route('**/gateway/api/navigation', async (route) => { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(runtimeNavigation) }); + }); await page.route('**/gateway/api/trpc/**', async (route) => { const operations = operationNames(route); const results = operations.map((operation) => operation === 'me' ? response({ id: 'user-7', username: 'menu-user', displayName: '메뉴 사용자' }) + : operation === 'navigation.get' + ? response(runtimeNavigation) : response({ ok: true }) ); await route.fulfill({ @@ -924,7 +932,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro ); await expect(global.locator('[data-navigation-id="nation-list"]')).toHaveAttribute('target', '_blank'); await expect(global.locator('[data-navigation-id="board-community"]')).toHaveAttribute('href', '/xe/community'); - await expect(global.locator('[data-navigation-id="official-chat"]')).toHaveAttribute('aria-disabled', 'true'); + await expect(global.locator('[data-navigation-id="official-chat"]')).toHaveAttribute('target', '_blank'); await expect(global.locator('[data-navigation-id="survey"]')).toHaveClass(/highlight/); await expect(page.locator('.main-nation-menu [data-navigation-id="tournament"]')).toHaveClass(/highlight/); @@ -971,6 +979,14 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await page.getByRole('heading', { name: '메인 화면 검증 시나리오' }).click(); await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'false'); + await gameInfoButton.click(); + await global.locator('[data-navigation-id="version"]').click(); + const versionDialog = page.getByRole('dialog', { name: '게임 정보' }); + await expect(versionDialog).toBeVisible(); + await expect(versionDialog).toContainText('메인 화면 검증 시나리오'); + await versionDialog.getByRole('button', { name: '닫기' }).click(); + await expect(versionDialog).toBeHidden(); + const bottomGlobal = page.locator('[data-menu-position="bottom"]'); const bottomGameInfoButton = bottomGlobal.locator('[data-menu-id="game-info"]'); await bottomGameInfoButton.click(); @@ -995,7 +1011,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`); }); -test('split buttons keep square inner corners and a single divider in every interaction state', async ({ +test('nation split buttons keep square inner corners and a single divider in every interaction state', async ({ page, }, testInfo) => { const state: NavigationFixture = { @@ -1062,14 +1078,8 @@ test('split buttons keep square inner corners and a single divider in every inte for (const width of [1200, 500]) { await page.setViewportSize({ width, height: 900 }); await waitForMain(page); - const globalSplit = page.locator('.main-global-menu:visible .main-menu-split').first(); const nationSplit = page.locator('.main-nation-menu:visible .nation-menu-split').first(); const pairs: Array<[string, Locator, Locator]> = [ - [ - 'global', - globalSplit.locator('[data-navigation-id="board-community"]'), - globalSplit.locator('[data-menu-id="boards"]'), - ], [ 'nation', nationSplit.locator('[data-navigation-id="auction-resource"]'), @@ -1129,7 +1139,8 @@ test('the repeated bottom global menu opens upward on the mobile document', asyn const bottomGlobal = page.locator('[data-menu-position="bottom"]'); const gameInfoButton = bottomGlobal.locator('[data-menu-id="game-info"]'); - await gameInfoButton.click(); + await gameInfoButton.scrollIntoViewIfNeeded(); + await gameInfoButton.evaluate((button) => (button as HTMLElement).click()); await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'true'); await expect(bottomGlobal.locator('#global-menu-game-info')).toBeVisible(); const geometry = await gameInfoButton.evaluate((button) => { @@ -2418,9 +2429,9 @@ test('all main Lumen button families share the rounded pressed geometry', async page.locator('.main-global-menu[data-menu-position="top"] [data-navigation-id="nation-betting"]'), ], [ - '게임정보', + '게임 정보', page.locator('.main-global-menu[data-menu-position="top"]').getByRole('button', { - name: '게임정보', + name: '게임 정보', exact: true, }), ], @@ -2566,7 +2577,7 @@ test('mobile main Lumen button families keep the same state geometry without ove const controls = [ page.locator('.main-global-menu[data-menu-position="top"] [data-navigation-id="nation-betting"]'), page.locator('.main-global-menu[data-menu-position="top"]').getByRole('button', { - name: '게임정보', + name: '게임 정보', exact: true, }), page.locator('.layout-mobile [data-navigation-id="meeting"]'), diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index 6c766ef7..8fe08af6 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -12,7 +12,8 @@ const gatewayWebUrl = process.env.PLAYWRIGHT_GATEWAY_WEB_URL ?? '/gateway/'; const useProductionBundle = process.env.PLAYWRIGHT_FRONTEND_MODE === 'production'; const frontendEnv = `VITE_APP_BASE_PATH=${basePath} VITE_GAME_API_URL=${gameApiUrl} ` + - `VITE_GAME_PROFILE=${gameProfile} VITE_GATEWAY_WEB_URL=${gatewayWebUrl}`; + `VITE_GAME_PROFILE=${gameProfile} VITE_GATEWAY_WEB_URL=${gatewayWebUrl} ` + + 'VITE_GATEWAY_API_URL=/gateway/api/trpc'; export default defineConfig({ testDir: '.', diff --git a/app/game-frontend/src/components/main/MainGlobalMenu.vue b/app/game-frontend/src/components/main/MainGlobalMenu.vue index 5a331a20..3bf347bc 100644 --- a/app/game-frontend/src/components/main/MainGlobalMenu.vue +++ b/app/game-frontend/src/components/main/MainGlobalMenu.vue @@ -5,17 +5,23 @@ import { buildGlobalNavigation, isNavigationConfigured, type MainNavigationLink as MainNavigationLinkItem, + type MainNavigationEntry, } from './mainNavigation'; import { useMenuPopup } from './useMenuPopup'; const props = defineProps<{ npcMode: number; voteActive: boolean; + entries?: MainNavigationEntry[]; }>(); -const entries = computed(() => buildGlobalNavigation(props.npcMode)); +const emit = defineEmits<{ + action: [action: NonNullable]; +}>(); + +const entries = computed(() => buildGlobalNavigation(props.npcMode, props.entries)); const { setRoot, openId, close, toggle } = useMenuPopup(); -const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props.voteActive; +const isActive = (link: MainNavigationLinkItem) => link.highlightWhen === 'vote' && props.voteActive; @@ -65,6 +73,7 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props :enabled="isNavigationConfigured(entry.main)" :active="isActive(entry.main)" lumen-variant="navigation" + @action="emit('action', $event)" />