fix: 모바일 터치 드래그 정렬 복구
NPC 정책 우선순위와 모바일 메인 패널 순서를 Ref와 같은 vuedraggable 기반으로 전환한다. 실제 모바일 Chromium 터치 제스처 회귀 검증을 추가한다.
This commit is contained in:
@@ -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'],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<TouchPoint> => {
|
||||
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<void> => {
|
||||
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');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user