From 26920add190d568134dca40d91ab8fe176371e06 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 21 Aug 2026 00:55:37 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20?= =?UTF-8?q?=ED=84=B0=EC=B9=98=20=EB=93=9C=EB=9E=98=EA=B7=B8=20=EC=A0=95?= =?UTF-8?q?=EB=A0=AC=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NPC 정책 우선순위와 모바일 메인 패널 순서를 Ref와 같은 vuedraggable 기반으로 전환한다. 실제 모바일 Chromium 터치 제스처 회귀 검증을 추가한다. --- app/game-frontend/e2e/inGameMenus.spec.ts | 65 +++++++++ app/game-frontend/e2e/npcPolicy.spec.ts | 53 ++++++++ app/game-frontend/e2e/touchDrag.ts | 77 +++++++++++ app/game-frontend/package.json | 1 + .../src/components/ui/SortableStringList.ts | 43 ++++++ app/game-frontend/src/views/MyPageView.vue | 90 +++++-------- .../src/views/NpcControlView.vue | 123 +++++++----------- pnpm-lock.yaml | 27 +++- 8 files changed, 342 insertions(+), 137 deletions(-) create mode 100644 app/game-frontend/e2e/touchDrag.ts create mode 100644 app/game-frontend/src/components/ui/SortableStringList.ts diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 442a0535..3a9515cf 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { basename, resolve } from 'node:path'; import { expect, test, type Locator, type Page, type Route } from '@playwright/test'; import { gameBasePath, gameProfile, gameTrpcRoute } from './gameTestPaths.js'; +import { touchDrag } from './touchDrag.js'; const response = (data: unknown) => ({ result: { data } }); const parityArtifactDir = process.env.MENU_PARITY_ARTIFACT_DIR; @@ -1469,6 +1470,70 @@ test('내 정보&설정에서 모바일 메인 패널을 드래그하거나 버 .toEqual(defaultOrder); }); +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'); + } + const context = await browser.newContext({ + baseURL: configuredBaseUrl, + viewport: { width: 390, height: 844 }, + screen: { width: 390, height: 844 }, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + colorScheme: 'dark', + }); + const mobilePage = await context.newPage(); + try { + const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] }; + await install(mobilePage, state); + await mobilePage.goto('my-page'); + await mobilePage.getByRole('button', { name: '순서 바꾸기', exact: true }).click(); + + const dialog = mobilePage.getByRole('dialog', { name: '모바일 레이아웃 순서 바꾸기' }); + const commands = dialog.locator('[data-mobile-layout-id="commands"]'); + const nationMenu = dialog.locator('[data-mobile-layout-id="nation-menu"]'); + await touchDrag(mobilePage, nationMenu, commands); + + await expect + .poll(() => + dialog + .locator('[data-mobile-layout-id]') + .evaluateAll((elements) => elements.map((element) => element.getAttribute('data-mobile-layout-id'))) + ) + .toEqual([ + 'nation-menu', + 'commands', + 'nation', + 'general', + 'city', + 'map', + 'records', + 'global-menu', + 'messages', + ]); + await dialog.screenshot({ path: testInfo.outputPath('mobile-main-panel-touch-dialog.png') }); + await dialog.getByRole('button', { name: '적용', exact: true }).click(); + await expect + .poll(() => mobilePage.evaluate(() => JSON.parse(localStorage.getItem('sam.mobileMainPanelOrder.v1') ?? '[]'))) + .toEqual([ + 'nation-menu', + 'commands', + 'nation', + 'general', + 'city', + 'map', + 'records', + 'global-menu', + 'messages', + ]); + await mobilePage.screenshot({ path: testInfo.outputPath('mobile-main-panel-touch.png'), fullPage: true }); + } finally { + await context.close(); + } +}); + for (const [label, failure] of [ ['daemon timeout', 'TIMEOUT'], ['engine transaction 오류', 'INTERNAL_SERVER_ERROR'], diff --git a/app/game-frontend/e2e/npcPolicy.spec.ts b/app/game-frontend/e2e/npcPolicy.spec.ts index 81fd4f2c..97e0b2ab 100644 --- a/app/game-frontend/e2e/npcPolicy.spec.ts +++ b/app/game-frontend/e2e/npcPolicy.spec.ts @@ -3,6 +3,7 @@ import { mkdir, readFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { gameProfile, gameTrpcRoute } from './gameTestPaths.js'; +import { touchDrag } from './touchDrag.js'; type FixtureState = { permissionLevel: number; @@ -320,6 +321,58 @@ test('500px layout stacks policy fields and priority panels like the reference', await screenshot(page, 'core-npc-policy-mobile.png'); }); +test('physical mobile touch reorders NPC priority across active and inactive lists', 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'); + } + const context = await browser.newContext({ + baseURL: configuredBaseUrl, + viewport: { width: 390, height: 844 }, + screen: { width: 390, height: 844 }, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + colorScheme: 'dark', + }); + const mobilePage = await context.newPage(); + try { + await installFixture(mobilePage, { permissionLevel: 4, mutations: [] }); + await gotoPolicy(mobilePage); + await expect(mobilePage.locator('#container')).toBeVisible(); + + const nationPanel = mobilePage.locator('.priority-panel').first(); + const activeList = nationPanel.locator('.priority-column').nth(1).locator('.priority-list'); + const activeRows = activeList.locator('.priority-item'); + await touchDrag( + mobilePage, + activeRows.nth(0), + activeRows.nth(3), + { targetYRatio: 0.9 } + ); + await expect + .poll(() => + activeList + .locator('.priority-item .priority_info > span:nth-child(2)') + .first() + .textContent() + ) + .toBe('선전포고'); + + const activeItem = activeList.getByText('불가침제의', { exact: true }); + const inactiveList = nationPanel.locator('.priority-column').first().locator('.priority-list'); + await touchDrag(mobilePage, activeItem, inactiveList.locator('.inactive-header')); + + await expect(inactiveList.getByText('불가침제의', { exact: true })).toBeVisible(); + await expect( + activeList.getByText('불가침제의', { exact: true }) + ).toHaveCount(0); + await mobilePage.screenshot({ path: testInfo.outputPath('npc-priority-mobile-touch.png'), fullPage: true }); + } finally { + await context.close(); + } +}); + test('a read-level user sees enabled legacy controls but a forbidden save retains the draft', async ({ page }) => { const state: FixtureState = { permissionLevel: 1, failNextMutation: true, mutations: [] }; await installFixture(page, state); diff --git a/app/game-frontend/e2e/touchDrag.ts b/app/game-frontend/e2e/touchDrag.ts new file mode 100644 index 00000000..c79bcb99 --- /dev/null +++ b/app/game-frontend/e2e/touchDrag.ts @@ -0,0 +1,77 @@ +import type { Locator, Page } from '@playwright/test'; + +type TouchPoint = { + x: number; + y: number; +}; + +type TouchDragOptions = { + targetYRatio?: number; +}; + +const pointIn = async (locator: Locator, yRatio = 0.5): Promise => { + const box = await locator.boundingBox(); + if (!box) { + throw new Error('Touch drag target has no visible bounding box'); + } + return { + x: box.x + box.width / 2, + y: box.y + box.height * yRatio, + }; +}; + +export const touchDrag = async ( + page: Page, + source: Locator, + target: Locator, + options: TouchDragOptions = {} +): Promise => { + await source.scrollIntoViewIfNeeded(); + await target.scrollIntoViewIfNeeded(); + const from = await pointIn(source); + const to = await pointIn(target, options.targetYRatio); + const cdp = await page.context().newCDPSession(page); + await page.evaluate(() => { + document.documentElement.removeAttribute('data-playwright-touch-trusted'); + document.addEventListener( + 'touchstart', + (event) => document.documentElement.setAttribute('data-playwright-touch-trusted', String(event.isTrusted)), + { capture: true, once: true } + ); + }); + + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [{ ...from, id: 0, radiusX: 1, radiusY: 1, force: 1 }], + }); + await page.waitForTimeout(50); + const dispatchMove = async (ratio: number) => { + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchMove', + touchPoints: [ + { + x: from.x + (to.x - from.x) * ratio, + y: from.y + (to.y - from.y) * ratio, + id: 0, + radiusX: 1, + radiusY: 1, + force: 1, + }, + ], + }); + }; + await dispatchMove(0.05); + await page.waitForTimeout(100); + for (let step = 2; step <= 20; step += 1) { + await dispatchMove(step / 20); + await page.waitForTimeout(16); + } + await page.waitForTimeout(50); + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); + const trusted = await page.evaluate( + () => document.documentElement.getAttribute('data-playwright-touch-trusted') === 'true' + ); + if (!trusted) { + throw new Error('Chromium did not dispatch a trusted touchstart event'); + } +}; diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index 992a9a0f..5297e4cf 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -47,6 +47,7 @@ "mitt": "^3.0.1", "pinia": "^3.0.4", "vue": "^3.5.26", + "vuedraggable-es": "4.1.1", "vue-router": "^4.6.4", "zod": "^4.3.5" }, diff --git a/app/game-frontend/src/components/ui/SortableStringList.ts b/app/game-frontend/src/components/ui/SortableStringList.ts new file mode 100644 index 00000000..12973226 --- /dev/null +++ b/app/game-frontend/src/components/ui/SortableStringList.ts @@ -0,0 +1,43 @@ +import { defineComponent, h, type PropType, type SlotsType, type VNode } from 'vue'; +import VueDraggable from 'vuedraggable-es'; + +export default defineComponent({ + name: 'SortableStringList', + inheritAttrs: false, + props: { + list: { + type: Array as PropType, + required: true, + }, + group: { + type: String, + default: undefined, + }, + tag: { + type: String, + default: 'div', + }, + }, + slots: Object as SlotsType<{ + header?: () => VNode[]; + item: (props: { element: string; index: number }) => VNode[]; + }>, + setup(props, { attrs, slots }) { + return () => + h( + VueDraggable, + { + ...attrs, + list: props.list, + group: props.group, + itemKey: (item: string) => item, + tag: props.tag, + }, + { + header: () => slots.header?.(), + item: ({ element, index }: { element: string; index: number }) => + slots.item({ element, index }), + } + ); + }, +}); diff --git a/app/game-frontend/src/views/MyPageView.vue b/app/game-frontend/src/views/MyPageView.vue index 28d367d7..122330c8 100644 --- a/app/game-frontend/src/views/MyPageView.vue +++ b/app/game-frontend/src/views/MyPageView.vue @@ -1,5 +1,6 @@