merge: 최신 main을 기본 등용 명령 복구에 통합
This commit is contained in:
@@ -522,6 +522,21 @@ const commandTable = {
|
||||
buildCityCommand('che_백성동원', '백성동원'),
|
||||
buildCityCommand('che_수몰', '수몰'),
|
||||
buildCityCommand('che_허보', '허보'),
|
||||
buildNationCommand('che_이호경식', '이호경식'),
|
||||
buildNationCommand('che_급습', '급습'),
|
||||
{
|
||||
...buildNationCommand('che_피장파장', '피장파장'),
|
||||
inputFields: [
|
||||
...buildNationCommand('che_피장파장', '피장파장').inputFields,
|
||||
{
|
||||
key: 'commandType',
|
||||
label: '대응 명령',
|
||||
kind: 'select',
|
||||
required: true,
|
||||
options: [{ value: 'che_수몰', label: '수몰' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -815,7 +830,7 @@ const install = async (page: Page, rejectGeneral = false, commandTableResponse:
|
||||
{ id: 2, name: '허창', level: 7, region: 2, x: 240, y: 180, path: [1] },
|
||||
],
|
||||
regionMap: { 1: '하북', 2: '예주' },
|
||||
levelMap: { 8: '특' },
|
||||
levelMap: { 8: '특', 7: '대' },
|
||||
});
|
||||
if (name === 'auth.status') return response({ ok: true });
|
||||
if (name === 'lobby.info')
|
||||
@@ -953,9 +968,7 @@ test('shows and reserves the Ref spy command for a user on desktop and mobile',
|
||||
await expect(spy).toBeFocused();
|
||||
await spy.click();
|
||||
const form = picker.getByTestId('command-argument-form');
|
||||
await expect(form.getByTestId('command-argument-guidance')).toContainText(
|
||||
'선택한 도시에 첩보를 실행합니다.'
|
||||
);
|
||||
await expect(form.getByTestId('command-argument-guidance')).toContainText('선택한 도시에 첩보를 실행합니다.');
|
||||
await expect(form.getByTestId('command-argument-guidance')).toContainText(
|
||||
'인접 도시에서는 더 많은 정보를 얻습니다.'
|
||||
);
|
||||
@@ -979,9 +992,7 @@ test('shows and reserves the Ref spy command for a user on desktop and mobile',
|
||||
await picker.screenshot({ path: test.info().outputPath('spy-command-mobile-500.png') });
|
||||
});
|
||||
|
||||
test('defaults founding to a Ref-selectable nation trait and paints color option labels', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('defaults founding to a Ref-selectable nation trait and paints color option labels', async ({ page }) => {
|
||||
const foundingColors = [
|
||||
{ value: 0, label: '색상 1', color: '#FF0000' },
|
||||
{ value: 15, label: '색상 16', color: '#6495ED' },
|
||||
@@ -1284,7 +1295,9 @@ test('keeps general and chief command categories after input and across page rel
|
||||
const reloadedChiefPicker = chiefPage.getByTestId('command-picker');
|
||||
await expect(reloadedChiefPicker.getByRole('button', { name: '전략', exact: true })).toHaveClass(/active/);
|
||||
await expect(reloadedChiefPicker.getByRole('button', { name: '필사즉생', exact: true })).toBeVisible();
|
||||
await expect.poll(() => reloadedChiefPicker.evaluate((element) => element.getBoundingClientRect().height)).toBeGreaterThan(200);
|
||||
await expect
|
||||
.poll(() => reloadedChiefPicker.evaluate((element) => element.getBoundingClientRect().height))
|
||||
.toBeGreaterThan(200);
|
||||
await reloadedChiefPicker.screenshot({
|
||||
path: test.info().outputPath('chief-category-after-reload-mobile-500.png'),
|
||||
});
|
||||
@@ -1780,18 +1793,25 @@ test('uses the map to choose a nation target in the chief command window', async
|
||||
await page.screenshot({ path: test.info().outputPath('chief-nation-map-option.png'), fullPage: true });
|
||||
});
|
||||
|
||||
test('shows city or capital maps for every requested chief command', async ({ page }) => {
|
||||
test('shows a map and target details for every city or nation argument chief command except assignment', async ({
|
||||
page,
|
||||
}) => {
|
||||
await install(page);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
const cases = [
|
||||
{ category: '인사', action: '발령', mode: 'city' },
|
||||
{ category: '특수', action: '초토화', mode: 'city' },
|
||||
{ category: '특수', action: '천도', mode: 'city' },
|
||||
{ category: '특수', action: '증축', mode: 'capital' },
|
||||
{ category: '특수', action: '감축', mode: 'capital' },
|
||||
{ category: '전략', action: '수몰', mode: 'city' },
|
||||
{ category: '전략', action: '허보', mode: 'city' },
|
||||
{ category: '전략', action: '백성동원', mode: 'city' },
|
||||
{ category: '외교', action: '원조', mode: 'nation' },
|
||||
{ category: '외교', action: '불가침 제의', mode: 'nation' },
|
||||
{ category: '외교', action: '선전포고', mode: 'nation' },
|
||||
{ category: '외교', action: '종전 제의', mode: 'nation' },
|
||||
{ category: '외교', action: '불가침 파기 제의', mode: 'nation' },
|
||||
{ category: '전략', action: '이호경식', mode: 'nation' },
|
||||
{ category: '전략', action: '급습', mode: 'nation' },
|
||||
{ category: '전략', action: '피장파장', mode: 'nation' },
|
||||
];
|
||||
|
||||
for (const entry of cases) {
|
||||
@@ -1805,24 +1825,106 @@ test('shows city or capital maps for every requested chief command', async ({ pa
|
||||
await expect(map, `${entry.action} 지도`).toBeVisible();
|
||||
await expect(form.getByTestId('command-argument-guidance')).toBeVisible();
|
||||
|
||||
if (entry.mode === 'capital') {
|
||||
await expect(form.getByTestId('command-map-selection-status')).toContainText('현재 수도업');
|
||||
await expect(form.getByTestId('command-map-target-summary')).toContainText('현재 수도');
|
||||
await expect(map.locator('.city-base').first()).toHaveJSProperty('tagName', 'DIV');
|
||||
expect(
|
||||
await map
|
||||
.locator('.city-base')
|
||||
.first()
|
||||
.evaluate((node) => getComputedStyle(node).cursor)
|
||||
).toBe('default');
|
||||
} else {
|
||||
await map.locator('.city-base').nth(1).click();
|
||||
await map.locator('.city-base').nth(1).click();
|
||||
if (entry.mode === 'city') {
|
||||
await expect(form.locator('#command-arg-destCityId')).toHaveValue('2');
|
||||
await expect(form.getByTestId('command-map-selection-status')).toContainText('선택 도시허창');
|
||||
await expect(form.getByTestId('command-map-target-summary')).toContainText(
|
||||
'허창 · 적국 · 예주 · 대 · 현재 도시에서 1칸'
|
||||
);
|
||||
} else {
|
||||
await expect(form.locator('#command-arg-destNationId')).toHaveValue('2');
|
||||
await expect(form.getByTestId('command-map-selection-status')).toContainText('선택 국가적국');
|
||||
await expect(form.getByTestId('command-map-target-summary')).toContainText('적국 · 수도 허창 · 도시 1개');
|
||||
}
|
||||
await expect(form.getByTestId('current-city-marker')).toHaveAttribute('aria-label', '현재 도시 업');
|
||||
}
|
||||
|
||||
await page.screenshot({ path: test.info().outputPath('chief-command-map-guidance.png'), fullPage: true });
|
||||
await page.screenshot({ path: test.info().outputPath('chief-command-target-map-details.png'), fullPage: true });
|
||||
|
||||
await page.setViewportSize({ width: 500, height: 900 });
|
||||
await page.goto('/che/chief-center');
|
||||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||
const picker = page.getByTestId('command-picker');
|
||||
await picker.getByRole('button', { name: /^(?:국가:)?전략$/, exact: true }).click();
|
||||
await picker.getByRole('button', { name: /피장파장/ }).click();
|
||||
const form = picker.getByTestId('command-argument-form');
|
||||
const map = form.getByTestId('command-argument-map');
|
||||
const targetCity = map.locator('.city-base').nth(1);
|
||||
await targetCity.hover();
|
||||
expect(await targetCity.evaluate((node) => getComputedStyle(node).cursor)).toBe('pointer');
|
||||
await targetCity.focus();
|
||||
await expect(targetCity).toBeFocused();
|
||||
await targetCity.click();
|
||||
await expect(form.locator('#command-arg-destNationId')).toHaveValue('2');
|
||||
const geometry = await picker.evaluate((element) => ({
|
||||
pickerWidth: element.getBoundingClientRect().width,
|
||||
pickerOverflow: element.scrollWidth - element.clientWidth,
|
||||
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
mapInsidePicker:
|
||||
element.querySelector<HTMLElement>('[data-testid="command-argument-map"]')!.getBoundingClientRect().right <=
|
||||
element.getBoundingClientRect().right,
|
||||
}));
|
||||
expect(geometry).toEqual({ pickerWidth: 500, pickerOverflow: 0, documentOverflow: 0, mapInsidePicker: true });
|
||||
await page.screenshot({
|
||||
path: test.info().outputPath('chief-command-target-map-details-mobile.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('prioritizes own cities for assignment while retaining other map targets', async ({ page }) => {
|
||||
const assignmentTable = structuredClone(commandTable);
|
||||
assignmentTable.inputOptions.cities = [
|
||||
{ value: 2, label: '허창 (적국)', description: '적국 · 예주 · 대도시' },
|
||||
{ value: 3, label: '단양 (무주)' },
|
||||
{ value: 1, label: '업 (아국)' },
|
||||
];
|
||||
|
||||
await install(page, false, assignmentTable);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
await page.goto('/che/chief-center');
|
||||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||
const picker = page.getByTestId('command-picker');
|
||||
await picker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
|
||||
await picker.getByRole('button', { name: /발령/ }).click();
|
||||
|
||||
const form = picker.getByTestId('command-argument-form');
|
||||
const citySelect = form.locator('#command-arg-destCityId');
|
||||
await expect(form.getByTestId('command-argument-map')).toBeVisible();
|
||||
await expect(citySelect.locator('option')).toHaveText(['업 (아국)', '허창 (적국)', '단양 (무주)']);
|
||||
await expect(citySelect).toHaveValue('1');
|
||||
|
||||
await form.getByTestId('command-argument-map').locator('.city-base').nth(1).click();
|
||||
await expect(citySelect).toHaveValue('2');
|
||||
await expect(form.getByTestId('command-map-selection-status')).toContainText('선택 도시허창');
|
||||
await expect(page).toHaveURL(/\/che\/chief-center$/);
|
||||
await form.screenshot({ path: test.info().outputPath('chief-assignment-own-city-priority.png') });
|
||||
|
||||
await page.setViewportSize({ width: 500, height: 900 });
|
||||
await page.goto('/che/chief-center');
|
||||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||
const mobilePicker = page.getByTestId('command-picker');
|
||||
await mobilePicker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
|
||||
await mobilePicker.getByRole('button', { name: /발령/ }).click();
|
||||
|
||||
const mobileForm = mobilePicker.getByTestId('command-argument-form');
|
||||
const mobileMap = mobileForm.getByTestId('command-argument-map');
|
||||
const mobileCitySelect = mobileForm.locator('#command-arg-destCityId');
|
||||
await expect(mobileMap).toBeVisible();
|
||||
await expect(mobileCitySelect.locator('option')).toHaveText(['업 (아국)', '허창 (적국)', '단양 (무주)']);
|
||||
const mobileGeometry = await mobilePicker.evaluate((element) => ({
|
||||
width: element.getBoundingClientRect().width,
|
||||
overflow: element.scrollWidth - element.clientWidth,
|
||||
}));
|
||||
expect(mobileGeometry).toEqual({ width: 500, overflow: 0 });
|
||||
const mapGeometry = await mobileMap.locator('.map-area').evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { width: rect.width, height: rect.height };
|
||||
});
|
||||
expect(mapGeometry.width / mapGeometry.height).toBeCloseTo(7 / 5, 2);
|
||||
await mobileMap.screenshot({ path: test.info().outputPath('chief-assignment-map-mobile.png') });
|
||||
await mobileCitySelect.scrollIntoViewIfNeeded();
|
||||
await mobilePicker.screenshot({ path: test.info().outputPath('chief-assignment-own-city-priority-mobile.png') });
|
||||
});
|
||||
|
||||
test('prioritizes current nation targets while preserving every choice', async ({ page }) => {
|
||||
|
||||
@@ -467,6 +467,112 @@ test('국가 정보의 작위는 Ref 국가 등급 이름으로 표시된다', a
|
||||
await expect(root).not.toContainText('작 위1');
|
||||
});
|
||||
|
||||
test('map keeps desktop hover navigation and lets touch users choose one-tap or two-tap city navigation', async ({
|
||||
browser,
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await install(page);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
await go(page, 'global-info');
|
||||
|
||||
const desktopCity = page.locator('.city-base').first();
|
||||
await expect(page.locator('.map-toggle-single-tap')).toHaveCount(0);
|
||||
await desktopCity.hover();
|
||||
await expect(page.locator('.map-tooltip .tooltip-title')).toHaveText('【하북|특】업');
|
||||
await desktopCity.click();
|
||||
await expect(page).toHaveURL(/\/current-city\?cityId=1$/u);
|
||||
|
||||
const configuredBaseUrl = testInfo.project.use.baseURL;
|
||||
if (typeof configuredBaseUrl !== 'string') {
|
||||
throw new Error('Playwright baseURL is required for the mobile map 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 install(mobilePage);
|
||||
await go(mobilePage, 'global-info');
|
||||
|
||||
const twoTapButton = mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 끄기' });
|
||||
await expect(twoTapButton).toBeVisible();
|
||||
await expect(twoTapButton).toHaveAttribute('aria-pressed', 'false');
|
||||
const controlGeometry = await twoTapButton.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const mapRect = element.closest('.map-area')?.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
right: rect.right,
|
||||
bottom: rect.bottom,
|
||||
mapRight: mapRect?.right,
|
||||
mapBottom: mapRect?.bottom,
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
viewportWidth: document.documentElement.clientWidth,
|
||||
overflowing: Array.from(document.querySelectorAll<HTMLElement>('body *'))
|
||||
.filter(
|
||||
(candidate) => candidate.getBoundingClientRect().right > document.documentElement.clientWidth
|
||||
)
|
||||
.map((candidate) => ({
|
||||
tag: candidate.tagName,
|
||||
className: candidate.className,
|
||||
right: candidate.getBoundingClientRect().right,
|
||||
}))
|
||||
.slice(0, 8),
|
||||
};
|
||||
});
|
||||
expect(controlGeometry).toMatchObject({
|
||||
fontSize: '11px',
|
||||
lineHeight: '18px',
|
||||
viewportWidth: 500,
|
||||
overflowing: [],
|
||||
});
|
||||
expect(controlGeometry.documentWidth).toBeLessThanOrEqual(controlGeometry.viewportWidth + 1);
|
||||
expect(controlGeometry.mapRight! - controlGeometry.right).toBeCloseTo(4, 1);
|
||||
expect(controlGeometry.mapBottom! - controlGeometry.bottom).toBeCloseTo(4, 1);
|
||||
|
||||
const mobileCities = mobilePage.locator('.city-base');
|
||||
await mobileCities.nth(0).tap();
|
||||
await expect(mobilePage).toHaveURL(/\/global-info$/u);
|
||||
await expect(mobilePage.locator('.map-tooltip .tooltip-title')).toHaveText('【하북|특】업');
|
||||
|
||||
await mobileCities.nth(1).tap();
|
||||
await expect(mobilePage).toHaveURL(/\/global-info$/u);
|
||||
await expect(mobilePage.locator('.map-tooltip .tooltip-title')).toHaveText('【하북|수】성2');
|
||||
await mobilePage.screenshot({ path: testInfo.outputPath('mobile-map-first-tap-tooltip.png'), fullPage: true });
|
||||
|
||||
await mobileCities.nth(1).tap();
|
||||
await expect(mobilePage).toHaveURL(/\/current-city\?cityId=2$/u);
|
||||
|
||||
await go(mobilePage, 'global-info');
|
||||
await mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 끄기' }).click();
|
||||
const singleTapButton = mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 켜기' });
|
||||
await expect(singleTapButton).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(await mobilePage.evaluate(() => localStorage.getItem('sam.toggleSingleTap'))).toBe('yes');
|
||||
|
||||
await mobilePage.reload();
|
||||
await expect(singleTapButton).toBeVisible();
|
||||
await mobilePage.locator('.city-base').nth(2).tap();
|
||||
await expect(mobilePage).toHaveURL(/\/current-city\?cityId=3$/u);
|
||||
|
||||
await go(mobilePage, 'global-info');
|
||||
await mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 켜기' }).click();
|
||||
expect(await mobilePage.evaluate(() => localStorage.getItem('sam.toggleSingleTap'))).toBe('no');
|
||||
await mobilePage.locator('.city-base').first().tap();
|
||||
await expect(mobilePage).toHaveURL(/\/global-info$/u);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('global-info renders the ref nation summary columns beside the map', async ({ page }) => {
|
||||
await install(page);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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);
|
||||
@@ -1102,7 +1102,9 @@ test('scopes the new-survey notice cursor to the reset-specific server ID', asyn
|
||||
expect(await page.evaluate(() => localStorage.getItem('state.che.lastVote'))).toBe('99');
|
||||
});
|
||||
|
||||
test('desktop menus preserve ref columns, prefix-safe routes, and controlled dropdown behavior', async ({ page }) => {
|
||||
test('desktop menus preserve ref columns, prefix-safe routes, and controlled dropdown behavior', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const state: NavigationFixture = {
|
||||
officerLevel: 5,
|
||||
permission: 2,
|
||||
@@ -1125,9 +1127,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분');
|
||||
@@ -1252,6 +1252,34 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
|
||||
const versionDialog = page.getByRole('dialog', { name: '게임 정보' });
|
||||
await expect(versionDialog).toBeVisible();
|
||||
await expect(versionDialog).toContainText('메인 화면 검증 시나리오');
|
||||
await expect(versionDialog.getByText('빌드 커밋', { exact: true })).toBeVisible();
|
||||
await expect(versionDialog.locator('code')).toHaveText('0123456789abcdef0123456789abcdef01234567');
|
||||
const versionGeometry = await versionDialog.evaluate((dialog) => {
|
||||
const code = dialog.querySelector('code');
|
||||
if (!code) throw new Error('game version commit is missing');
|
||||
const dialogStyle = getComputedStyle(dialog);
|
||||
const codeStyle = getComputedStyle(code);
|
||||
return {
|
||||
dialog: dialog.getBoundingClientRect().toJSON(),
|
||||
code: code.getBoundingClientRect().toJSON(),
|
||||
dialogBackground: dialogStyle.backgroundColor,
|
||||
dialogColor: dialogStyle.color,
|
||||
codeColor: codeStyle.color,
|
||||
codeFontFamily: codeStyle.fontFamily,
|
||||
viewportWidth: window.innerWidth,
|
||||
};
|
||||
});
|
||||
expect(versionGeometry.dialog.width).toBeLessThanOrEqual(versionGeometry.viewportWidth - 32);
|
||||
expect(versionGeometry.code.left).toBeGreaterThanOrEqual(versionGeometry.dialog.left);
|
||||
expect(versionGeometry.code.right).toBeLessThanOrEqual(versionGeometry.dialog.right);
|
||||
expect(versionGeometry.dialogBackground).toBe('rgb(32, 32, 32)');
|
||||
expect(versionGeometry.dialogColor).toBe('rgb(255, 255, 255)');
|
||||
expect(versionGeometry.codeColor).toBe('rgb(215, 215, 215)');
|
||||
await writeFile(
|
||||
testInfo.outputPath('desktop-game-version-dialog.json'),
|
||||
`${JSON.stringify(versionGeometry, null, 2)}\n`
|
||||
);
|
||||
await versionDialog.screenshot({ path: testInfo.outputPath('desktop-game-version-dialog.png') });
|
||||
await versionDialog.getByRole('button', { name: '닫기' }).click();
|
||||
await expect(versionDialog).toBeHidden();
|
||||
|
||||
@@ -1279,7 +1307,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,
|
||||
@@ -1479,6 +1509,30 @@ test('the repeated bottom global menu opens upward on the mobile document', asyn
|
||||
expect(geometry.caretBorderTopWidth).toBe('0px');
|
||||
expect(geometry.caretBorderBottomWidth).toBe('4px');
|
||||
await bottomGlobal.screenshot({ path: testInfo.outputPath('mobile-bottom-global-dropup.png') });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await bottomGlobal.locator('[data-navigation-id="version"]').click();
|
||||
const versionDialog = page.getByRole('dialog', { name: '게임 정보' });
|
||||
await expect(versionDialog).toBeVisible();
|
||||
await expect(versionDialog.locator('code')).toHaveText('0123456789abcdef0123456789abcdef01234567');
|
||||
const versionGeometry = await versionDialog.evaluate((dialog) => {
|
||||
const code = dialog.querySelector('code');
|
||||
if (!code) throw new Error('game version commit is missing');
|
||||
return {
|
||||
dialog: dialog.getBoundingClientRect().toJSON(),
|
||||
code: code.getBoundingClientRect().toJSON(),
|
||||
viewportWidth: window.innerWidth,
|
||||
documentScrollWidth: document.documentElement.scrollWidth,
|
||||
};
|
||||
});
|
||||
expect(versionGeometry.dialog.width).toBeLessThanOrEqual(versionGeometry.viewportWidth - 32);
|
||||
expect(versionGeometry.code.left).toBeGreaterThanOrEqual(versionGeometry.dialog.left);
|
||||
expect(versionGeometry.code.right).toBeLessThanOrEqual(versionGeometry.dialog.right);
|
||||
expect(versionGeometry.documentScrollWidth).toBe(500);
|
||||
await writeFile(
|
||||
testInfo.outputPath('mobile-game-version-dialog.json'),
|
||||
`${JSON.stringify(versionGeometry, null, 2)}\n`
|
||||
);
|
||||
await versionDialog.screenshot({ path: testInfo.outputPath('mobile-game-version-dialog.png') });
|
||||
await persistArtifact(page, `${basePath.slice(1)}-mobile-bottom-dropup`);
|
||||
});
|
||||
|
||||
@@ -2794,6 +2848,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<string, unknown> = {};
|
||||
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<HTMLMetaElement>('meta[name="viewport"]')?.content)
|
||||
).toBe(expectedWideLayout ? 'width=1000' : 'width=device-width, initial-scale=1');
|
||||
const modeMeasurements: Record<string, unknown> = {
|
||||
auto: await mobilePage.locator('.main-page').evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const mobileLayout = document.querySelector<HTMLElement>('.layout-mobile');
|
||||
const desktopLayout = document.querySelector<HTMLElement>('.layout-desktop');
|
||||
return {
|
||||
viewportMeta: document.querySelector<HTMLMetaElement>('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<HTMLMetaElement>('meta[name="viewport"]')?.content
|
||||
)
|
||||
).toBe('width=500');
|
||||
modeMeasurements.forced500 = await mobilePage.locator('.main-page').evaluate(() => {
|
||||
const mobileLayout = document.querySelector<HTMLElement>('.layout-mobile');
|
||||
const desktopLayout = document.querySelector<HTMLElement>('.layout-desktop');
|
||||
return {
|
||||
viewportMeta: document.querySelector<HTMLMetaElement>('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<HTMLMetaElement>('meta[name="viewport"]')?.content
|
||||
)
|
||||
).toBe('width=1000');
|
||||
modeMeasurements.forced1000 = await mobilePage.locator('.main-page').evaluate(() => {
|
||||
const mobileLayout = document.querySelector<HTMLElement>('.layout-mobile');
|
||||
const desktopLayout = document.querySelector<HTMLElement>('.layout-desktop');
|
||||
return {
|
||||
viewportMeta: document.querySelector<HTMLMetaElement>('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<HTMLMetaElement>('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,
|
||||
|
||||
@@ -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<HTMLMetaElement>('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 });
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -9,11 +9,12 @@ const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'che:default';
|
||||
const baseURL = `http://127.0.0.1:${port}${basePath}/`;
|
||||
const gameApiUrl = process.env.PLAYWRIGHT_GAME_API_URL ?? `${basePath}/api/trpc`;
|
||||
const gatewayWebUrl = process.env.PLAYWRIGHT_GATEWAY_WEB_URL ?? '/gateway/';
|
||||
const buildCommitSha = process.env.PLAYWRIGHT_BUILD_COMMIT_SHA ?? '0123456789abcdef0123456789abcdef01234567';
|
||||
const useProductionBundle = process.env.PLAYWRIGHT_FRONTEND_MODE === 'production';
|
||||
const frontendEnv =
|
||||
`VITE_APP_BASE_PATH=${basePath} VITE_GAME_API_URL=${gameApiUrl} ` +
|
||||
`VITE_GAME_PROFILE=${gameProfile} VITE_GATEWAY_WEB_URL=${gatewayWebUrl} ` +
|
||||
'VITE_GATEWAY_API_URL=/gateway/api/trpc';
|
||||
`VITE_GATEWAY_API_URL=/gateway/api/trpc VITE_BUILD_COMMIT_SHA=${buildCommitSha}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: '.',
|
||||
|
||||
@@ -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