merge: 최신 main을 사령턴 대상 지도 브랜치에 통합
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -126,6 +126,19 @@ test('bootstrap superuser can navigate the administrator workspace from the lobb
|
||||
|
||||
await expect(page).toHaveURL(/\/gateway\/admin$/);
|
||||
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: '관리자 메뉴' });
|
||||
await expect(navigation).toBeVisible();
|
||||
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 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 expect(page).toHaveURL(/\/gateway\/admin\/releases$/);
|
||||
await expect(page.getByRole('heading', { name: 'Gateway 릴리스', level: 1 })).toBeVisible();
|
||||
|
||||
@@ -77,19 +77,7 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminConsoleLayout
|
||||
title="운영 개요"
|
||||
description="관리 대상을 먼저 선택하세요. 위험도가 높은 변경과 배포 이력은 각 영역에서 분리해 확인할 수 있습니다."
|
||||
eyebrow="Admin workspace"
|
||||
>
|
||||
<section class="overview-guide" aria-label="관리 원칙">
|
||||
<div>
|
||||
<strong>작업 영역이 분리되었습니다.</strong>
|
||||
<p>계정 변경, 서버 설정, 버전 배포가 한 화면에 섞이지 않도록 책임별로 나누었습니다.</p>
|
||||
</div>
|
||||
<span>권한 검사는 기존 서버 정책을 그대로 따릅니다.</span>
|
||||
</section>
|
||||
|
||||
<AdminConsoleLayout title="운영 개요" description="관리 대상을 선택하세요." eyebrow="Admin workspace">
|
||||
<section class="overview-grid" aria-label="관리 기능">
|
||||
<RouterLink
|
||||
v-for="section in sections.filter((entry) => entry.visible)"
|
||||
@@ -108,37 +96,6 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -214,11 +171,6 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.overview-guide {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.overview-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user