From a14282b34d0ce70dac97b4d0537300794250143f Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 19 Aug 2026 11:13:57 +0000 Subject: [PATCH] =?UTF-8?q?feat(menu):=20PHP=20=EA=B8=B0=EC=A4=80=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=20=EB=A9=94=EB=89=B4=EB=A5=BC=20=EB=9F=B0?= =?UTF-8?q?=ED=83=80=EC=9E=84=20=EC=84=A4=EC=A0=95=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateway와 게임 공통 메뉴를 영속 JSON에서 읽고 다음 페이지 로드에 반영한다. 운영 PHP의 항목과 순서, desktop/mobile geometry 및 정보 동작을 보존하고 검증·복구 문서를 추가한다. --- README.md | 1 + app/game-frontend/e2e/mainNavigation.spec.ts | 37 +++-- app/game-frontend/e2e/playwright.config.mjs | 3 +- .../src/components/main/MainGlobalMenu.vue | 14 +- .../components/main/MainMobileBottomBar.vue | 13 +- .../components/main/MainNavigationLink.vue | 15 ++ .../src/components/main/mainNavigation.ts | 143 ++++-------------- app/game-frontend/src/views/MainView.vue | 72 ++++++++- app/gateway-api/src/config.ts | 10 +- app/gateway-api/src/context.ts | 7 + .../src/navigation/runtimeNavigationConfig.ts | 126 +++++++++++++++ app/gateway-api/src/router.ts | 3 + app/gateway-api/src/server.ts | 10 ++ .../test/runtimeNavigationConfig.test.ts | 86 +++++++++++ .../e2e/lobby-admin-navigation.spec.ts | 8 +- .../e2e/playwright.config.mjs | 8 +- .../e2e/runtime-navigation.spec.ts | 72 +++++++++ .../src/layouts/AdminConsoleLayout.vue | 10 +- .../src/layouts/DefaultLayout.vue | 82 +++++++--- docs/index.md | 2 + docs/runtime-navigation.md | 59 ++++++++ packages/common/package.json | 4 + packages/common/src/navigation/menuConfig.ts | 57 +++++++ packages/common/tsdown.config.ts | 1 + resources/navigation.json | 74 +++++++++ 25 files changed, 744 insertions(+), 173 deletions(-) create mode 100644 app/gateway-api/src/navigation/runtimeNavigationConfig.ts create mode 100644 app/gateway-api/test/runtimeNavigationConfig.test.ts create mode 100644 app/gateway-frontend/e2e/runtime-navigation.spec.ts create mode 100644 docs/runtime-navigation.md create mode 100644 packages/common/src/navigation/menuConfig.ts create mode 100644 resources/navigation.json 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)" />