merge: 최신 main을 모바일 터치 드래그 브랜치에 통합

This commit is contained in:
2026-08-21 01:09:37 +00:00
11 changed files with 383 additions and 63 deletions
+60 -7
View File
@@ -953,9 +953,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 +977,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 +1280,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'),
});
@@ -1825,6 +1823,61 @@ test('shows city or capital maps for every requested chief command', async ({ pa
await page.screenshot({ path: test.info().outputPath('chief-command-map-guidance.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 }) => {
await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
+106
View File
@@ -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 });
@@ -0,0 +1,21 @@
import type { CommandMapData, CommandOption } from './types';
export const commandCityOptions = (
commandKey: string,
options: readonly CommandOption[],
mapData?: CommandMapData | null
): CommandOption[] => {
if (commandKey !== 'che_발령' || typeof mapData?.myNation !== 'number') return [...options];
const nationByCityId = new Map(mapData.cityList.map(([cityId, , , nationId]) => [cityId, nationId]));
return options
.map((option, index) => ({ option, index }))
.sort((left, right) => {
const leftOwned =
typeof left.option.value === 'number' && nationByCityId.get(left.option.value) === mapData.myNation;
const rightOwned =
typeof right.option.value === 'number' && nationByCityId.get(right.option.value) === mapData.myNation;
return Number(rightOwned) - Number(leftOwned) || left.index - right.index;
})
.map(({ option }) => option);
};
@@ -2,6 +2,7 @@
import { computed, reactive, watch, type CSSProperties } from 'vue';
import MapViewer from './MapViewer.vue';
import { commandArgumentPresentation } from '../command/commandArgumentPresentation';
import { commandCityOptions } from '../command/commandArgumentOptions';
import {
commandArgumentFieldContract,
shouldPreserveCommandArgumentValue,
@@ -64,6 +65,9 @@ const optionsFor = (field: CommandInputField): CommandOption[] => {
if (field.optionSource === 'nations') {
return props.options.nationTargets?.[props.commandKey] ?? props.options.nations;
}
if (field.optionSource === 'cities') {
return commandCityOptions(props.commandKey, props.options.cities, props.mapData);
}
if (field.optionSource === 'items') {
return props.options.items[String(values.itemType ?? '')] ?? [];
}
@@ -104,12 +108,7 @@ const synchronizeValues = () => {
for (const field of props.fields) {
const preserve =
!commandChanged &&
shouldPreserveCommandArgumentValue(
field,
previousFieldContracts.get(field.key),
values,
optionsFor(field)
);
shouldPreserveCommandArgumentValue(field, previousFieldContracts.get(field.key), values, optionsFor(field));
if (!preserve) values[field.key] = defaultValue(field);
}
const itemCodeField = props.fields.find((field) => field.key === 'itemCode');
@@ -28,6 +28,8 @@ const props = defineProps<{
const emit = defineEmits<{
(event: 'hover', cityId: number): void;
(event: 'leave'): void;
(event: 'touch', cityId: number, touchEvent: TouchEvent): void;
(event: 'touchleave'): void;
(event: 'select', cityId: number): void;
}>();
@@ -37,6 +39,25 @@ const stateOffset = computed(() => -6 * props.mapScale);
const selectCity = () => {
if (!props.readonly) emit('select', props.city.id);
};
let touchOnTrack = false;
const touchstart = () => {
touchOnTrack = true;
};
const touchmove = () => {
touchOnTrack = false;
};
const touchend = (event: TouchEvent) => {
if (touchOnTrack) {
event.stopPropagation();
emit('touch', props.city.id, event);
return;
}
emit('touchleave');
};
</script>
<template>
@@ -59,6 +80,9 @@ const selectCity = () => {
:style="{ left: `${props.city.x}px`, top: `${props.city.y}px` }"
@mouseenter="emit('hover', props.city.id)"
@mouseleave="emit('leave')"
@touchstart="touchstart"
@touchmove="touchmove"
@touchend="touchend"
@click.stop="selectCity"
>
<div class="city-dot" :style="{ backgroundColor: props.city.color, width: `${size}px`, height: `${size}px` }">
@@ -54,6 +54,8 @@ const props = defineProps<{
const emit = defineEmits<{
(event: 'hover', cityId: number): void;
(event: 'leave'): void;
(event: 'touch', cityId: number, touchEvent: TouchEvent): void;
(event: 'touchleave'): void;
(event: 'select', cityId: number): void;
}>();
@@ -148,6 +150,25 @@ const selectCity = () => {
if (!props.readonly) emit('select', props.city.id);
};
let touchOnTrack = false;
const touchstart = () => {
touchOnTrack = true;
};
const touchmove = () => {
touchOnTrack = false;
};
const touchend = (event: TouchEvent) => {
if (touchOnTrack) {
event.stopPropagation();
emit('touch', props.city.id, event);
return;
}
emit('touchleave');
};
const cityStateStyle = computed(() => ({
width: `${12 * props.mapScale}px`,
height: `${12 * props.mapScale}px`,
@@ -174,6 +195,9 @@ const cityStateStyle = computed(() => ({
:style="cityBaseStyle"
@mouseenter="emit('hover', props.city.id)"
@mouseleave="emit('leave')"
@touchstart="touchstart"
@touchmove="touchmove"
@touchend="touchend"
@click.stop="selectCity"
>
<div v-if="cityBgStyle" class="city-bg" :style="[cityBgWrapperStyle, cityBgStyle]" />
@@ -94,9 +94,11 @@ const mapStore = useMapViewerStore();
const {
showCityName,
detailMode: storeDetailMode,
singleTapNavigation,
hoveredCityId,
selectedCityId: storeSelectedCityId,
} = storeToRefs(mapStore);
const hasTouchInput = useMediaQuery('(any-pointer: coarse)');
const mapArea = ref<HTMLElement | null>(null);
const mapBody = ref<HTMLElement | null>(null);
@@ -404,6 +406,28 @@ const setHoveredCity = (cityId: number | null) => {
mapStore.setHoveredCity(cityId);
};
const touchPreviewCityId = ref<number | null>(null);
const clearTouchPreview = () => {
touchPreviewCityId.value = null;
setHoveredCity(null);
};
const touchCity = (cityId: number, event: TouchEvent) => {
if (touchPreviewCityId.value !== cityId) {
touchPreviewCityId.value = cityId;
setHoveredCity(cityId);
if (!singleTapNavigation.value) {
event.preventDefault();
}
}
};
const toggleSingleTapNavigation = () => {
clearTouchPreview();
mapStore.toggleSingleTapNavigation();
};
const selectCity = (cityId: number) => {
if (props.readonly) return;
emit('select-city', cityId);
@@ -433,6 +457,7 @@ const selectCity = (cityId: number) => {
class="map-area"
:class="[mapThemeClass, mapSeasonClass]"
:style="{ width: mapWidth, height: mapHeight }"
@click="clearTouchPreview"
>
<div class="map-layer map-bglayer1" :style="mapBackgroundStyle" />
<div class="map-layer map-bglayer2" />
@@ -449,6 +474,8 @@ const selectCity = (cityId: number) => {
v-bind="detailProps"
@hover="setHoveredCity"
@leave="setHoveredCity(null)"
@touch="touchCity"
@touchleave="clearTouchPreview"
@select="selectCity"
/>
<div
@@ -466,9 +493,18 @@ const selectCity = (cityId: number) => {
<div class="tooltip-body">{{ hoveredCity.nationId > 0 ? hoveredCity.nationName : '' }}</div>
</div>
<div class="map-controls">
<button class="map-toggle" :class="{ active: showCityName }" @click="mapStore.toggleCityName">
<button class="map-toggle" :class="{ active: showCityName }" @click.stop="mapStore.toggleCityName">
도시명 표기 {{ showCityName ? '끄기' : '켜기' }}
</button>
<button
v-if="hasTouchInput"
class="map-toggle map-toggle-single-tap"
:class="{ active: singleTapNavigation }"
:aria-pressed="singleTapNavigation"
@click.stop="toggleSingleTapNavigation"
>
두번 도시 이동 {{ singleTapNavigation ? '켜기' : '끄기' }}
</button>
</div>
</div>
</div>
@@ -555,6 +591,8 @@ const selectCity = (cityId: number) => {
right: 4px;
bottom: 4px;
display: flex;
flex-direction: column;
align-items: flex-end;
}
.map-toggle {
+15
View File
@@ -3,14 +3,23 @@ import { defineStore } from 'pinia';
interface MapViewerState {
showCityName: boolean;
detailMode: boolean;
singleTapNavigation: boolean;
hoveredCityId: number | null;
selectedCityId: number | null;
}
const SINGLE_TAP_STORAGE_KEY = 'sam.toggleSingleTap';
const loadSingleTapNavigation = (): boolean => {
if (typeof window === 'undefined') return false;
return window.localStorage.getItem(SINGLE_TAP_STORAGE_KEY) === 'yes';
};
export const useMapViewerStore = defineStore('mapViewer', {
state: (): MapViewerState => ({
showCityName: true,
detailMode: true,
singleTapNavigation: loadSingleTapNavigation(),
hoveredCityId: null,
selectedCityId: null,
}),
@@ -21,6 +30,12 @@ export const useMapViewerStore = defineStore('mapViewer', {
toggleDetailMode() {
this.detailMode = !this.detailMode;
},
toggleSingleTapNavigation() {
this.singleTapNavigation = !this.singleTapNavigation;
if (typeof window !== 'undefined') {
window.localStorage.setItem(SINGLE_TAP_STORAGE_KEY, this.singleTapNavigation ? 'yes' : 'no');
}
},
setHoveredCity(cityId: number | null) {
this.hoveredCityId = cityId;
},
@@ -0,0 +1,57 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { commandCityOptions } from '../src/components/command/commandArgumentOptions.ts';
import type { CommandMapData, CommandOption } from '../src/components/command/types.ts';
const cities: CommandOption[] = [
{ value: 20, label: '적국 도시' },
{ value: 30, label: '아국 도시 둘' },
{ value: 40, label: '공백지' },
{ value: 10, label: '아국 도시 하나' },
];
const mapData: CommandMapData = {
year: 200,
month: 1,
startYear: 180,
cityList: [
[10, 8, 0, 1, 1, 1],
[20, 7, 0, 2, 2, 1],
[30, 6, 0, 1, 3, 1],
[40, 5, 0, 0, 4, 1],
],
nationList: [
[1, '아국', '#008000', 10],
[2, '적국', '#800000', 20],
],
myCity: 10,
myNation: 1,
};
void test('발령은 아국 도시를 먼저 두고 적국과 공백지를 원래 순서로 보존한다', () => {
const sorted = commandCityOptions('che_발령', cities, mapData);
assert.deepEqual(
sorted.map((option) => option.value),
[30, 10, 20, 40]
);
assert.deepEqual(
cities.map((option) => option.value),
[20, 30, 40, 10],
'공용 입력 option은 변경하지 않는다'
);
});
void test('다른 도시 대상 명령의 순서는 바꾸지 않는다', () => {
assert.deepEqual(
commandCityOptions('che_출병', cities, mapData).map((option) => option.value),
[20, 30, 40, 10]
);
});
void test('지도 국가 정보가 아직 없으면 발령의 기존 option 순서를 유지한다', () => {
assert.deepEqual(
commandCityOptions('che_발령', cities, null).map((option) => option.value),
[20, 30, 40, 10]
);
});