diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 7b51451d..959529c1 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -22,6 +22,13 @@ const persistParityArtifact = async (page: Page, name: string, geometry: unknown ]); }; +const waitForVisualAssets = async (page: Page): Promise => { + await page.waitForLoadState('networkidle'); + await page.evaluate(async () => { + await document.fonts.ready; + }); +}; + const readGeneralPanelImages = async (panel: Locator) => panel.evaluate((element) => [...element.querySelectorAll('.general-image')].map((image) => { @@ -1270,6 +1277,9 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide await expect(page.locator('#container')).not.toContainText('che_'); await expect(page.locator('.title-row')).toContainText('내 정 보'); await expect(page.locator('#set_my_setting')).toBeVisible(); + await expect(page.getByRole('radiogroup', { name: '화면 폭 모드' })).toHaveCount(0); + await expect(page.getByRole('button', { name: '순서 바꾸기', exact: true })).toHaveCount(0); + await expect(page.locator('#custom_css')).toHaveCount(0); await expect(page.locator('.general-column [role="progressbar"]')).toHaveCount(14); await expect(page.locator('.general-column [aria-label*="1,275,975 (EX+)"]')).toHaveCount(5); await expect.poll(() => state.generalMeQueries).toBeGreaterThan(0); @@ -1290,7 +1300,6 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide const settings = element.querySelector('.settings-column')!.getBoundingClientRect(); const saveButton = element.querySelector('#set_my_setting')!; const save = saveButton.getBoundingClientRect(); - const customCss = element.querySelector('#custom_css')!.getBoundingClientRect(); const columns = getComputedStyle(element.querySelector('.top-grid')!).gridTemplateColumns; return { width: rect.width, @@ -1302,8 +1311,6 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide saveWidth: save.width, saveHeight: save.height, saveBackground: getComputedStyle(saveButton).backgroundColor, - customCssWidth: customCss.width, - customCssHeight: customCss.height, backgroundImage: getComputedStyle(element).backgroundImage, sectionBackgroundImage: getComputedStyle(element.querySelector('.section-title')!).backgroundImage, }; @@ -1317,8 +1324,6 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide expect(desktop.saveWidth).toBe(160); expect(desktop.saveHeight).toBe(30); expect(desktop.saveBackground).toBe('rgb(34, 85, 0)'); - expect(desktop.customCssWidth).toBe(420); - expect(desktop.customCssHeight).toBe(150); expect(desktop.backgroundImage).toContain('back_walnut.jpg'); expect(desktop.sectionBackgroundImage).toContain('back_green.jpg'); await expectLumenButtonStates(page, page.locator('#set_my_setting'), 'rgb(34, 85, 0)'); @@ -1583,11 +1588,68 @@ test('내 정보&설정의 지난 플레이는 기본 탐색과 분리되어 오 } }); -test('내 정보&설정에서 모바일 메인 패널을 드래그하거나 버튼으로 재정렬하고 기본 순서로 복원한다', async ({ page }) => { +test('화면 설정에서 화면 폭과 개인 CSS를 저장하고 게임 설정 API는 호출하지 않는다', async ({ page }, testInfo) => { + const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] }; + await install(page, state); + await page.setViewportSize({ width: 1000, height: 900 }); + await page.goto('my-settings'); + await waitForVisualAssets(page); + + await expect(page.locator('.title-row')).toContainText('화 면 설 정'); + await expect( + page.getByText('이 설정은 이 기기의 화면 표시만 바꾸며 게임 상태에는 영향을 주지 않습니다.') + ).toHaveCount(0); + await expect(page.locator('#set_my_setting')).toHaveCount(0); + await expect(page.locator('#custom_css')).toBeVisible(); + expect(state.settingMutations).toHaveLength(0); + + const desktop = await page.locator('#interface-settings').evaluate((element) => { + const rect = element.getBoundingClientRect(); + const css = element.querySelector('#custom_css')!.getBoundingClientRect(); + return { + width: rect.width, + columns: getComputedStyle(element.querySelector('.settings-grid')!).gridTemplateColumns, + cssWidth: css.width, + cssHeight: css.height, + backgroundImage: getComputedStyle(element).backgroundImage, + sectionBackgroundImage: getComputedStyle(element.querySelector('.section-title')!).backgroundImage, + }; + }); + expect(desktop.width).toBe(1000); + expect(desktop.columns.split(' ')).toHaveLength(2); + expect(desktop.cssWidth).toBe(420); + expect(desktop.cssHeight).toBe(150); + expect(desktop.backgroundImage).toContain('back_walnut.jpg'); + expect(desktop.sectionBackgroundImage).toContain('back_green.jpg'); + await page.screenshot({ path: testInfo.outputPath('interface-settings-desktop.png'), fullPage: true }); + + await page.getByRole('radio', { name: '500px' }).check(); + await expect.poll(() => page.evaluate(() => localStorage.getItem('sam.screenMode'))).toBe('500px'); + await expect(page.locator('meta[name="viewport"]')).toHaveAttribute('content', 'width=500'); + + const cssText = '#interface-settings { --ui-settings-e2e: 23px; }'; + await page.getByLabel('개인용 CSS').fill(cssText); + await expect(page.locator('.custom-css span')).toHaveText('(저장 중)'); + await expect.poll(() => page.evaluate(() => localStorage.getItem('sam_customCSS'))).toBe(cssText); + await expect.poll(() => page.locator('#sammo-custom-css').textContent()).toBe(cssText); + await page.reload(); + await expect.poll(() => page.locator('#sammo-custom-css').textContent()).toBe(cssText); + expect(state.settingMutations).toHaveLength(0); + + await page.getByLabel('개인용 CSS').fill(''); + await expect.poll(() => page.evaluate(() => localStorage.getItem('sam_customCSS'))).toBe(''); + await page.getByRole('radio', { name: '자동' }).check(); + await persistParityArtifact(page, 'core-interface-settings-desktop', desktop); +}); + +test('화면 설정에서 모바일 메인 패널을 드래그하거나 버튼으로 재정렬하고 기본 순서로 복원한다', async ({ + page, +}, testInfo) => { const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] }; await install(page, state); await page.setViewportSize({ width: 390, height: 844 }); - await page.goto('my-page'); + await page.goto('my-settings'); + await waitForVisualAssets(page); await page.getByRole('button', { name: '순서 바꾸기', exact: true }).click(); const dialog = page.getByRole('dialog', { name: '모바일 레이아웃 순서 바꾸기' }); @@ -1637,7 +1699,8 @@ test('내 정보&설정에서 모바일 메인 패널을 드래그하거나 버 expect(dialogGeometry.firstItem?.height).toBeGreaterThanOrEqual(44); expect(dialogGeometry.moveButton?.width).toBeGreaterThanOrEqual(36); expect(dialogGeometry.documentWidth).toBe(390); - await persistParityArtifact(page, 'core-my-page-mobile-layout-order-dialog', dialogGeometry); + await dialog.screenshot({ path: testInfo.outputPath('interface-settings-mobile-layout-dialog.png') }); + await persistParityArtifact(page, 'core-interface-settings-mobile-layout-order-dialog', dialogGeometry); await dialog.getByRole('button', { name: '적용', exact: true }).click(); await expect(dialog).toBeHidden(); @@ -1654,7 +1717,7 @@ test('내 정보&설정에서 모바일 메인 패널을 드래그하거나 버 .toEqual(defaultOrder); }); -test('실제 모바일 터치로 메인 패널 순서를 재정렬한다', async ({ browser }, testInfo) => { +test('화면 설정에서 실제 모바일 터치로 메인 패널 순서를 재정렬한다', async ({ browser }, testInfo) => { const configuredBaseUrl = testInfo.project.use.baseURL; if (typeof configuredBaseUrl !== 'string') { throw new Error('Playwright baseURL is required for the mobile touch contract'); @@ -1672,7 +1735,8 @@ test('실제 모바일 터치로 메인 패널 순서를 재정렬한다', async try { const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] }; await install(mobilePage, state); - await mobilePage.goto('my-page'); + await mobilePage.goto('my-settings'); + await waitForVisualAssets(mobilePage); await mobilePage.getByRole('button', { name: '순서 바꾸기', exact: true }).click(); const dialog = mobilePage.getByRole('dialog', { name: '모바일 레이아웃 순서 바꾸기' }); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 9f78ed3f..8ae3476a 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -29,6 +29,8 @@ type NavigationFixture = { permission: number; nationLevel: number; stage: number; + tournamentType?: 0 | 1 | 2 | 3; + tournamentWinnerId?: number; npcMode: number; generalMeCalls: number; operations: string[]; @@ -758,7 +760,16 @@ const installFixture = async (page: Page, state: NavigationFixture) => { canSecret: state.permission >= 2, }); } - if (operation === 'tournament.getState') return response({ stage: state.stage }); + if (operation === 'tournament.getState') { + if (state.stage === 0 && state.tournamentType === undefined && state.tournamentWinnerId === undefined) { + return response(null); + } + return response({ + stage: state.stage, + type: state.tournamentType ?? 0, + winnerId: state.tournamentWinnerId, + }); + } return response({ ok: true }); }); operations.forEach((operation, index) => { @@ -1260,7 +1271,27 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await expect(global.locator('[data-navigation-id="board-community"]')).toHaveAttribute('href', '/xe/community'); 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/); + const nationMenu = page.locator('.main-nation-menu:visible'); + await expect(nationMenu.locator(':scope > *')).toHaveCount(20); + const tournamentMain = nationMenu.locator('[data-navigation-id="tournament"]'); + const tournamentToggle = nationMenu.locator('[data-menu-id="tournament-betting"]'); + await expect(tournamentMain).toHaveClass(/highlight/); + await expect(tournamentMain).toHaveAttribute('href', `${basePath}/tournament`); + await expect(tournamentToggle).toHaveAttribute('aria-expanded', 'false'); + await expect(nationMenu.locator('[data-navigation-id="my-settings"]')).toHaveAttribute( + 'href', + `${basePath}/my-settings` + ); + await tournamentToggle.click(); + await expect(tournamentToggle).toHaveAttribute('aria-expanded', 'true'); + await expect( + nationMenu.locator('#nation-menu-tournament-betting [data-navigation-id="tournament-menu"]') + ).toHaveText('토너먼트'); + await expect(nationMenu.locator('#nation-menu-tournament-betting [data-navigation-id="betting"]')).toHaveAttribute( + 'href', + `${basePath}/betting` + ); + await page.keyboard.press('Escape'); const gameInfoButton = global.locator('[data-menu-id="game-info"]'); const bettingButton = global.locator('[data-navigation-id="nation-betting"]'); @@ -1417,6 +1448,95 @@ test('shows the persisted official game index beside the scenario title without } }); +test('tournament split main action follows recruitment, betting, finals, and tournament type on desktop and mobile', async ({ + page, +}, testInfo) => { + const state: NavigationFixture = { + officerLevel: 5, + permission: 2, + nationLevel: 3, + stage: 1, + tournamentType: 3, + npcMode: 1, + scenarioTitle: '토너먼트 동적 메뉴 검증 시나리오', + generalMeCalls: 0, + operations: [], + }; + await installFixture(page, state); + + const cases = [ + { stage: 1, type: 3 as const, label: '설 전', route: '/tournament' }, + { stage: 5, type: 2 as const, label: '일 기 토', route: '/tournament' }, + { stage: 6, type: 2 as const, label: '베 팅 장', route: '/betting' }, + { stage: 7, type: 2 as const, label: '일 기 토', route: '/tournament' }, + { stage: 10, type: 3 as const, label: '설 전', route: '/tournament' }, + { stage: 0, type: 3 as const, label: '설 전', route: '/tournament', winnerId: 17 }, + ]; + + for (const viewport of [ + { width: 1200, height: 900 }, + { width: 500, height: 900 }, + ]) { + await page.setViewportSize(viewport); + for (const lifecycle of cases) { + state.stage = lifecycle.stage; + state.tournamentType = lifecycle.type; + state.tournamentWinnerId = lifecycle.winnerId; + if (page.url() === 'about:blank') { + await waitForMain(page); + } else { + await page.reload(); + await waitForMain(page); + } + + const nationMenu = page.locator('.main-nation-menu:visible'); + const main = nationMenu.locator('[data-navigation-id="tournament"]'); + await expect(main).toHaveText(lifecycle.label); + await expect(main).toHaveAttribute('href', `${basePath}${lifecycle.route}`); + await expect(main).toHaveClass(/highlight/); + + const toggle = nationMenu.locator('[data-menu-id="tournament-betting"]'); + await toggle.click(); + const tournamentItem = nationMenu.locator( + '#nation-menu-tournament-betting [data-navigation-id="tournament-menu"]' + ); + const bettingItem = nationMenu.locator('#nation-menu-tournament-betting [data-navigation-id="betting"]'); + if (lifecycle.stage === 6) { + await expect(tournamentItem).not.toHaveClass(/highlight/); + await expect(bettingItem).toHaveClass(/highlight/); + } else { + await expect(tournamentItem).toHaveClass(/highlight/); + await expect(bettingItem).not.toHaveClass(/highlight/); + } + await page.keyboard.press('Escape'); + + if (viewport.width === 500) { + const nationTrigger = page.locator('[data-bottom-menu="nation"]'); + await nationTrigger.click(); + const mobileTournament = page.locator('#mobile-nation-menu [data-navigation-id="tournament-menu"]'); + const mobileBetting = page.locator('#mobile-nation-menu [data-navigation-id="betting"]'); + if (lifecycle.stage === 6) { + await expect(mobileTournament).not.toHaveClass(/highlight/); + await expect(mobileBetting).toHaveClass(/highlight/); + } else { + await expect(mobileTournament).toHaveClass(/highlight/); + await expect(mobileBetting).not.toHaveClass(/highlight/); + } + await page.keyboard.press('Escape'); + } + } + + await page + .locator('.main-nation-menu:visible [data-menu-id="tournament-betting"]') + .locator('..') + .screenshot({ + path: artifactRoot + ? resolve(artifactRoot, `tournament-dynamic-main-${viewport.width}.png`) + : testInfo.outputPath(`tournament-dynamic-main-${viewport.width}.png`), + }); + } +}); + test('shows game index zero for a profile whose first game starts at zero', async ({ page }) => { const state: NavigationFixture = { officerLevel: 5, @@ -1455,7 +1575,7 @@ test('nation split buttons keep square inner corners and a single divider in eve officerLevel: 5, permission: 2, nationLevel: 3, - stage: 1, + stage: 6, npcMode: 1, scenarioTitle: '분할 버튼 이음새 검증 시나리오', generalMeCalls: 0, @@ -1515,14 +1635,21 @@ test('nation split buttons keep square inner corners and a single divider in eve for (const width of [1200, 500]) { await page.setViewportSize({ width, height: 900 }); await waitForMain(page); - const nationSplit = page.locator('.main-nation-menu:visible .nation-menu-split').first(); const pairs: Array<[string, Locator, Locator]> = [ [ - 'nation', - nationSplit.locator('[data-navigation-id="auction-resource"]'), - nationSplit.locator('[data-menu-id="auction"]'), + 'tournament', + page.locator('.main-nation-menu:visible [data-navigation-id="tournament"]'), + page.locator('.main-nation-menu:visible [data-menu-id="tournament-betting"]'), + ], + [ + 'auction', + page.locator('.main-nation-menu:visible [data-navigation-id="auction-resource"]'), + page.locator('.main-nation-menu:visible [data-menu-id="auction"]'), ], ]; + await expect(page.locator('.main-nation-menu:visible [data-navigation-id="tournament"]')).toHaveClass( + /highlight/ + ); for (const [label, main, toggle] of pairs) { await expect(main).toBeVisible(); @@ -3959,9 +4086,7 @@ for (const viewport of [ await picker.getByLabel('장비 종류', { exact: true }).selectOption('weapon'); const equipment = picker.getByLabel('장비', { exact: true }); await equipment.selectOption('청룡언월도'); - const optionLabelBeforeRefresh = await equipment - .locator('option[value="청룡언월도"]') - .textContent(); + const optionLabelBeforeRefresh = await equipment.locator('option[value="청룡언월도"]').textContent(); await equipment.evaluate((element) => { const select = element as HTMLSelectElement; const valueDescriptor = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value'); @@ -4091,8 +4216,8 @@ test('keeps an Android Chromium native command select untouched while a turn sig await waitForMain(mobilePage); await expect .poll(() => - mobilePage.evaluate( - () => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime() + mobilePage.evaluate(() => + (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime() ) ) .toBe(true); diff --git a/app/game-frontend/src/assets/main.css b/app/game-frontend/src/assets/main.css index 65148076..4912c428 100644 --- a/app/game-frontend/src/assets/main.css +++ b/app/game-frontend/src/assets/main.css @@ -42,6 +42,7 @@ body { /* These redesigned screens own a true handheld layout. */ #app:has(.responsive-settings-page), +#app:has(.interface-settings-page), #app:has(#tournament-container), #app:has(#tournament-betting-container), #app:has(#personnel-container) { diff --git a/app/game-frontend/src/components/main/MainMobileBottomBar.vue b/app/game-frontend/src/components/main/MainMobileBottomBar.vue index dc492b03..21559738 100644 --- a/app/game-frontend/src/components/main/MainMobileBottomBar.vue +++ b/app/game-frontend/src/components/main/MainMobileBottomBar.vue @@ -4,9 +4,9 @@ import { legacyNationTextColor } from '../../utils/legacyNationColor'; import MainNavigationLink from './MainNavigationLink.vue'; import { buildGlobalNavigation, + buildNationNavigation, isNationNavigationEnabled, isNavigationConfigured, - nationNavigation, quickNavigation, type MainNavigationLink as MainNavigationLinkItem, type MainNavigationEntry, @@ -18,6 +18,7 @@ import { useMenuPopup } from './useMenuPopup'; const props = defineProps<{ access: NationNavigationAccess; tournamentStage: number; + tournamentType: number | null; nationColor: string; npcMode: number; realtimeEnabled: boolean; @@ -35,9 +36,12 @@ const emit = defineEmits<{ const { setRoot, openId, close, toggle } = useMenuPopup(); const globalEntries = computed(() => buildGlobalNavigation(props.npcMode, props.entries)); +const nationEntries = computed(() => buildNationNavigation(props.tournamentStage, props.tournamentType)); const nationMenuColor = computed(() => props.nationColor || '#000000'); const nationMenuTextColor = computed(() => legacyNationTextColor(nationMenuColor.value)); -const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props.tournamentStage; +const isActive = (link: MainNavigationLinkItem) => + link.highlightStage === props.tournamentStage || + link.highlightStages?.some((stage) => stage === props.tournamentStage) === true; const onQuick = (item: QuickNavigationItem) => { close(); @@ -148,7 +152,7 @@ const onAction = (action: NonNullable) => {