merge: 최신 main을 모바일 터치 드래그 브랜치에 통합
This commit is contained in:
@@ -953,9 +953,7 @@ test('shows and reserves the Ref spy command for a user on desktop and mobile',
|
|||||||
await expect(spy).toBeFocused();
|
await expect(spy).toBeFocused();
|
||||||
await spy.click();
|
await spy.click();
|
||||||
const form = picker.getByTestId('command-argument-form');
|
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(
|
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') });
|
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 ({
|
test('defaults founding to a Ref-selectable nation trait and paints color option labels', async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const foundingColors = [
|
const foundingColors = [
|
||||||
{ value: 0, label: '색상 1', color: '#FF0000' },
|
{ value: 0, label: '색상 1', color: '#FF0000' },
|
||||||
{ value: 15, label: '색상 16', color: '#6495ED' },
|
{ 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');
|
const reloadedChiefPicker = chiefPage.getByTestId('command-picker');
|
||||||
await expect(reloadedChiefPicker.getByRole('button', { name: '전략', exact: true })).toHaveClass(/active/);
|
await expect(reloadedChiefPicker.getByRole('button', { name: '전략', exact: true })).toHaveClass(/active/);
|
||||||
await expect(reloadedChiefPicker.getByRole('button', { name: '필사즉생', exact: true })).toBeVisible();
|
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({
|
await reloadedChiefPicker.screenshot({
|
||||||
path: test.info().outputPath('chief-category-after-reload-mobile-500.png'),
|
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 });
|
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 }) => {
|
test('prioritizes current nation targets while preserving every choice', async ({ page }) => {
|
||||||
await install(page);
|
await install(page);
|
||||||
await page.setViewportSize({ width: 1200, height: 900 });
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
|
|||||||
@@ -467,6 +467,112 @@ test('국가 정보의 작위는 Ref 국가 등급 이름으로 표시된다', a
|
|||||||
await expect(root).not.toContainText('작 위1');
|
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 }) => {
|
test('global-info renders the ref nation summary columns beside the map', async ({ page }) => {
|
||||||
await install(page);
|
await install(page);
|
||||||
await page.setViewportSize({ width: 1200, height: 900 });
|
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 { computed, reactive, watch, type CSSProperties } from 'vue';
|
||||||
import MapViewer from './MapViewer.vue';
|
import MapViewer from './MapViewer.vue';
|
||||||
import { commandArgumentPresentation } from '../command/commandArgumentPresentation';
|
import { commandArgumentPresentation } from '../command/commandArgumentPresentation';
|
||||||
|
import { commandCityOptions } from '../command/commandArgumentOptions';
|
||||||
import {
|
import {
|
||||||
commandArgumentFieldContract,
|
commandArgumentFieldContract,
|
||||||
shouldPreserveCommandArgumentValue,
|
shouldPreserveCommandArgumentValue,
|
||||||
@@ -64,6 +65,9 @@ const optionsFor = (field: CommandInputField): CommandOption[] => {
|
|||||||
if (field.optionSource === 'nations') {
|
if (field.optionSource === 'nations') {
|
||||||
return props.options.nationTargets?.[props.commandKey] ?? props.options.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') {
|
if (field.optionSource === 'items') {
|
||||||
return props.options.items[String(values.itemType ?? '')] ?? [];
|
return props.options.items[String(values.itemType ?? '')] ?? [];
|
||||||
}
|
}
|
||||||
@@ -104,12 +108,7 @@ const synchronizeValues = () => {
|
|||||||
for (const field of props.fields) {
|
for (const field of props.fields) {
|
||||||
const preserve =
|
const preserve =
|
||||||
!commandChanged &&
|
!commandChanged &&
|
||||||
shouldPreserveCommandArgumentValue(
|
shouldPreserveCommandArgumentValue(field, previousFieldContracts.get(field.key), values, optionsFor(field));
|
||||||
field,
|
|
||||||
previousFieldContracts.get(field.key),
|
|
||||||
values,
|
|
||||||
optionsFor(field)
|
|
||||||
);
|
|
||||||
if (!preserve) values[field.key] = defaultValue(field);
|
if (!preserve) values[field.key] = defaultValue(field);
|
||||||
}
|
}
|
||||||
const itemCodeField = props.fields.find((field) => field.key === 'itemCode');
|
const itemCodeField = props.fields.find((field) => field.key === 'itemCode');
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(event: 'hover', cityId: number): void;
|
(event: 'hover', cityId: number): void;
|
||||||
(event: 'leave'): void;
|
(event: 'leave'): void;
|
||||||
|
(event: 'touch', cityId: number, touchEvent: TouchEvent): void;
|
||||||
|
(event: 'touchleave'): void;
|
||||||
(event: 'select', cityId: number): void;
|
(event: 'select', cityId: number): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -37,6 +39,25 @@ const stateOffset = computed(() => -6 * props.mapScale);
|
|||||||
const selectCity = () => {
|
const selectCity = () => {
|
||||||
if (!props.readonly) emit('select', props.city.id);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -59,6 +80,9 @@ const selectCity = () => {
|
|||||||
:style="{ left: `${props.city.x}px`, top: `${props.city.y}px` }"
|
:style="{ left: `${props.city.x}px`, top: `${props.city.y}px` }"
|
||||||
@mouseenter="emit('hover', props.city.id)"
|
@mouseenter="emit('hover', props.city.id)"
|
||||||
@mouseleave="emit('leave')"
|
@mouseleave="emit('leave')"
|
||||||
|
@touchstart="touchstart"
|
||||||
|
@touchmove="touchmove"
|
||||||
|
@touchend="touchend"
|
||||||
@click.stop="selectCity"
|
@click.stop="selectCity"
|
||||||
>
|
>
|
||||||
<div class="city-dot" :style="{ backgroundColor: props.city.color, width: `${size}px`, height: `${size}px` }">
|
<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<{
|
const emit = defineEmits<{
|
||||||
(event: 'hover', cityId: number): void;
|
(event: 'hover', cityId: number): void;
|
||||||
(event: 'leave'): void;
|
(event: 'leave'): void;
|
||||||
|
(event: 'touch', cityId: number, touchEvent: TouchEvent): void;
|
||||||
|
(event: 'touchleave'): void;
|
||||||
(event: 'select', cityId: number): void;
|
(event: 'select', cityId: number): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -148,6 +150,25 @@ const selectCity = () => {
|
|||||||
if (!props.readonly) emit('select', props.city.id);
|
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(() => ({
|
const cityStateStyle = computed(() => ({
|
||||||
width: `${12 * props.mapScale}px`,
|
width: `${12 * props.mapScale}px`,
|
||||||
height: `${12 * props.mapScale}px`,
|
height: `${12 * props.mapScale}px`,
|
||||||
@@ -174,6 +195,9 @@ const cityStateStyle = computed(() => ({
|
|||||||
:style="cityBaseStyle"
|
:style="cityBaseStyle"
|
||||||
@mouseenter="emit('hover', props.city.id)"
|
@mouseenter="emit('hover', props.city.id)"
|
||||||
@mouseleave="emit('leave')"
|
@mouseleave="emit('leave')"
|
||||||
|
@touchstart="touchstart"
|
||||||
|
@touchmove="touchmove"
|
||||||
|
@touchend="touchend"
|
||||||
@click.stop="selectCity"
|
@click.stop="selectCity"
|
||||||
>
|
>
|
||||||
<div v-if="cityBgStyle" class="city-bg" :style="[cityBgWrapperStyle, cityBgStyle]" />
|
<div v-if="cityBgStyle" class="city-bg" :style="[cityBgWrapperStyle, cityBgStyle]" />
|
||||||
|
|||||||
@@ -94,9 +94,11 @@ const mapStore = useMapViewerStore();
|
|||||||
const {
|
const {
|
||||||
showCityName,
|
showCityName,
|
||||||
detailMode: storeDetailMode,
|
detailMode: storeDetailMode,
|
||||||
|
singleTapNavigation,
|
||||||
hoveredCityId,
|
hoveredCityId,
|
||||||
selectedCityId: storeSelectedCityId,
|
selectedCityId: storeSelectedCityId,
|
||||||
} = storeToRefs(mapStore);
|
} = storeToRefs(mapStore);
|
||||||
|
const hasTouchInput = useMediaQuery('(any-pointer: coarse)');
|
||||||
|
|
||||||
const mapArea = ref<HTMLElement | null>(null);
|
const mapArea = ref<HTMLElement | null>(null);
|
||||||
const mapBody = ref<HTMLElement | null>(null);
|
const mapBody = ref<HTMLElement | null>(null);
|
||||||
@@ -404,6 +406,28 @@ const setHoveredCity = (cityId: number | null) => {
|
|||||||
mapStore.setHoveredCity(cityId);
|
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) => {
|
const selectCity = (cityId: number) => {
|
||||||
if (props.readonly) return;
|
if (props.readonly) return;
|
||||||
emit('select-city', cityId);
|
emit('select-city', cityId);
|
||||||
@@ -433,6 +457,7 @@ const selectCity = (cityId: number) => {
|
|||||||
class="map-area"
|
class="map-area"
|
||||||
:class="[mapThemeClass, mapSeasonClass]"
|
:class="[mapThemeClass, mapSeasonClass]"
|
||||||
:style="{ width: mapWidth, height: mapHeight }"
|
:style="{ width: mapWidth, height: mapHeight }"
|
||||||
|
@click="clearTouchPreview"
|
||||||
>
|
>
|
||||||
<div class="map-layer map-bglayer1" :style="mapBackgroundStyle" />
|
<div class="map-layer map-bglayer1" :style="mapBackgroundStyle" />
|
||||||
<div class="map-layer map-bglayer2" />
|
<div class="map-layer map-bglayer2" />
|
||||||
@@ -449,6 +474,8 @@ const selectCity = (cityId: number) => {
|
|||||||
v-bind="detailProps"
|
v-bind="detailProps"
|
||||||
@hover="setHoveredCity"
|
@hover="setHoveredCity"
|
||||||
@leave="setHoveredCity(null)"
|
@leave="setHoveredCity(null)"
|
||||||
|
@touch="touchCity"
|
||||||
|
@touchleave="clearTouchPreview"
|
||||||
@select="selectCity"
|
@select="selectCity"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
@@ -466,9 +493,18 @@ const selectCity = (cityId: number) => {
|
|||||||
<div class="tooltip-body">{{ hoveredCity.nationId > 0 ? hoveredCity.nationName : '' }}</div>
|
<div class="tooltip-body">{{ hoveredCity.nationId > 0 ? hoveredCity.nationName : '' }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="map-controls">
|
<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 ? '끄기' : '켜기' }}
|
도시명 표기 {{ showCityName ? '끄기' : '켜기' }}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -555,6 +591,8 @@ const selectCity = (cityId: number) => {
|
|||||||
right: 4px;
|
right: 4px;
|
||||||
bottom: 4px;
|
bottom: 4px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-toggle {
|
.map-toggle {
|
||||||
|
|||||||
@@ -3,14 +3,23 @@ import { defineStore } from 'pinia';
|
|||||||
interface MapViewerState {
|
interface MapViewerState {
|
||||||
showCityName: boolean;
|
showCityName: boolean;
|
||||||
detailMode: boolean;
|
detailMode: boolean;
|
||||||
|
singleTapNavigation: boolean;
|
||||||
hoveredCityId: number | null;
|
hoveredCityId: number | null;
|
||||||
selectedCityId: 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', {
|
export const useMapViewerStore = defineStore('mapViewer', {
|
||||||
state: (): MapViewerState => ({
|
state: (): MapViewerState => ({
|
||||||
showCityName: true,
|
showCityName: true,
|
||||||
detailMode: true,
|
detailMode: true,
|
||||||
|
singleTapNavigation: loadSingleTapNavigation(),
|
||||||
hoveredCityId: null,
|
hoveredCityId: null,
|
||||||
selectedCityId: null,
|
selectedCityId: null,
|
||||||
}),
|
}),
|
||||||
@@ -21,6 +30,12 @@ export const useMapViewerStore = defineStore('mapViewer', {
|
|||||||
toggleDetailMode() {
|
toggleDetailMode() {
|
||||||
this.detailMode = !this.detailMode;
|
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) {
|
setHoveredCity(cityId: number | null) {
|
||||||
this.hoveredCityId = cityId;
|
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]
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -126,6 +126,19 @@ test('bootstrap superuser can navigate the administrator workspace from the lobb
|
|||||||
|
|
||||||
await expect(page).toHaveURL(/\/gateway\/admin$/);
|
await expect(page).toHaveURL(/\/gateway\/admin$/);
|
||||||
await expect(page.getByRole('heading', { name: '운영 개요' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: '운영 개요' })).toBeVisible();
|
||||||
|
await expect(page.getByText('관리 대상을 선택하세요.')).toBeVisible();
|
||||||
|
await expect(page.getByRole('region', { name: '관리 원칙' })).toHaveCount(0);
|
||||||
|
await expect(page.getByText('작업 영역이 분리되었습니다.')).toHaveCount(0);
|
||||||
|
const overviewGrid = page.getByRole('region', { name: '관리 기능' });
|
||||||
|
const desktopOverviewGeometry = await overviewGrid.evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return { left: rect.left, right: rect.right, top: rect.top, width: rect.width };
|
||||||
|
});
|
||||||
|
expect(desktopOverviewGeometry.top).toBeLessThan(300);
|
||||||
|
await writeFile(
|
||||||
|
testInfo.outputPath('admin-overview-desktop-geometry.json'),
|
||||||
|
JSON.stringify(desktopOverviewGeometry)
|
||||||
|
);
|
||||||
const navigation = page.getByRole('navigation', { name: '관리자 메뉴' });
|
const navigation = page.getByRole('navigation', { name: '관리자 메뉴' });
|
||||||
await expect(navigation).toBeVisible();
|
await expect(navigation).toBeVisible();
|
||||||
const userLink = navigation.getByRole('link', { name: '사용자 관리' });
|
const userLink = navigation.getByRole('link', { name: '사용자 관리' });
|
||||||
@@ -148,6 +161,24 @@ test('bootstrap superuser can navigate the administrator workspace from the lobb
|
|||||||
await writeFile(testInfo.outputPath('admin-overview-mobile-geometry.json'), JSON.stringify(geometry));
|
await writeFile(testInfo.outputPath('admin-overview-mobile-geometry.json'), JSON.stringify(geometry));
|
||||||
await page.screenshot({ path: testInfo.outputPath('admin-overview-mobile-menu.png'), fullPage: true });
|
await page.screenshot({ path: testInfo.outputPath('admin-overview-mobile-menu.png'), fullPage: true });
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '관리자 메뉴' }).click();
|
||||||
|
await expect(navigation).toBeHidden();
|
||||||
|
await page.evaluate(() => window.scrollTo(0, 0));
|
||||||
|
await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(0);
|
||||||
|
const mobileOverviewGeometry = await overviewGrid.evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return { left: rect.left, right: rect.right, top: rect.top, width: rect.width };
|
||||||
|
});
|
||||||
|
expect(mobileOverviewGeometry.left).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(mobileOverviewGeometry.right).toBeLessThanOrEqual(390);
|
||||||
|
expect(mobileOverviewGeometry.top).toBeLessThan(340);
|
||||||
|
await writeFile(
|
||||||
|
testInfo.outputPath('admin-overview-mobile-content-geometry.json'),
|
||||||
|
JSON.stringify(mobileOverviewGeometry)
|
||||||
|
);
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('admin-overview-mobile-content.png'), fullPage: true });
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '관리자 메뉴' }).click();
|
||||||
await navigation.getByRole('link', { name: 'Gateway 릴리스' }).click();
|
await navigation.getByRole('link', { name: 'Gateway 릴리스' }).click();
|
||||||
await expect(page).toHaveURL(/\/gateway\/admin\/releases$/);
|
await expect(page).toHaveURL(/\/gateway\/admin\/releases$/);
|
||||||
await expect(page.getByRole('heading', { name: 'Gateway 릴리스', level: 1 })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Gateway 릴리스', level: 1 })).toBeVisible();
|
||||||
|
|||||||
@@ -77,19 +77,7 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AdminConsoleLayout
|
<AdminConsoleLayout title="운영 개요" description="관리 대상을 선택하세요." eyebrow="Admin workspace">
|
||||||
title="운영 개요"
|
|
||||||
description="관리 대상을 먼저 선택하세요. 위험도가 높은 변경과 배포 이력은 각 영역에서 분리해 확인할 수 있습니다."
|
|
||||||
eyebrow="Admin workspace"
|
|
||||||
>
|
|
||||||
<section class="overview-guide" aria-label="관리 원칙">
|
|
||||||
<div>
|
|
||||||
<strong>작업 영역이 분리되었습니다.</strong>
|
|
||||||
<p>계정 변경, 서버 설정, 버전 배포가 한 화면에 섞이지 않도록 책임별로 나누었습니다.</p>
|
|
||||||
</div>
|
|
||||||
<span>권한 검사는 기존 서버 정책을 그대로 따릅니다.</span>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="overview-grid" aria-label="관리 기능">
|
<section class="overview-grid" aria-label="관리 기능">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-for="section in sections.filter((entry) => entry.visible)"
|
v-for="section in sections.filter((entry) => entry.visible)"
|
||||||
@@ -108,37 +96,6 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.overview-guide {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 20px;
|
|
||||||
margin-bottom: 22px;
|
|
||||||
border: 1px solid #3f3f46;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #18181b;
|
|
||||||
padding: 18px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overview-guide strong {
|
|
||||||
color: #f4f4f5;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overview-guide p,
|
|
||||||
.overview-guide span {
|
|
||||||
margin: 4px 0 0;
|
|
||||||
color: #a1a1aa;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overview-guide > span {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
margin: 0;
|
|
||||||
color: #fbbf24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overview-grid {
|
.overview-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -214,11 +171,6 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.overview-guide {
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overview-grid {
|
.overview-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user