diff --git a/app/game-api/test/battleSimProcessor.test.ts b/app/game-api/test/battleSimProcessor.test.ts index 233b3d51..d569422a 100644 --- a/app/game-api/test/battleSimProcessor.test.ts +++ b/app/game-api/test/battleSimProcessor.test.ts @@ -230,12 +230,14 @@ const buildPayload = (action: BattleSimJobPayload['action']): BattleSimJobPayloa describe('battle sim processor', () => { it('returns the fixed-seed battle summary instead of only a successful shape', () => { const payload = buildPayload('battle'); + payload.repeatCnt = 1000; const result = processBattleSimJob(payload); expect(result).toMatchObject({ result: true, reason: 'success', datetime: '2026-01-01 00:00:00', + repeatCnt: 1, avgWar: 1, phase: 2, killed: 626, @@ -277,6 +279,7 @@ describe('battle sim processor', () => { const second = processBattleSimJob(secondPayload); expect(first).toEqual(second); + expect(first.repeatCnt).toBe(2); expect(observedSeeds).toEqual(['server-repeat-0', 'server-repeat-1']); }); diff --git a/app/game-frontend/e2e/battleSimulator.spec.ts b/app/game-frontend/e2e/battleSimulator.spec.ts index ebd67c11..60b9d3ec 100644 --- a/app/game-frontend/e2e/battleSimulator.spec.ts +++ b/app/game-frontend/e2e/battleSimulator.spec.ts @@ -202,6 +202,7 @@ const importedGeneral = { type Fixture = { hasGeneral: boolean; failNextSimulation?: boolean; + prepareDelayMs?: number; requests: string[]; preparedPayloads: BattleSimJobPayload[]; serverResults: BattleSimResultPayload[]; @@ -256,6 +257,9 @@ const installApi = async (page: Page, fixture: Fixture) => { }, gameProfile); await page.route(gameTrpcRoute, async (route) => { const operations = operationNames(route); + if (fixture.prepareDelayMs && operations.includes('battle.prepareSimulation')) { + await new Promise((resolveDelay) => setTimeout(resolveDelay, fixture.prepareDelayMs)); + } const rawRequestBody: unknown = route.request().postData() ? route.request().postDataJSON() : {}; const requestBody = rawRequestBody && typeof rawRequestBody === 'object' ? (rawRequestBody as Record) : {}; @@ -446,8 +450,21 @@ test('keeps simulation available without a game general and preserves input afte await expect(page.getByText('시뮬레이터 입력 오류')).toBeVisible(); await expect(page.getByLabel('시드')).toHaveValue('keep-this-seed'); + fixture.prepareDelayMs = 1_500; await page.getByRole('button', { name: '전투', exact: true }).click(); - await expect(page.getByText('전투를 진행 중입니다.')).toHaveCount(0); + const progressToast = page.getByTestId('game-toast').filter({ hasText: '전투를 진행 중입니다.' }); + await expect(progressToast).toBeVisible(); + const progressToastRect = await progressToast.boundingBox(); + expect(progressToastRect?.x).toBeGreaterThanOrEqual(0); + expect((progressToastRect?.x ?? 0) + (progressToastRect?.width ?? 0)).toBeLessThanOrEqual(500); + if (artifactRoot) { + await page.screenshot({ + path: resolve(artifactRoot, 'battle-simulator-progress-mobile.png'), + fullPage: true, + animations: 'disabled', + }); + } + await expect(progressToast).toHaveCount(0); await expect(page.getByText('시뮬레이터 입력 오류')).toHaveCount(0); expect(await readBrowserWorkerResult(page, 0)).toEqual(fixture.serverResults[0]); @@ -468,6 +485,7 @@ test('runs 1000 battles in the Chromium worker and matches the Node processor ex test.setTimeout(60_000); const fixture: Fixture = { hasGeneral: false, + prepareDelayMs: 500, requests: [], preparedPayloads: [], serverResults: [], @@ -478,13 +496,35 @@ test('runs 1000 battles in the Chromium worker and matches the Node processor ex await page.getByLabel('반복 횟수').selectOption('1000'); await page.getByLabel('시드').fill(''); + const worldSettings = page.locator('[data-parity-id="world-settings"]'); + const worldSettingsTop = (await worldSettings.boundingBox())?.y; await page.getByRole('button', { name: '전투', exact: true }).click(); - await expect(page.getByText('전투를 진행 중입니다.')).toHaveCount(0, { timeout: 30_000 }); + + const progressToast = page.getByTestId('game-toast').filter({ hasText: '전투를 진행 중입니다.' }); + await expect(progressToast).toBeVisible(); + expect(await page.locator('.game-toast-viewport').evaluate((element) => getComputedStyle(element).position)).toBe( + 'fixed' + ); + expect((await worldSettings.boundingBox())?.y).toBe(worldSettingsTop); + if (artifactRoot) { + await page.screenshot({ + path: resolve(artifactRoot, 'battle-simulator-progress-desktop.png'), + fullPage: true, + animations: 'disabled', + }); + } + await expect(progressToast).toHaveCount(0, { timeout: 30_000 }); expect(fixture.preparedPayloads).toHaveLength(1); expect(fixture.preparedPayloads[0]?.seeds).toHaveLength(1000); expect(new Set(fixture.preparedPayloads[0]?.seeds).size).toBe(1000); expect(await readBrowserWorkerResult(page, 0)).toEqual(fixture.serverResults[0]); + const battleSummary = page.locator('[data-parity-id="battle-summary"]'); + await expect(battleSummary.locator('tr').filter({ hasText: '전투 횟수' }).locator('td')).toHaveText('1,000'); + await expect(battleSummary.locator('tr').filter({ hasText: '전투 일시' }).locator('td')).toHaveText( + /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/u + ); + expect(fixture.preparedPayloads[0]?.attackerGeneral.turntime).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/u); expect(fixture.requests).not.toContain('battle.simulate'); expect(fixture.requests).not.toContain('battle.getSimulation'); const workerUrls = await page.evaluate(() => { diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 05503f87..a7c616b8 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -327,9 +327,27 @@ const install = async (page: Page, state: FixtureState) => { const rawRequestBody: unknown = route.request().postData() ? route.request().postDataJSON() : {}; const requestBody = rawRequestBody && typeof rawRequestBody === 'object' ? (rawRequestBody as Record) : {}; + const queryInputText = new URL(route.request().url()).searchParams.get('input'); + let queryInput: Record = {}; + if (queryInputText) { + try { + const parsed: unknown = JSON.parse(queryInputText); + if (parsed && typeof parsed === 'object') { + queryInput = parsed as Record; + } + } catch { + queryInput = {}; + } + } const results = operations.map((operation, operationIndex) => { const rawPayload = - requestBody[String(operationIndex)] ?? (operations.length === 1 ? requestBody : undefined); + requestBody[String(operationIndex)] ?? + queryInput[String(operationIndex)] ?? + (operations.length === 1 + ? Object.keys(requestBody).length > 0 + ? requestBody + : queryInput + : undefined); const payload = rawPayload && typeof rawPayload === 'object' ? (rawPayload as TrpcRequestPayload) : undefined; const jsonInput = @@ -537,10 +555,12 @@ const install = async (page: Page, state: FixtureState) => { return response(battleCenter(state)); } if (operation === 'nation.getGeneralLog') { - const type = new URL(route.request().url()).searchParams.get('input')?.includes('generalAction') - ? 'generalAction' - : operation; - return response({ type, generalId: 7, logs: [{ id: 1, text: '감찰 기록' }] }); + const type = + typeof jsonInput.type === 'string' && + ['generalHistory', 'battleDetail', 'battleResult', 'generalAction'].includes(jsonInput.type) + ? jsonInput.type + : 'generalAction'; + return response({ type, generalId: 7, logs: [{ id: 1, text: `${type} 감찰 기록` }] }); } return response({ ok: true }); }); @@ -1310,7 +1330,9 @@ test('감찰부 keeps the selector interaction and shows the permission error pa await expect(page.locator('.selector-row select').nth(1)).toHaveValue('8'); await page.getByRole('button', { name: '다음 ▶' }).click(); await expect(page.locator('.selector-row select').nth(1)).toHaveValue('7'); - await expect(page.locator('.battle-general-name')).toContainText('검증장수 【 간의대부 | 건강 】'); + await expect(page.locator('.battle-general-name')).toContainText('검증장수'); + await expect(page.locator('.battle-general-name')).toContainText('간의대부'); + await expect(page.locator('.battle-general-name')).toContainText('건강'); await expect(page.locator('.battle-general-extra')).toContainText('계급29품관'); await expect(page.locator('.battle-general-card')).toContainText('병종보병'); await expect(page.locator('.battle-general-card')).not.toContainText('che_'); @@ -1321,6 +1343,11 @@ test('감찰부 keeps the selector interaction and shows the permission error pa expect(battleImages[1]?.backgroundImage).toContain('/game/crewtype1.png'); await expect(page.locator('.battle-general-card [role="progressbar"]')).toHaveCount(14); await expect(page.locator('.battle-general-card [aria-label*="1,275,975 (EX+)"]')).toHaveCount(5); + await expect(page.locator('.general-meta')).toHaveCount(0); + await expect(page.locator('.battle-general-extra__recent-value')).toHaveText('01-01 00:00'); + await expect(page.locator('.battle-general-extra__recent-value')).not.toContainText('2026'); + await expect(page.locator('.log-block')).toHaveCount(4); + await expect(page.locator('.log-block[data-log-type="battleResult"]')).toContainText('battleResult 감찰 기록'); expect( await page .locator('.battle-general-card [role="progressbar"]') @@ -1330,7 +1357,11 @@ test('감찰부 keeps the selector interaction and shows the permission error pa const geometry = await page.locator('.battle-page').evaluate((element) => { const selector = element.querySelector('.selector-row')!; const controls = [...selector.children].map((child) => (child as HTMLElement).getBoundingClientRect()); - const logBlock = element.querySelector('.log-block')!.getBoundingClientRect(); + const logBlocks = [...element.querySelectorAll('.log-block')]; + const logBlock = logBlocks[0]!.getBoundingClientRect(); + const recentLabel = element.querySelector('.battle-general-extra__recent-label')!; + const recentValue = element.querySelector('.battle-general-extra__recent-value')!; + const previousStat = recentLabel.previousElementSibling as HTMLElement; return { width: element.getBoundingClientRect().width, fontSize: getComputedStyle(element).fontSize, @@ -1341,6 +1372,11 @@ test('감찰부 keeps the selector interaction and shows the permission error pa backgroundImage: getComputedStyle(element).backgroundImage, generalBackgroundImage: getComputedStyle(element.querySelector('.battle-general-card')!) .backgroundImage, + logBackgroundImages: logBlocks.map((block) => getComputedStyle(block).backgroundImage), + recentLabelTop: recentLabel.getBoundingClientRect().top, + recentValueTop: recentValue.getBoundingClientRect().top, + recentValueWidth: recentValue.getBoundingClientRect().width, + previousStatTop: previousStat.getBoundingClientRect().top, }; }); expect(geometry.width).toBe(1000); @@ -1352,16 +1388,41 @@ test('감찰부 keeps the selector interaction and shows the permission error pa expect(geometry.logBlockWidth).toBeCloseTo(500, 0); expect(geometry.backgroundImage).toContain('back_walnut.jpg'); expect(geometry.generalBackgroundImage).toContain('back_blue.jpg'); + expect(geometry.logBackgroundImages).toHaveLength(4); + expect(geometry.logBackgroundImages.every((background) => background.includes('back_walnut.jpg'))).toBe(true); + expect(geometry.recentLabelTop).toBeCloseTo(geometry.recentValueTop, 0); + expect(geometry.recentLabelTop).toBeGreaterThan(geometry.previousStatTop); + expect(geometry.recentValueWidth).toBeGreaterThan(400); await persistParityArtifact(page, 'core-battle-center-desktop', geometry); await page.setViewportSize({ width: 500, height: 900 }); - const mobileGeometry = await page.locator('.selector-row').evaluate((element) => ({ - columns: getComputedStyle(element).gridTemplateColumns, - controlWidths: [...element.children].map((child) => (child as HTMLElement).getBoundingClientRect().width), - })); + const mobileGeometry = await page.locator('.battle-page').evaluate((element) => { + const selector = element.querySelector('.selector-row')!; + const battleResult = element.querySelector('.log-block[data-log-type="battleResult"]')!; + const footer = element.querySelector('.battle-footer')!; + const pageRect = element.getBoundingClientRect(); + const resultRect = battleResult.getBoundingClientRect(); + const footerRect = footer.getBoundingClientRect(); + return { + columns: getComputedStyle(selector).gridTemplateColumns, + controlWidths: [...selector.children].map((child) => (child as HTMLElement).getBoundingClientRect().width), + pageHeight: pageRect.height, + pageOverflow: getComputedStyle(element).overflow, + resultBottomWithinPage: resultRect.bottom <= pageRect.bottom, + footerAfterResult: footerRect.top >= resultRect.bottom, + resultBackgroundImage: getComputedStyle(battleResult).backgroundImage, + }; + }); expect(mobileGeometry.columns.split(' ')).toHaveLength(4); expect(mobileGeometry.controlWidths[0]).toBeCloseTo(83.33, 0); expect(mobileGeometry.controlWidths[1]).toBeCloseTo(125, 0); + expect(mobileGeometry.pageHeight).toBeGreaterThan(0); + expect(mobileGeometry.pageOverflow).toBe('visible'); + expect(mobileGeometry.resultBottomWithinPage).toBe(true); + expect(mobileGeometry.footerAfterResult).toBe(true); + expect(mobileGeometry.resultBackgroundImage).toContain('back_walnut.jpg'); + await page.locator('.log-block[data-log-type="battleResult"]').scrollIntoViewIfNeeded(); + await expect(page.locator('.log-block[data-log-type="battleResult"]')).toBeInViewport(); await persistParityArtifact(page, 'core-battle-center-mobile', mobileGeometry); await page.unrouteAll({ behavior: 'wait' }); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 767b75f3..bf51dec7 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -748,6 +748,7 @@ const persistArtifact = async (page: Page, name: string) => { return { viewport: { width: innerWidth, height: innerHeight }, global: describe('.main-global-menu'), + bottomGlobalPopup: describe('[data-menu-position="bottom"] .main-menu-popup__list'), nation: describe('.main-nation-menu'), bottom: describe('.main-mobile-bottom'), globalPopup: describe('#mobile-global-menu'), @@ -915,6 +916,20 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await gameInfoButton.press('Enter'); await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'true'); await expect(global.locator('#global-menu-game-info')).toBeVisible(); + const topMenuGeometry = await gameInfoButton.evaluate((button) => { + const popup = button.parentElement?.querySelector('.main-menu-popup__list'); + const caret = button.querySelector('.menu-caret'); + if (!popup || !caret) throw new Error('top global menu popup geometry is incomplete'); + return { + trigger: button.getBoundingClientRect().toJSON(), + popup: popup.getBoundingClientRect().toJSON(), + caretBorderTopWidth: getComputedStyle(caret).borderTopWidth, + caretBorderBottomWidth: getComputedStyle(caret).borderBottomWidth, + }; + }); + expect(topMenuGeometry.popup.top).toBeGreaterThanOrEqual(topMenuGeometry.trigger.bottom + 1); + expect(topMenuGeometry.caretBorderTopWidth).toBe('4px'); + expect(topMenuGeometry.caretBorderBottomWidth).toBe('0px'); await page.keyboard.press('Escape'); await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'false'); await expect(gameInfoButton).toBeFocused(); @@ -922,9 +937,72 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await gameInfoButton.click(); await page.getByRole('heading', { name: '메인 화면 검증 시나리오' }).click(); await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'false'); + + const bottomGlobal = page.locator('[data-menu-position="bottom"]'); + const bottomGameInfoButton = bottomGlobal.locator('[data-menu-id="game-info"]'); + await bottomGameInfoButton.click(); + await expect(bottomGameInfoButton).toHaveAttribute('aria-expanded', 'true'); + await expect(bottomGlobal.locator('#global-menu-game-info')).toBeVisible(); + const bottomMenuGeometry = await bottomGameInfoButton.evaluate((button) => { + const popup = button.parentElement?.querySelector('.main-menu-popup__list'); + const caret = button.querySelector('.menu-caret'); + if (!popup || !caret) throw new Error('bottom global menu popup geometry is incomplete'); + return { + trigger: button.getBoundingClientRect().toJSON(), + popup: popup.getBoundingClientRect().toJSON(), + caretBorderTopWidth: getComputedStyle(caret).borderTopWidth, + caretBorderBottomWidth: getComputedStyle(caret).borderBottomWidth, + boxShadow: getComputedStyle(popup).boxShadow, + }; + }); + expect(bottomMenuGeometry.popup.bottom).toBeLessThanOrEqual(bottomMenuGeometry.trigger.top - 1); + expect(bottomMenuGeometry.caretBorderTopWidth).toBe('0px'); + expect(bottomMenuGeometry.caretBorderBottomWidth).toBe('4px'); + expect(bottomMenuGeometry.boxShadow).toContain('0px -8px 18px'); await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`); }); +test('the repeated bottom global menu opens upward on the mobile document', async ({ page }, testInfo) => { + const state: NavigationFixture = { + officerLevel: 5, + permission: 2, + nationLevel: 3, + stage: 1, + npcMode: 1, + scenarioTitle: '하단 메뉴 방향 검증 시나리오', + generalMeCalls: 0, + operations: [], + }; + await installFixture(page, state); + await page.setViewportSize({ width: 500, height: 900 }); + await waitForMain(page); + + const bottomGlobal = page.locator('[data-menu-position="bottom"]'); + const gameInfoButton = bottomGlobal.locator('[data-menu-id="game-info"]'); + await gameInfoButton.click(); + await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'true'); + await expect(bottomGlobal.locator('#global-menu-game-info')).toBeVisible(); + const geometry = await gameInfoButton.evaluate((button) => { + const popup = button.parentElement?.querySelector('.main-menu-popup__list'); + const caret = button.querySelector('.menu-caret'); + if (!popup || !caret) throw new Error('mobile bottom global menu popup geometry is incomplete'); + return { + trigger: button.getBoundingClientRect().toJSON(), + popup: popup.getBoundingClientRect().toJSON(), + caretBorderTopWidth: getComputedStyle(caret).borderTopWidth, + caretBorderBottomWidth: getComputedStyle(caret).borderBottomWidth, + viewportHeight: window.innerHeight, + }; + }); + expect(geometry.popup.bottom).toBeLessThanOrEqual(geometry.trigger.top - 1); + expect(geometry.popup.top).toBeGreaterThanOrEqual(0); + expect(geometry.popup.bottom).toBeLessThanOrEqual(geometry.viewportHeight); + expect(geometry.caretBorderTopWidth).toBe('0px'); + expect(geometry.caretBorderBottomWidth).toBe('4px'); + await bottomGlobal.screenshot({ path: testInfo.outputPath('mobile-bottom-global-dropup.png') }); + await persistArtifact(page, `${basePath.slice(1)}-mobile-bottom-dropup`); +}); + test('main general card uses local turn time and command clock tracks corrected server time', async ({ page }) => { const state: NavigationFixture = { officerLevel: 0, diff --git a/app/game-frontend/src/components/main/MainGlobalMenu.vue b/app/game-frontend/src/components/main/MainGlobalMenu.vue index e30d07a3..419fe7e5 100644 --- a/app/game-frontend/src/components/main/MainGlobalMenu.vue +++ b/app/game-frontend/src/components/main/MainGlobalMenu.vue @@ -163,6 +163,17 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props list-style: none; } +.main-global-menu[data-menu-position='bottom'] .main-menu-popup__list { + top: auto; + bottom: calc(100% + 2px); + box-shadow: 0 -8px 18px rgb(0 0 0 / 45%); +} + +.main-global-menu[data-menu-position='bottom'] .menu-caret { + border-top-width: 0; + border-bottom: 4px solid currentColor; +} + .main-menu-split .main-menu-popup__list { right: 0; left: auto; diff --git a/app/game-frontend/src/views/BattleCenterView.vue b/app/game-frontend/src/views/BattleCenterView.vue index e2067bfa..759b5101 100644 --- a/app/game-frontend/src/views/BattleCenterView.vue +++ b/app/game-frontend/src/views/BattleCenterView.vue @@ -286,28 +286,26 @@ onMounted(() => { >{{ selectedGeneral.battleStats.killCrew.toLocaleString('ko-KR') }} 피살{{ selectedGeneral.battleStats.deathCrew.toLocaleString('ko-KR') }} - 최근 전투{{ selectedGeneral.recentWar || '-' }} + 최근 전투 + + {{ + formatServerDateTime(selectedGeneral.recentWar, { + format: 'monthDayTime', + fallback: '-', + }) + }} + -
-
- 최근 턴: - {{ - formatServerDateTime(selectedGeneral.turnTime, { format: 'hourMinute', fallback: '-' }) - }} -
-
최근 전투: {{ selectedGeneral.recentWar || '-' }}
-
전투 횟수: {{ selectedGeneral.warnum }}
-
-
+
{{ logLabels[type] }}