From 70eefc4720493b8451114373da71b32b21faf6ba Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 21 Aug 2026 01:13:22 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(game-ui):=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=ED=8F=AD=EA=B3=BC=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=ED=8F=AC=EC=BB=A4=EC=8A=A4=20=ED=99=95=EB=8C=80=EB=A5=BC=20?= =?UTF-8?q?=EB=B0=94=EB=A1=9C=EC=9E=A1=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref의 700px 자동 판정과 940px 레이아웃 경계를 연결하고 태블릿에서 1000px 모드를 선택한다. 검색 입력에는 pinch zoom을 보존하는 touch-action 계약을 적용한다. --- app/game-frontend/e2e/mainNavigation.spec.ts | 147 +++++++++++++++++- .../e2e/nationGeneralSecret.spec.ts | 11 ++ app/game-frontend/src/assets/main.css | 9 ++ app/game-frontend/src/main.ts | 2 + .../src/utils/screenModeViewport.ts | 78 ++++++++++ app/game-frontend/src/views/MyPageView.vue | 5 +- .../test/screenModeViewport.test.ts | 35 +++++ 7 files changed, 277 insertions(+), 10 deletions(-) create mode 100644 app/game-frontend/src/utils/screenModeViewport.ts create mode 100644 app/game-frontend/test/screenModeViewport.test.ts diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index df63fc59..2d752e4d 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -885,9 +885,9 @@ const expectMobilePanelVisualOrder = async (page: Page, expectedOrder: readonly expect(audit.panels.map(({ id }) => id)).toEqual(expectedOrder); expect(audit.visualOrder).toEqual(expectedOrder); expect(audit.panels.every(({ left, right, width }) => left >= 0 && right <= 500 && width === 500)).toBe(true); - expect( - audit.panels.every((panel, index) => index === 0 || panel.top >= audit.panels[index - 1]!.bottom) - ).toBe(true); + expect(audit.panels.every((panel, index) => index === 0 || panel.top >= audit.panels[index - 1]!.bottom)).toBe( + true + ); for (const panel of audit.panels) { expect(panel.display, `${panel.id}: display`).not.toBe('none'); expect(['static', 'relative'], `${panel.id}: position`).toContain(panel.position); @@ -1125,9 +1125,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await expect(page.locator('.main-mobile-bottom')).toBeHidden(); await expect(page.locator('.layout-desktop')).toBeVisible(); await expect(page.locator('.layout-mobile')).toHaveCount(0); - await expect(page.getByRole('heading', { name: '메인 화면 검증 시나리오 체섭 101기', exact: true })).toHaveCount( - 1 - ); + await expect(page.getByRole('heading', { name: '메인 화면 검증 시나리오 체섭 101기', exact: true })).toHaveCount(1); await expect(page.locator('.game-shell__subtitle')).toHaveCount(0); await expect(page.locator('.legacy-game-info')).toContainText('현재: 185년 1월'); await expect(page.locator('.legacy-game-info')).toContainText('턴: 10분'); @@ -1279,7 +1277,9 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`); }); -test('shows the persisted official game index beside the scenario title without viewport overflow', async ({ page }) => { +test('shows the persisted official game index beside the scenario title without viewport overflow', async ({ + page, +}) => { const state: NavigationFixture = { officerLevel: 5, permission: 2, @@ -2794,6 +2794,139 @@ test('real mobile devices initially fit the complete 500px game canvas', async ( } }); +test('automatic screen mode switches wide mobile screens to the 1000px layout at the Ref boundary', async ({ + browser, +}, testInfo) => { + test.setTimeout(60_000); + const configuredBaseUrl = testInfo.project.use.baseURL; + if (typeof configuredBaseUrl !== 'string') { + throw new Error('Playwright baseURL is required for the automatic screen-mode contract'); + } + + const measurements: Record = {}; + for (const deviceWidth of [699, 700, 820]) { + const context = await browser.newContext({ + baseURL: configuredBaseUrl, + viewport: { width: deviceWidth, height: 1180 }, + screen: { width: deviceWidth, height: 1180 }, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + colorScheme: 'dark', + }); + const mobilePage = await context.newPage(); + const state: NavigationFixture = { + officerLevel: 5, + permission: 2, + nationLevel: 3, + stage: 6, + npcMode: 1, + generalMeCalls: 0, + operations: [], + }; + await installFixture(mobilePage, state); + await waitForMain(mobilePage); + + const expectedWideLayout = deviceWidth >= 700; + await expect(mobilePage.locator(expectedWideLayout ? '.layout-desktop' : '.layout-mobile')).toBeVisible(); + expect( + await mobilePage.evaluate(() => document.querySelector('meta[name="viewport"]')?.content) + ).toBe(expectedWideLayout ? 'width=1000' : 'width=device-width, initial-scale=1'); + const modeMeasurements: Record = { + auto: await mobilePage.locator('.main-page').evaluate((element) => { + const rect = element.getBoundingClientRect(); + const mobileLayout = document.querySelector('.layout-mobile'); + const desktopLayout = document.querySelector('.layout-desktop'); + return { + viewportMeta: document.querySelector('meta[name="viewport"]')?.content, + screenWidth: screen.availWidth, + innerWidth: window.innerWidth, + layoutViewportWidth: document.documentElement.clientWidth, + visualViewportWidth: window.visualViewport?.width ?? null, + visualViewportScale: window.visualViewport?.scale ?? null, + mobileDisplay: mobileLayout ? getComputedStyle(mobileLayout).display : null, + desktopDisplay: desktopLayout ? getComputedStyle(desktopLayout).display : null, + canvas: { left: rect.left, right: rect.right, width: rect.width }, + }; + }), + }; + + if (deviceWidth === 820) { + await mobilePage.evaluate(() => { + localStorage.setItem('sam.screenMode', '500px'); + document.dispatchEvent(new CustomEvent('tryChangeScreenMode')); + }); + await expect(mobilePage.locator('.layout-mobile')).toBeVisible(); + expect( + await mobilePage.evaluate( + () => document.querySelector('meta[name="viewport"]')?.content + ) + ).toBe('width=500'); + modeMeasurements.forced500 = await mobilePage.locator('.main-page').evaluate(() => { + const mobileLayout = document.querySelector('.layout-mobile'); + const desktopLayout = document.querySelector('.layout-desktop'); + return { + viewportMeta: document.querySelector('meta[name="viewport"]')?.content, + layoutViewportWidth: document.documentElement.clientWidth, + visualViewportWidth: window.visualViewport?.width ?? null, + mobileDisplay: mobileLayout ? getComputedStyle(mobileLayout).display : null, + desktopDisplay: desktopLayout ? getComputedStyle(desktopLayout).display : null, + }; + }); + + await mobilePage.evaluate(() => { + localStorage.setItem('sam.screenMode', '1000px'); + document.dispatchEvent(new CustomEvent('tryChangeScreenMode')); + }); + await expect(mobilePage.locator('.layout-desktop')).toBeVisible(); + expect( + await mobilePage.evaluate( + () => document.querySelector('meta[name="viewport"]')?.content + ) + ).toBe('width=1000'); + modeMeasurements.forced1000 = await mobilePage.locator('.main-page').evaluate(() => { + const mobileLayout = document.querySelector('.layout-mobile'); + const desktopLayout = document.querySelector('.layout-desktop'); + return { + viewportMeta: document.querySelector('meta[name="viewport"]')?.content, + layoutViewportWidth: document.documentElement.clientWidth, + visualViewportWidth: window.visualViewport?.width ?? null, + mobileDisplay: mobileLayout ? getComputedStyle(mobileLayout).display : null, + desktopDisplay: desktopLayout ? getComputedStyle(desktopLayout).display : null, + }; + }); + + await mobilePage.evaluate(() => { + localStorage.setItem('sam.screenMode', 'auto'); + document.dispatchEvent(new CustomEvent('tryChangeScreenMode')); + }); + await expect(mobilePage.locator('.layout-desktop')).toBeVisible(); + expect( + await mobilePage.evaluate( + () => document.querySelector('meta[name="viewport"]')?.content + ) + ).toBe('width=1000'); + } + + measurements[String(deviceWidth)] = modeMeasurements; + if (artifactRoot) { + await mkdir(artifactRoot, { recursive: true }); + await mobilePage.screenshot({ + path: resolve(artifactRoot, `auto-screen-mode-${deviceWidth}.png`), + fullPage: true, + }); + } + await context.close(); + } + + if (artifactRoot) { + await writeFile( + resolve(artifactRoot, 'auto-screen-mode-computed-dom.json'), + `${JSON.stringify(measurements, null, 2)}\n` + ); + } +}); + test('nation menu presentation follows the server-derived permission matrix', async ({ page }) => { const state: NavigationFixture = { officerLevel: 1, diff --git a/app/game-frontend/e2e/nationGeneralSecret.spec.ts b/app/game-frontend/e2e/nationGeneralSecret.spec.ts index c4a95cf8..8259b737 100644 --- a/app/game-frontend/e2e/nationGeneralSecret.spec.ts +++ b/app/game-frontend/e2e/nationGeneralSecret.spec.ts @@ -294,8 +294,19 @@ test('nation generals filter buttons open Ref operator menus and apply compound await page.setViewportSize({ width: 500, height: 900 }); expect(await page.locator('.general-page').evaluate((element) => element.getBoundingClientRect().width)).toBe(1000); + const generalSearch = page.getByLabel('장수명 필터'); + await expect(generalSearch).toHaveCSS('touch-action', 'manipulation'); + const viewportContract = await page.evaluate(() => ({ + content: document.querySelector('meta[name="viewport"]')?.content ?? '', + scale: window.visualViewport?.scale ?? 1, + })); + expect(viewportContract.content).not.toMatch(/(?:user-scalable|minimum-scale|maximum-scale)/u); + await generalSearch.focus(); + await expect(generalSearch).toBeFocused(); + expect(await page.evaluate(() => window.visualViewport?.scale ?? 1)).toBe(viewportContract.scale); await nameMenuButton.click(); await expect(namePopup).toBeVisible(); + await expect(page.getByLabel('장수명 첫 번째 필터 값')).toHaveCSS('touch-action', 'manipulation'); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeGreaterThanOrEqual(1000); await page.screenshot({ path: testInfo.outputPath('core-mobile-filter-menu.png'), fullPage: true }); }); diff --git a/app/game-frontend/src/assets/main.css b/app/game-frontend/src/assets/main.css index 4e0d1317..65148076 100644 --- a/app/game-frontend/src/assets/main.css +++ b/app/game-frontend/src/assets/main.css @@ -63,6 +63,15 @@ textarea { font: inherit; } +/* + * Firefox for Android may zoom to a focused search field. `manipulation` + * suppresses that focus-only zoom while retaining ordinary pan and pinch zoom. + */ +input[type='search'], +input[inputmode='search'] { + touch-action: manipulation; +} + /* * Ref's `.bg0/.bg1/.bg2` set a background image and nothing else, so the * element stays transparent where the texture does not cover it. Adding a diff --git a/app/game-frontend/src/main.ts b/app/game-frontend/src/main.ts index 025d426c..fa3cafb7 100644 --- a/app/game-frontend/src/main.ts +++ b/app/game-frontend/src/main.ts @@ -4,8 +4,10 @@ import App from './App.vue'; import router from './router'; import './assets/main.css'; import { installImageAssetCssVariables } from './utils/imageAssets'; +import { installScreenModeViewport } from './utils/screenModeViewport'; installImageAssetCssVariables(); +installScreenModeViewport(); const app = createApp(App); diff --git a/app/game-frontend/src/utils/screenModeViewport.ts b/app/game-frontend/src/utils/screenModeViewport.ts new file mode 100644 index 00000000..260fec55 --- /dev/null +++ b/app/game-frontend/src/utils/screenModeViewport.ts @@ -0,0 +1,78 @@ +export const SCREEN_MODE_KEY = 'sam.screenMode'; +export const SCREEN_MODE_CHANGE_EVENT = 'tryChangeScreenMode'; + +export type ScreenMode = 'auto' | '500px' | '1000px'; + +export type AutoViewportMeasurements = { + deviceWidth: number; + viewportHeight: number; + targetHeight?: number; +}; + +export const normalizeScreenMode = (value: string | null): ScreenMode => + value === '500px' || value === '1000px' ? value : 'auto'; + +export const resolveAutoViewportContent = ({ + deviceWidth, + viewportHeight, + targetHeight = 700, +}: AutoViewportMeasurements): string => { + if (deviceWidth < 500) { + return 'width=500'; + } + + if (viewportHeight < targetHeight) { + const widthAtTargetHeight = (deviceWidth / viewportHeight) * targetHeight; + return widthAtTargetHeight >= 700 ? 'width=1000' : `height=${Math.ceil(targetHeight)}`; + } + + return deviceWidth >= 700 ? 'width=1000' : 'width=device-width, initial-scale=1'; +}; + +export const resolveViewportContent = (mode: ScreenMode, measurements: AutoViewportMeasurements): string => { + if (mode === '500px') return 'width=500'; + if (mode === '1000px') return 'width=1000'; + return resolveAutoViewportContent(measurements); +}; + +const findOrCreateViewportMeta = (): HTMLMetaElement => { + const existing = document.querySelector('meta[name="viewport"]'); + if (existing) return existing; + + const viewportMeta = document.createElement('meta'); + viewportMeta.name = 'viewport'; + document.head.appendChild(viewportMeta); + return viewportMeta; +}; + +export const installScreenModeViewport = (targetHeight = 700): void => { + if (typeof window === 'undefined' || typeof document === 'undefined') return; + + const viewportMeta = findOrCreateViewportMeta(); + let previousMode: ScreenMode | null = null; + let previousDeviceWidth: number | null = null; + + const adjustViewport = () => { + const mode = normalizeScreenMode(window.localStorage.getItem(SCREEN_MODE_KEY)); + const deviceWidth = window.screen.availWidth; + + if (mode === previousMode && mode === 'auto' && deviceWidth === previousDeviceWidth) return; + if (mode === previousMode && mode !== 'auto') return; + + previousMode = mode; + previousDeviceWidth = deviceWidth; + viewportMeta.content = resolveViewportContent(mode, { + deviceWidth, + viewportHeight: window.innerHeight, + targetHeight, + }); + }; + + adjustViewport(); + window.addEventListener('resize', adjustViewport); + window.addEventListener('orientationchange', adjustViewport); + window.addEventListener('storage', (event) => { + if (event.key === SCREEN_MODE_KEY) adjustViewport(); + }); + document.addEventListener(SCREEN_MODE_CHANGE_EVENT, adjustViewport); +}; diff --git a/app/game-frontend/src/views/MyPageView.vue b/app/game-frontend/src/views/MyPageView.vue index 28d367d7..63fe756f 100644 --- a/app/game-frontend/src/views/MyPageView.vue +++ b/app/game-frontend/src/views/MyPageView.vue @@ -9,6 +9,7 @@ import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIc import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue'; import GeneralBasicCard from '../components/main/GeneralBasicCard.vue'; import { useGameFeedback } from '../composables/useGameFeedback'; +import { SCREEN_MODE_CHANGE_EVENT, SCREEN_MODE_KEY, type ScreenMode } from '../utils/screenModeViewport'; import { DEFAULT_MOBILE_MAIN_PANEL_ORDER, loadMobileMainPanelOrder, @@ -18,11 +19,9 @@ import { type MobileMainPanelId, } from '../utils/mobileMainPanelOrder'; -const SCREEN_MODE_KEY = 'sam.screenMode'; const CUSTOM_CSS_KEY = 'sam_customCSS'; const PENDING_DIE_ON_PRESTART_KEY = 'sam.pending.dieOnPrestart'; const { success: showSuccessToast, error: showErrorToast, showDialog } = useGameFeedback(); -type ScreenMode = 'auto' | '500px' | '1000px'; type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction'; type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item'; type MyGeneralResponse = Awaited>; @@ -387,7 +386,7 @@ const dropItem = (item: { key: ItemSlotKey; slotName: string; displayName: strin watch(screenMode, (mode) => { localStorage.setItem(SCREEN_MODE_KEY, mode); - document.dispatchEvent(new CustomEvent('tryChangeScreenMode')); + document.dispatchEvent(new CustomEvent(SCREEN_MODE_CHANGE_EVENT)); }); watch(customCss, (text) => { diff --git a/app/game-frontend/test/screenModeViewport.test.ts b/app/game-frontend/test/screenModeViewport.test.ts new file mode 100644 index 00000000..45e426a9 --- /dev/null +++ b/app/game-frontend/test/screenModeViewport.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + normalizeScreenMode, + resolveAutoViewportContent, + resolveViewportContent, +} from '../src/utils/screenModeViewport.ts'; + +void test('automatic mode follows the Ref physical-screen thresholds', () => { + assert.equal(resolveAutoViewportContent({ deviceWidth: 390, viewportHeight: 844 }), 'width=500'); + assert.equal( + resolveAutoViewportContent({ deviceWidth: 699, viewportHeight: 900 }), + 'width=device-width, initial-scale=1' + ); + assert.equal(resolveAutoViewportContent({ deviceWidth: 700, viewportHeight: 900 }), 'width=1000'); + assert.equal(resolveAutoViewportContent({ deviceWidth: 820, viewportHeight: 1180 }), 'width=1000'); +}); + +void test('automatic mode preserves the Ref short-viewport aspect-ratio branch', () => { + assert.equal(resolveAutoViewportContent({ deviceWidth: 600, viewportHeight: 650 }), 'height=700'); + assert.equal(resolveAutoViewportContent({ deviceWidth: 650, viewportHeight: 600 }), 'width=1000'); +}); + +void test('explicit modes override automatic measurements and invalid storage falls back to auto', () => { + const phone = { deviceWidth: 390, viewportHeight: 844 }; + const tablet = { deviceWidth: 820, viewportHeight: 1180 }; + + assert.equal(resolveViewportContent('1000px', phone), 'width=1000'); + assert.equal(resolveViewportContent('500px', tablet), 'width=500'); + assert.equal(normalizeScreenMode('1000px'), '1000px'); + assert.equal(normalizeScreenMode('500px'), '500px'); + assert.equal(normalizeScreenMode('unexpected'), 'auto'); + assert.equal(normalizeScreenMode(null), 'auto'); +}); From c924eaf205ec9db3d806fb03ceb7cebbb5e6c108 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 21 Aug 2026 01:16:35 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(message):=20=EC=88=98=EB=87=8C=EC=9D=98?= =?UTF-8?q?=20=EC=9E=AC=EC=95=BC=20=EA=B4=91=EA=B3=A0=20=EB=B0=9C=EC=86=A1?= =?UTF-8?q?=20=EB=8C=80=EC=83=81=EC=9D=84=20=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref와 같이 외교 권한이 있는 장수에게 재야 국가 우편함을 노출한다. 서버 권한 강제와 재야 수신 경로, 실제 Chromium 발송 흐름을 회귀 테스트로 고정한다. --- app/game-api/test/messagesRouter.test.ts | 110 ++++++++++++++++++ app/game-frontend/src/stores/mainDashboard.ts | 2 +- .../ingame-message-parity.spec.ts | 61 +++++++++- .../reference-ingame-message.mjs | 14 ++- 4 files changed, 181 insertions(+), 6 deletions(-) diff --git a/app/game-api/test/messagesRouter.test.ts b/app/game-api/test/messagesRouter.test.ts index 3572cc13..993eeb79 100644 --- a/app/game-api/test/messagesRouter.test.ts +++ b/app/game-api/test/messagesRouter.test.ts @@ -260,6 +260,116 @@ describe('messages router missing-flow compatibility', () => { expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9002, 'diplomacy'])); }); + it('allows a ruler to send a recruitment advertisement to the wanderer mailbox', async () => { + const ruler = { ...general, officerLevel: 12 } as GeneralRow; + const queryRaw = vi.fn(async () => [{ id: 53 }]); + const changeJournal = new ChangeJournal(); + const { caller } = buildContext( + { + $queryRaw: queryRaw, + general: { + findUnique: vi.fn(async () => ruler), + findMany: vi.fn(async () => []), + }, + nation: { + findMany: vi.fn(async () => []), + findUnique: vi.fn(async () => ({ id: 1, name: '위', color: '#112233', meta: {} })), + }, + }, + { changeJournal } + ); + + const result = await caller.messages.send({ + generalId: ruler.id, + mailbox: 9000, + text: '우리 나라로 와주세요', + }); + + expect(result.msgType).toBe('diplomacy'); + expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9000, 'diplomacy'])); + expect(queryRaw).toHaveBeenCalledTimes(2); + expect(changeJournal.snapshot()).toEqual([ + { domain: 'messages.mailbox', entityId: 9000 }, + { domain: 'messages.mailbox', entityId: 9001 }, + ]); + }); + + it('keeps the wanderer mailbox unavailable to a non-diplomat on the server', async () => { + const queryRaw = vi.fn(async () => [{ id: 54 }]); + const { caller } = buildContext({ + $queryRaw: queryRaw, + nation: { + findMany: vi.fn(async () => []), + findUnique: vi.fn(async () => ({ id: 1, name: '위', color: '#112233', meta: {} })), + }, + }); + + const result = await caller.messages.send({ + generalId: general.id, + mailbox: 9000, + text: '권한 없는 재야 광고', + }); + + expect(result.msgType).toBe('national'); + expect(queryRaw).toHaveBeenCalledTimes(1); + expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9001, 'national'])); + }); + + it('shows a wanderer the advertisement stored in mailbox 9000 without diplomacy redaction', async () => { + const wanderer = { ...general, nationId: 0, officerLevel: 0 } as GeneralRow; + const advertisementRow = { + id: 55, + mailbox: 9000, + type: 'diplomacy', + src: 9001, + dest: 9000, + time: new Date(), + valid_until: new Date('9999-12-31T00:00:00Z'), + message: { + src: { + generalId: 1, + generalName: '위왕', + nationId: 1, + nationName: '위', + color: '#112233', + icon: '', + }, + dest: { + generalId: 0, + generalName: '', + nationId: 0, + nationName: '재야', + color: '#000000', + icon: '', + }, + text: '우리 나라로 와주세요', + option: {}, + }, + }; + const queryRaw = vi.fn(async (...args: unknown[]) => { + const values = args.slice(1); + return values.includes(9000) && values.includes('diplomacy') ? [advertisementRow] : []; + }); + const { caller } = buildContext({ + $queryRaw: queryRaw, + general: { + findUnique: vi.fn(async () => wanderer), + findMany: vi.fn(async () => []), + }, + }); + + const result = await caller.messages.getRecent({ generalId: wanderer.id }); + + expect(result.permission).toBe(-1); + expect(result.diplomacy).toEqual([ + expect.objectContaining({ + text: '우리 나라로 와주세요', + dest: expect.objectContaining({ nationId: 0, nationName: '재야' }), + option: {}, + }), + ]); + }); + it('blocks private messages between foreign ambassadors', async () => { const ambassador = { ...general, diff --git a/app/game-frontend/src/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts index 2b313082..37ad2a7f 100644 --- a/app/game-frontend/src/stores/mainDashboard.ts +++ b/app/game-frontend/src/stores/mainDashboard.ts @@ -220,7 +220,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { label: '외교메시지', color: '#000000', options: contacts - .filter((nation) => nation.mailbox !== ownMailbox && nation.nationId > 0) + .filter((nation) => nation.mailbox !== ownMailbox) .map((nation) => ({ label: nation.name, value: nation.mailbox, diff --git a/tools/frontend-legacy-parity/ingame-message-parity.spec.ts b/tools/frontend-legacy-parity/ingame-message-parity.spec.ts index 30ded23f..cb8be207 100644 --- a/tools/frontend-legacy-parity/ingame-message-parity.spec.ts +++ b/tools/frontend-legacy-parity/ingame-message-parity.spec.ts @@ -1,8 +1,11 @@ import { expect, test, type Page, type Route } from '@playwright/test'; +import { mkdir } from 'node:fs/promises'; +import { resolve } from 'node:path'; import { canonicalFrontendFixture as fixture } from './fixtures/canonical.js'; const gamePort = process.env.FRONTEND_PARITY_GAME_PORT ?? '15102'; +const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR; const response = (data: unknown) => ({ result: { data } }); const errorResponse = (path: string, message: string) => ({ error: { @@ -42,7 +45,35 @@ const general = { const generalContext = { general, city: null, - nation: null, + nation: { + id: 1, + name: '테스트국', + color: '#d32f2f', + level: 1, + levelName: '군벌', + gold: 1000, + rice: 1000, + tech: 1000, + typeCode: 'test', + typeName: '테스트', + typePros: '-', + typeCons: '-', + capitalCityId: 1, + capitalCityName: '낙양', + population: { cityCount: 1, current: 10000, max: 20000 }, + crew: { generalCount: 2, current: 1000, max: 16000 }, + power: 100, + bill: 10, + taxRate: 10, + strategicCommandLimit: 0, + diplomaticLimit: 0, + prohibitScout: false, + prohibitWar: false, + techLevel: 1, + techLimited: false, + topChiefs: { 12: null, 11: null }, + impossibleStrategicCommands: [], + }, settings: {}, penalties: {}, }; @@ -373,14 +404,25 @@ for (const viewport of [ boxShadow: getComputedStyle(element).boxShadow, })) ).toEqual({ outlineWidth: '0px', boxShadow: 'none' }); + + if (artifactRoot) { + await mkdir(artifactRoot, { recursive: true }); + await page.locator('.MessagePanel').screenshot({ + path: resolve(artifactRoot, `message-panel-${viewport.width}.png`), + animations: 'disabled', + }); + } }); } -test('exposes ambassador targets, reply, read, delete, and successful send interactions', async ({ page }) => { +test('exposes nation targets including wanderers, reply, read, delete, and successful send interactions', async ({ + page, +}) => { const mutations = await installFixture(page, { permission: 4 }); await openMessages(page, { width: 500, height: 900 }); const select = page.getByLabel('메시지 수신 대상'); + await expect(select.locator('optgroup[label="외교메시지"] option[value="9000"]')).toHaveText('재야'); await expect(select.locator('option[value="9002"]')).toHaveCount(1); await expect(select.locator('option[value="8"]')).toBeDisabled(); await expect(select.locator('option[value="9"]')).toBeEnabled(); @@ -396,11 +438,21 @@ test('exposes ambassador targets, reply, read, delete, and successful send inter await deleteButton.click(); await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.delete').length).toBe(1); - await select.selectOption('9999'); - await page.getByLabel('메시지 입력').fill('전송 성공'); + await select.selectOption('9000'); + await page.getByLabel('메시지 입력').fill('우리 나라로 와주세요'); + if (artifactRoot) { + await mkdir(artifactRoot, { recursive: true }); + await page.locator('.MessageInputForm').screenshot({ + path: resolve(artifactRoot, 'wanderer-recruitment-target-500.png'), + animations: 'disabled', + }); + } await page.getByRole('button', { name: '서신전달&갱신' }).click(); await expect(page.getByLabel('메시지 입력')).toHaveValue(''); await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.send').length).toBe(1); + expect(JSON.stringify(mutations.find((entry) => entry.operation === 'messages.send')?.body)).toContain( + '"mailbox":9000' + ); }); test('accepts recruitment and declines invader prompts through private-message controls', async ({ page }) => { @@ -442,6 +494,7 @@ test('redacts diplomacy for a low-permission general and preserves the failed-se await openMessages(page, { width: 500, height: 900 }); const select = page.getByLabel('메시지 수신 대상'); + await expect(select.locator('option[value="9000"]')).toHaveCount(0); await expect(select.locator('option[value="9002"]')).toHaveCount(0); await expect(page.locator('.DiplomacyTalk')).toContainText('삭제된 메시지입니다'); await expect(page.locator('.DiplomacyTalk')).not.toContainText('외교 메시지 본문'); diff --git a/tools/frontend-legacy-parity/reference-ingame-message.mjs b/tools/frontend-legacy-parity/reference-ingame-message.mjs index 45c147eb..3f2e447e 100644 --- a/tools/frontend-legacy-parity/reference-ingame-message.mjs +++ b/tools/frontend-legacy-parity/reference-ingame-message.mjs @@ -65,6 +65,14 @@ const measure = async (browser, name, viewport) => { await ensureGeneral(page); await page.locator('.MessagePanel').waitFor({ state: 'visible', timeout: 30_000 }); await page.locator('.BoardHeader').first().waitFor({ state: 'visible' }); + const mailboxOptions = await page.locator('.MessageInputForm select option').evaluateAll((options) => + options.map((option) => ({ + value: option.value, + label: option.textContent?.trim() ?? '', + group: option.parentElement?.tagName === 'OPTGROUP' ? option.parentElement.label : '', + disabled: option.disabled, + })) + ); const marker = `computed-dom-${name}-${Date.now()}`; await page.locator('.MessageInputForm select').selectOption('9999'); await page.locator('.MessageInputForm input').fill(marker); @@ -160,7 +168,11 @@ const measure = async (browser, name, viewport) => { page.once('dialog', (dialog) => dialog.accept()); await deleteButton.click(); } - return { ...result, interaction: { hover, focus } }; + return { + ...result, + mailboxOptions, + interaction: { hover, focus }, + }; } finally { await context.close(); }