diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 9d3a4204..9cd9f019 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -75,6 +75,8 @@ type FixtureState = { dieOnPrestartAttempts?: number; dieOnPrestartInputs?: Array>; generalMeQueries?: number; + generalMeFailure?: boolean; + canChangeIcon?: boolean; generalLogQueries?: number; ensurePrestartQueries?: number; ensurePrestartFailure?: 'TIMEOUT' | 'INTERNAL_SERVER_ERROR'; @@ -285,7 +287,7 @@ const myGeneral = (state: FixtureState) => ({ }, penalties: {}, iconChoices: state.iconChoices ?? [], - canChangeIcon: true, + canChangeIcon: state.canChangeIcon ?? true, iconChangeAvailableAt: null, }); @@ -467,6 +469,15 @@ const install = async (page: Page, state: FixtureState) => { } if (operation === 'general.me') { state.generalMeQueries = (state.generalMeQueries ?? 0) + 1; + if (state.generalMeFailure) { + return { + error: { + message: '아이콘 조회 실패', + code: -32603, + data: { code: 'INTERNAL_SERVER_ERROR', httpStatus: 500, path: operation }, + }, + }; + } return response(myGeneral(state)); } if (operation === 'general.ensureDieOnPrestartStatus') { @@ -518,9 +529,7 @@ const install = async (page: Page, state: FixtureState) => { }, meta: { turntime: '2026-01-01T00:00:00.000Z', - lastTurnTime: state.gameStarted - ? '2026-03-01T00:00:00.000Z' - : '2026-01-01T00:00:00.000Z', + lastTurnTime: state.gameStarted ? '2026-03-01T00:00:00.000Z' : '2026-01-01T00:00:00.000Z', opentime: state.buildNationCandidateEnabled ? '2026-02-01T00:00:00.000Z' : '2025-12-01T00:00:00.000Z', @@ -1126,9 +1135,7 @@ test('커맨드 실행 결과의 성공·실패·일반 색상을 메인 기록 ]) { await page.setViewportSize(viewport); await page.goto(''); - const colors = await inspectResultColors( - `${viewport.selector} [data-record-bucket="general"] .record-line` - ); + const colors = await inspectResultColors(`${viewport.selector} [data-record-bucket="general"] .record-line`); await persistParityArtifact(page, `core-main-command-result-colors-${viewport.name}`, colors); } }); @@ -1408,12 +1415,7 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide const promotionAutomation = page.getByRole('checkbox', { name: '자동 수뇌 임명' }); const financeAutomation = page.getByRole('checkbox', { name: '자동 세율·지급률 조정' }); const capitalAutomation = page.getByRole('checkbox', { name: '자동 천도' }); - for (const checkbox of [ - warAutomation, - promotionAutomation, - financeAutomation, - capitalAutomation, - ]) { + for (const checkbox of [warAutomation, promotionAutomation, financeAutomation, capitalAutomation]) { await expect(checkbox).not.toBeChecked(); await checkbox.check(); } @@ -1719,14 +1721,14 @@ test('내 정보&설정의 지난 플레이는 기본 탐색과 분리되어 오 } }); -test('화면 설정에서 화면 폭과 개인 CSS를 저장하고 게임 설정 API는 호출하지 않는다', async ({ page }, testInfo) => { +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.locator('.title-row')).toContainText('환 경 설 정'); await expect( page.getByText('이 설정은 이 기기의 화면 표시만 바꾸며 게임 상태에는 영향을 주지 않습니다.') ).toHaveCount(0); @@ -1773,7 +1775,7 @@ test('화면 설정에서 화면 폭과 개인 CSS를 저장하고 게임 설정 await persistParityArtifact(page, 'core-interface-settings-desktop', desktop); }); -test('화면 설정에서 모바일 메인 패널을 드래그하거나 버튼으로 재정렬하고 기본 순서로 복원한다', async ({ +test('환경 설정에서 모바일 메인 패널을 드래그하거나 버튼으로 재정렬하고 기본 순서로 복원한다', async ({ page, }, testInfo) => { const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] }; @@ -1848,7 +1850,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'); @@ -1944,36 +1946,121 @@ for (const [label, failure] of [ }); } -test('내 정보에서 사람 장수의 등록 전콘을 골라 변경한다', async ({ page }) => { - const iconId = '3f804277-584f-4f44-b39c-9ecf40d1ed31'; +for (const viewport of [ + { name: 'desktop', width: 1000, height: 900 }, + { name: 'mobile', width: 390, height: 844 }, +]) { + test(`환경 설정에서 사람 장수의 등록 전콘을 골라 변경한다 ${viewport.name}`, async ({ page }) => { + await page.setViewportSize(viewport); + const iconId = '3f804277-584f-4f44-b39c-9ecf40d1ed31'; + const state: FixtureState = { + permission: 'member', + myset: 1, + settingMutations: [], + accessPages: [], + iconChoices: [ + { + id: iconId, + picture: 'mine.png', + imageServer: 1, + createdAt: '2026-08-01T00:00:00.000Z', + }, + ], + adjustIconInputs: [], + }; + await install(page, state); + await page.goto('my-page'); + await expect(page.locator('#set_my_setting')).toBeVisible(); + await expect(page.locator('.general-icon-action')).toHaveCount(0); + const prestartQueries = state.ensurePrestartQueries; + await page.route('**/mine.png', async (route) => + route.fulfill({ + contentType: 'image/svg+xml', + body: '', + }) + ); + await page.goto('my-settings'); + await expect(page.getByText('전용 아이콘 변경 (24시간에 1회)')).toBeVisible(); + await expect(page.locator('.selected-general-icon img')).toHaveCSS('width', '64px'); + await expect(page.locator('.selected-general-icon img')).toHaveCSS('height', '64px'); + await page.locator('.general-icon-choice input').check(); + await expect(page.locator('.general-icon-choice input')).toBeChecked(); + await page.getByRole('button', { name: '아이콘 변경' }).focus(); + await expect(page.getByRole('button', { name: '아이콘 변경' })).toBeFocused(); + await page.getByRole('button', { name: '아이콘 변경' }).hover(); + await waitForVisualAssets(page); + const geometry = await page.locator('#interface-settings').evaluate((element) => { + const rect = element.getBoundingClientRect(); + const icon = element.querySelector('.selected-general-icon img')!; + return { + url: location.href, + width: rect.width, + scrollWidth: document.documentElement.scrollWidth, + icon: { + width: icon.width, + height: icon.height, + naturalWidth: icon.naturalWidth, + naturalHeight: icon.naturalHeight, + objectFit: getComputedStyle(icon).objectFit, + }, + columns: getComputedStyle(element.querySelector('.settings-grid')!).gridTemplateColumns, + html: element.outerHTML, + }; + }); + expect(geometry.width).toBe(viewport.width); + expect(geometry.scrollWidth).toBe(viewport.width); + expect(geometry.icon).toMatchObject({ width: 64, height: 64, objectFit: 'cover' }); + expect(geometry.icon.naturalWidth).toBeGreaterThan(0); + await persistParityArtifact(page, `core-preferences-icon-${viewport.name}`, geometry); + page.once('dialog', async (dialog) => dialog.dismiss()); + await page.getByRole('button', { name: '아이콘 변경' }).click(); + expect(state.adjustIconInputs).toHaveLength(0); + page.once('dialog', async (dialog) => dialog.accept()); + await page.getByRole('button', { name: '아이콘 변경' }).click(); + await expect.poll(() => state.adjustIconInputs?.length ?? 0).toBe(1); + expect(state.adjustIconInputs?.[0]).toMatchObject({ iconId }); + await expect.poll(() => state.generalMeQueries).toBe(3); + expect(state.ensurePrestartQueries).toBe(prestartQueries); + expect(state.settingMutations).toHaveLength(0); + await page.reload(); + await expect(page.locator('.general-icon-action')).toBeVisible(); + expect(state.adjustIconInputs?.[0]?.clientRequestId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + ); + }); +} + +test('환경 설정의 아이콘 조회 오류는 화면 설정을 막지 않고 재시도할 수 있다', async ({ page }) => { const state: FixtureState = { permission: 'member', myset: 1, settingMutations: [], accessPages: [], + generalMeFailure: true, iconChoices: [ { - id: iconId, + id: '3f804277-584f-4f44-b39c-9ecf40d1ed31', picture: 'mine.png', imageServer: 1, createdAt: '2026-08-01T00:00:00.000Z', }, ], - adjustIconInputs: [], }; await install(page, state); - await page.goto('my-page'); - await expect(page.getByText('전용 아이콘 변경 (24시간에 1회)')).toBeVisible(); - await expect(page.locator('.selected-general-icon img')).toHaveCSS('width', '64px'); - await expect(page.locator('.selected-general-icon img')).toHaveCSS('height', '64px'); - await page.locator('.general-icon-choice input').check(); - page.once('dialog', async (dialog) => dialog.accept()); - await page.getByRole('button', { name: '아이콘 변경' }).click(); - await expect.poll(() => state.adjustIconInputs?.length ?? 0).toBe(1); - expect(state.adjustIconInputs?.[0]).toMatchObject({ iconId }); - expect(state.adjustIconInputs?.[0]?.clientRequestId).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i - ); + await page.goto('my-settings'); + await expect(page.getByRole('alert')).toContainText('아이콘 조회 실패'); + await page.getByRole('radio', { name: '500px' }).check(); + await expect.poll(() => page.evaluate(() => localStorage.getItem('sam.screenMode'))).toBe('500px'); + state.generalMeFailure = false; + await page.getByRole('button', { name: '다시 불러오기' }).click(); + await expect(page.locator('.general-icon-action')).toBeVisible(); + state.canChangeIcon = false; + await page.reload(); + await expect.poll(() => state.generalMeQueries).toBe(3); + await expect(page.locator('.general-icon-action')).toHaveCount(0); + await expect(page.locator('#custom_css')).toBeVisible(); + expect(state.ensurePrestartQueries ?? 0).toBe(0); + expect(state.settingMutations).toHaveLength(0); }); test('내 정보 아이템 파기 확인은 이름의 받침에 맞는 조사를 쓴다', async ({ page }) => { diff --git a/app/game-frontend/src/components/main/mainNavigation.ts b/app/game-frontend/src/components/main/mainNavigation.ts index 08d5a2b1..682affa7 100644 --- a/app/game-frontend/src/components/main/mainNavigation.ts +++ b/app/game-frontend/src/components/main/mainNavigation.ts @@ -243,7 +243,7 @@ export const nationNavigation: MainNavigationEntry[] = [ }, ], }, - { kind: 'link', id: 'my-settings', label: '화면 설정', to: '/my-settings', access: 'always' }, + { kind: 'link', id: 'my-settings', label: '환경 설정', to: '/my-settings', access: 'always' }, ]; export const buildNationNavigation = ( diff --git a/app/game-frontend/src/views/MyPageView.vue b/app/game-frontend/src/views/MyPageView.vue index dd67c293..73a106f8 100644 --- a/app/game-frontend/src/views/MyPageView.vue +++ b/app/game-frontend/src/views/MyPageView.vue @@ -6,7 +6,6 @@ import { formatLog } from '../utils/formatLog'; import { formatSeoulDateTime } from '../utils/legacyDateTime'; import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic/scenario/scenarioEffect.js'; import { useSessionStore } from '../stores/session'; -import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon'; import GeneralInformationPanel from '../components/main/GeneralInformationPanel.vue'; import { useGameFeedback } from '../composables/useGameFeedback'; import { gameFrontendRuntimeConfig } from '../config/runtimeConfig'; @@ -46,7 +45,6 @@ const dieOnPrestartStatusLoading = ref(false); const dieOnPrestartStatusError = ref(null); const loading = ref(false); const error = ref(null); -const selectedIconId = ref(''); const session = useSessionStore(); const readPendingDieOnPrestartId = (): string => { const stored = window.sessionStorage.getItem(PENDING_DIE_ON_PRESTART_KEY); @@ -169,8 +167,6 @@ const items = computed data.value?.iconChoices ?? []); -const selectedIcon = computed(() => iconChoices.value.find((icon) => icon.id === selectedIconId.value) ?? null); const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user)); const showAutoNationTurn = computed(() => asRecord(autorunUser.value.options).chief !== false); const showUserRulerAutomation = computed(() => showAutoNationTurn.value && (data.value?.general.npcState ?? 2) < 2); @@ -259,10 +255,6 @@ const loadPage = async (resetImmediateActionIds = true) => { use_auto_nation_finance: general.settings.use_auto_nation_finance, use_auto_nation_capital: general.settings.use_auto_nation_capital, }); - selectedIconId.value = - iconChoices.value.find((icon) => icon.picture === general.general.picture)?.id ?? - iconChoices.value[0]?.id ?? - ''; } await Promise.all(logTypes.map((type) => loadLog(type))); if (resetImmediateActionIds) { @@ -275,20 +267,6 @@ const loadPage = async (resetImmediateActionIds = true) => { } }; -const changeGeneralIcon = async () => { - if (!selectedIconId.value) return; - if (!confirm('선택한 전용 아이콘으로 바꿀까요? 변경 후 24시간 동안 다시 바꿀 수 없습니다.')) return; - try { - await trpc.general.adjustIcon.mutate({ - iconId: selectedIconId.value, - clientRequestId: crypto.randomUUID(), - }); - await loadPage(); - } catch (cause) { - showErrorToast(`전용 아이콘 변경에 실패했습니다: ${errorText(cause)}`); - } -}; - const saveSettings = async () => { if (!canSave.value) return; try { @@ -447,12 +425,7 @@ onMounted(() => { > 사용자 군주 자동 업무