Merge remote-tracking branch 'origin/main' into fix/recruitment-fullscreen-mobile-20260813
This commit is contained in:
@@ -465,6 +465,7 @@ test('enters general and nation command arguments and sends exact values', async
|
|||||||
const requests = await install(page);
|
const requests = await install(page);
|
||||||
await page.setViewportSize({ width: 1200, height: 900 });
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
await expect(page.getByTestId('current-city-marker')).toHaveCount(0);
|
||||||
|
|
||||||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||||
await page.getByTestId('command-picker').getByRole('button', { name: /화계/ }).click();
|
await page.getByTestId('command-picker').getByRole('button', { name: /화계/ }).click();
|
||||||
@@ -474,6 +475,12 @@ test('enters general and nation command arguments and sends exact values', async
|
|||||||
await expect(form.getByTestId('command-argument-guidance')).toContainText('선택한 도시에 화계를 실행합니다.');
|
await expect(form.getByTestId('command-argument-guidance')).toContainText('선택한 도시에 화계를 실행합니다.');
|
||||||
await expect(form.getByTestId('command-map-target-summary')).toContainText('현재 도시에서 0칸');
|
await expect(form.getByTestId('command-map-target-summary')).toContainText('현재 도시에서 0칸');
|
||||||
const commandMap = form.getByTestId('command-argument-map');
|
const commandMap = form.getByTestId('command-argument-map');
|
||||||
|
const currentCityMarker = commandMap.getByTestId('current-city-marker');
|
||||||
|
const selectionStatus = form.getByTestId('command-map-selection-status');
|
||||||
|
await expect(currentCityMarker).toHaveText('현재');
|
||||||
|
await expect(currentCityMarker).toHaveAttribute('aria-label', '현재 도시 업');
|
||||||
|
await expect(selectionStatus).toContainText('현재 도시업');
|
||||||
|
await expect(selectionStatus).toContainText('선택 도시업');
|
||||||
const mapCities = commandMap.locator('.city-base');
|
const mapCities = commandMap.locator('.city-base');
|
||||||
await expect(mapCities).toHaveCount(2);
|
await expect(mapCities).toHaveCount(2);
|
||||||
await expect(commandMap.locator('.city-bg')).toHaveCount(2);
|
await expect(commandMap.locator('.city-bg')).toHaveCount(2);
|
||||||
@@ -515,11 +522,17 @@ test('enters general and nation command arguments and sends exact values', async
|
|||||||
await mapCities.nth(1).click();
|
await mapCities.nth(1).click();
|
||||||
await expect(form.locator('select')).toHaveValue('2');
|
await expect(form.locator('select')).toHaveValue('2');
|
||||||
await expect(form.getByTestId('command-map-target-summary')).toContainText('현재 도시에서 1칸');
|
await expect(form.getByTestId('command-map-target-summary')).toContainText('현재 도시에서 1칸');
|
||||||
|
await expect(selectionStatus).toContainText('현재 도시업');
|
||||||
|
await expect(selectionStatus).toContainText('선택 도시허창');
|
||||||
|
await expect(currentCityMarker).toHaveAttribute('aria-label', '현재 도시 업');
|
||||||
|
await expect(mapCities.nth(0)).toHaveClass(/mine/);
|
||||||
|
await expect(mapCities.nth(1)).toHaveClass(/selected/);
|
||||||
await mapCities.nth(1).hover();
|
await mapCities.nth(1).hover();
|
||||||
expect(await mapCities.nth(1).evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer');
|
expect(await mapCities.nth(1).evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer');
|
||||||
await mapCities.nth(1).focus();
|
await mapCities.nth(1).focus();
|
||||||
await expect(mapCities.nth(1)).toBeFocused();
|
await expect(mapCities.nth(1)).toBeFocused();
|
||||||
await expect(page).toHaveURL(/\/$/);
|
await expect(page).toHaveURL(/\/$/);
|
||||||
|
await commandMap.screenshot({ path: test.info().outputPath('main-city-current-marker-desktop.png') });
|
||||||
const mapGeometry = await form.getByTestId('command-argument-map').evaluate((element) => {
|
const mapGeometry = await form.getByTestId('command-argument-map').evaluate((element) => {
|
||||||
const area = element.querySelector<HTMLElement>('.map-area')!;
|
const area = element.querySelector<HTMLElement>('.map-area')!;
|
||||||
const rect = area.getBoundingClientRect();
|
const rect = area.getBoundingClientRect();
|
||||||
@@ -759,6 +772,9 @@ test('uses the map to choose a nation target in the chief command window', async
|
|||||||
await expect(form.getByTestId('command-argument-guidance')).toContainText('초반 제한');
|
await expect(form.getByTestId('command-argument-guidance')).toContainText('초반 제한');
|
||||||
await form.getByTestId('command-argument-map').locator('.city-base').nth(1).click();
|
await form.getByTestId('command-argument-map').locator('.city-base').nth(1).click();
|
||||||
await expect(form.locator('select')).toHaveValue('2');
|
await expect(form.locator('select')).toHaveValue('2');
|
||||||
|
await expect(form.getByTestId('command-map-selection-status')).toContainText('현재 도시업');
|
||||||
|
await expect(form.getByTestId('command-map-selection-status')).toContainText('선택 국가적국');
|
||||||
|
await expect(form.getByTestId('current-city-marker')).toHaveAttribute('aria-label', '현재 도시 업');
|
||||||
await expect(form.getByTestId('command-map-target-summary')).toContainText('수도 허창 · 도시 1개');
|
await expect(form.getByTestId('command-map-target-summary')).toContainText('수도 허창 · 도시 1개');
|
||||||
await expect(page).toHaveURL(/\/che\/chief-center$/);
|
await expect(page).toHaveURL(/\/che\/chief-center$/);
|
||||||
await page.screenshot({ path: test.info().outputPath('chief-nation-map-option.png'), fullPage: true });
|
await page.screenshot({ path: test.info().outputPath('chief-nation-map-option.png'), fullPage: true });
|
||||||
@@ -771,6 +787,16 @@ test('fits the city map option window inside the Ref-compatible 500px mobile pag
|
|||||||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||||
const picker = page.getByTestId('command-picker');
|
const picker = page.getByTestId('command-picker');
|
||||||
await picker.getByRole('button', { name: /화계/ }).click();
|
await picker.getByRole('button', { name: /화계/ }).click();
|
||||||
|
const mobileForm = picker.getByTestId('command-argument-form');
|
||||||
|
const mobileMap = mobileForm.getByTestId('command-argument-map');
|
||||||
|
const mobileCities = mobileMap.locator('.city-base');
|
||||||
|
await mobileCities.nth(1).click();
|
||||||
|
await expect(mobileForm.locator('select')).toHaveValue('2');
|
||||||
|
await expect(mobileForm.getByTestId('command-map-selection-status')).toContainText('현재 도시업');
|
||||||
|
await expect(mobileForm.getByTestId('command-map-selection-status')).toContainText('선택 도시허창');
|
||||||
|
await expect(mobileMap.getByTestId('current-city-marker')).toHaveAttribute('aria-label', '현재 도시 업');
|
||||||
|
await expect(mobileCities.nth(0)).toHaveClass(/mine/);
|
||||||
|
await expect(mobileCities.nth(1)).toHaveClass(/selected/);
|
||||||
const geometry = await picker.evaluate((element) => {
|
const geometry = await picker.evaluate((element) => {
|
||||||
const map = element.querySelector<HTMLElement>('[data-testid="command-argument-map"] .map-area')!;
|
const map = element.querySelector<HTMLElement>('[data-testid="command-argument-map"] .map-area')!;
|
||||||
const pickerRect = element.getBoundingClientRect();
|
const pickerRect = element.getBoundingClientRect();
|
||||||
@@ -782,6 +808,19 @@ test('fits the city map option window inside the Ref-compatible 500px mobile pag
|
|||||||
pickerScrollWidth: element.scrollWidth,
|
pickerScrollWidth: element.scrollWidth,
|
||||||
mapWidth: mapRect.width,
|
mapWidth: mapRect.width,
|
||||||
mapHeight: mapRect.height,
|
mapHeight: mapRect.height,
|
||||||
|
marker: (() => {
|
||||||
|
const marker = element.querySelector<HTMLElement>('[data-testid="current-city-marker"]')!;
|
||||||
|
const rect = marker.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(marker);
|
||||||
|
return {
|
||||||
|
left: rect.left,
|
||||||
|
right: rect.right,
|
||||||
|
top: rect.top,
|
||||||
|
bottom: rect.bottom,
|
||||||
|
pointerEvents: style.pointerEvents,
|
||||||
|
borderColor: style.borderColor,
|
||||||
|
};
|
||||||
|
})(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
expect(geometry.pickerX).toBeGreaterThanOrEqual(0);
|
expect(geometry.pickerX).toBeGreaterThanOrEqual(0);
|
||||||
@@ -790,6 +829,12 @@ test('fits the city map option window inside the Ref-compatible 500px mobile pag
|
|||||||
expect(geometry.pickerScrollWidth).toBeLessThanOrEqual(geometry.pickerWidth);
|
expect(geometry.pickerScrollWidth).toBeLessThanOrEqual(geometry.pickerWidth);
|
||||||
expect(geometry.mapWidth).toBeGreaterThan(470);
|
expect(geometry.mapWidth).toBeGreaterThan(470);
|
||||||
expect(geometry.mapHeight / geometry.mapWidth).toBeCloseTo(5 / 7, 2);
|
expect(geometry.mapHeight / geometry.mapWidth).toBeCloseTo(5 / 7, 2);
|
||||||
|
expect(geometry.marker.left).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(geometry.marker.right).toBeLessThanOrEqual(500);
|
||||||
|
expect(geometry.marker.top).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(geometry.marker.bottom).toBeLessThanOrEqual(900);
|
||||||
|
expect(geometry.marker.pointerEvents).toBe('none');
|
||||||
|
expect(geometry.marker.borderColor).toBe('rgb(130, 207, 255)');
|
||||||
await page.screenshot({ path: test.info().outputPath('main-city-map-option-mobile.png'), fullPage: true });
|
await page.screenshot({ path: test.info().outputPath('main-city-map-option-mobile.png'), fullPage: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type NavigationFixture = {
|
|||||||
currentYear?: number;
|
currentYear?: number;
|
||||||
currentMonth?: number;
|
currentMonth?: number;
|
||||||
scenarioTitle?: string;
|
scenarioTitle?: string;
|
||||||
|
lastExecuted?: string | null;
|
||||||
latestVote?: { id: number; title: string; hasVoted: boolean } | null;
|
latestVote?: { id: number; title: string; hasVoted: boolean } | null;
|
||||||
globalRecords?: Array<{ id: number; text: string }>;
|
globalRecords?: Array<{ id: number; text: string }>;
|
||||||
generalRecords?: Array<{ id: number; text: string }>;
|
generalRecords?: Array<{ id: number; text: string }>;
|
||||||
@@ -423,7 +424,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
|||||||
onlineNations: '위(1)',
|
onlineNations: '위(1)',
|
||||||
onlineGenerals: '메뉴검증장수',
|
onlineGenerals: '메뉴검증장수',
|
||||||
nationNotice: '<p>국가 방침</p>',
|
nationNotice: '<p>국가 방침</p>',
|
||||||
lastExecuted: null,
|
lastExecuted: state.lastExecuted === undefined ? '2026-08-13T00:05:06.000Z' : state.lastExecuted,
|
||||||
latestVote:
|
latestVote:
|
||||||
state.latestVote === undefined
|
state.latestVote === undefined
|
||||||
? { id: 9, title: '메뉴 설문', hasVoted: false }
|
? { id: 9, title: '메뉴 설문', hasVoted: false }
|
||||||
@@ -567,6 +568,10 @@ const persistArtifact = async (page: Page, name: string) => {
|
|||||||
globalPopup: describe('#mobile-global-menu'),
|
globalPopup: describe('#mobile-global-menu'),
|
||||||
nationPopup: describe('#mobile-nation-menu'),
|
nationPopup: describe('#mobile-nation-menu'),
|
||||||
quickPopup: describe('#mobile-quick-menu'),
|
quickPopup: describe('#mobile-quick-menu'),
|
||||||
|
activityStatus: describe('.activity-status'),
|
||||||
|
executionStatus: describe('.execution-status'),
|
||||||
|
tournamentStatus: describe('.tournament-status'),
|
||||||
|
voteStatus: describe('.vote-status'),
|
||||||
commandMenu: describe('.reserved-command-editor details[open] .menu-items'),
|
commandMenu: describe('.reserved-command-editor details[open] .menu-items'),
|
||||||
commandDividers: [...document.querySelectorAll<HTMLElement>('.reserved-command-editor details[open] .menu-divider')].map(
|
commandDividers: [...document.querySelectorAll<HTMLElement>('.reserved-command-editor details[open] .menu-divider')].map(
|
||||||
(element) => {
|
(element) => {
|
||||||
@@ -599,6 +604,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
|
|||||||
stage: 1,
|
stage: 1,
|
||||||
npcMode: 1,
|
npcMode: 1,
|
||||||
scenarioTitle: '메인 화면 검증 시나리오',
|
scenarioTitle: '메인 화면 검증 시나리오',
|
||||||
|
lastExecuted: '2026-08-13T00:05:06.000Z',
|
||||||
generalMeCalls: 0,
|
generalMeCalls: 0,
|
||||||
operations: [],
|
operations: [],
|
||||||
};
|
};
|
||||||
@@ -615,31 +621,35 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
|
|||||||
await expect(page.getByRole('heading', { name: '메인 화면 검증 시나리오', exact: true })).toHaveCount(1);
|
await expect(page.getByRole('heading', { name: '메인 화면 검증 시나리오', exact: true })).toHaveCount(1);
|
||||||
await expect(page.locator('.game-shell__subtitle')).toHaveText('185년 1월 · 턴 10분');
|
await expect(page.locator('.game-shell__subtitle')).toHaveText('185년 1월 · 턴 10분');
|
||||||
await expect(page.locator('.game-shell__subtitle')).not.toContainText('메인 화면 검증 시나리오');
|
await expect(page.locator('.game-shell__subtitle')).not.toContainText('메인 화면 검증 시나리오');
|
||||||
|
await expect(page.locator('.execution-status')).toHaveText('동작 시각: 08-13 09:05');
|
||||||
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 참가 모집중');
|
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 참가 모집중');
|
||||||
await expect(page.locator('.vote-status')).toHaveText('설문: 메뉴 설문');
|
await expect(page.locator('.vote-status')).toHaveText('설문: 메뉴 설문');
|
||||||
const headerStatusGeometry = await page.locator('.main-page').evaluate((element) => {
|
const headerStatusGeometry = await page.locator('.main-page').evaluate((element) => {
|
||||||
const title = element.querySelector<HTMLElement>('.game-shell__title');
|
const title = element.querySelector<HTMLElement>('.game-shell__title');
|
||||||
const subtitle = element.querySelector<HTMLElement>('.game-shell__subtitle');
|
const subtitle = element.querySelector<HTMLElement>('.game-shell__subtitle');
|
||||||
const activity = element.querySelector<HTMLElement>('.activity-status');
|
const activity = element.querySelector<HTMLElement>('.activity-status');
|
||||||
|
const execution = element.querySelector<HTMLElement>('.execution-status');
|
||||||
const tournament = element.querySelector<HTMLElement>('.tournament-status');
|
const tournament = element.querySelector<HTMLElement>('.tournament-status');
|
||||||
const survey = element.querySelector<HTMLElement>('.vote-status');
|
const survey = element.querySelector<HTMLElement>('.vote-status');
|
||||||
if (!title || !subtitle || !activity || !tournament || !survey) {
|
if (!title || !subtitle || !activity || !execution || !tournament || !survey) {
|
||||||
throw new Error('main header status geometry is incomplete');
|
throw new Error('main header status geometry is incomplete');
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
title: title.getBoundingClientRect().toJSON(),
|
title: title.getBoundingClientRect().toJSON(),
|
||||||
subtitle: subtitle.getBoundingClientRect().toJSON(),
|
subtitle: subtitle.getBoundingClientRect().toJSON(),
|
||||||
activity: activity.getBoundingClientRect().toJSON(),
|
activity: activity.getBoundingClientRect().toJSON(),
|
||||||
|
execution: execution.getBoundingClientRect().toJSON(),
|
||||||
tournament: tournament.getBoundingClientRect().toJSON(),
|
tournament: tournament.getBoundingClientRect().toJSON(),
|
||||||
survey: survey.getBoundingClientRect().toJSON(),
|
survey: survey.getBoundingClientRect().toJSON(),
|
||||||
activityColumns: getComputedStyle(activity).gridTemplateColumns,
|
activityColumns: getComputedStyle(activity).gridTemplateColumns,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
expect(headerStatusGeometry.subtitle.y).toBeGreaterThanOrEqual(headerStatusGeometry.title.bottom);
|
expect(headerStatusGeometry.subtitle.y).toBeGreaterThanOrEqual(headerStatusGeometry.title.bottom);
|
||||||
expect(headerStatusGeometry.activity.width).toBeCloseTo(666.67, 0);
|
expect(headerStatusGeometry.activity.width).toBeCloseTo(1000, 0);
|
||||||
|
expect(headerStatusGeometry.execution.width).toBeCloseTo(333.33, 0);
|
||||||
expect(headerStatusGeometry.tournament.width).toBeCloseTo(333.33, 0);
|
expect(headerStatusGeometry.tournament.width).toBeCloseTo(333.33, 0);
|
||||||
expect(headerStatusGeometry.survey.width).toBeCloseTo(333.33, 0);
|
expect(headerStatusGeometry.survey.width).toBeCloseTo(333.33, 0);
|
||||||
expect(headerStatusGeometry.activityColumns.split(' ')).toHaveLength(2);
|
expect(headerStatusGeometry.activityColumns.split(' ')).toHaveLength(3);
|
||||||
const tournamentStatusLink = page.locator('.tournament-status a');
|
const tournamentStatusLink = page.locator('.tournament-status a');
|
||||||
const surveyStatusLink = page.locator('.vote-status a');
|
const surveyStatusLink = page.locator('.vote-status a');
|
||||||
await tournamentStatusLink.hover();
|
await tournamentStatusLink.hover();
|
||||||
@@ -1217,6 +1227,7 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy
|
|||||||
stage: 6,
|
stage: 6,
|
||||||
npcMode: 1,
|
npcMode: 1,
|
||||||
scenarioTitle: '모바일 검증 시나리오',
|
scenarioTitle: '모바일 검증 시나리오',
|
||||||
|
lastExecuted: null,
|
||||||
latestVote: null,
|
latestVote: null,
|
||||||
generalMeCalls: 0,
|
generalMeCalls: 0,
|
||||||
operations: [],
|
operations: [],
|
||||||
@@ -1238,25 +1249,27 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy
|
|||||||
await page.setViewportSize({ width: 500, height: 900 });
|
await page.setViewportSize({ width: 500, height: 900 });
|
||||||
await expect(page.getByRole('heading', { name: '모바일 검증 시나리오', exact: true })).toHaveCount(1);
|
await expect(page.getByRole('heading', { name: '모바일 검증 시나리오', exact: true })).toHaveCount(1);
|
||||||
await expect(page.locator('.game-shell__subtitle')).toHaveText('185년 1월 · 턴 10분');
|
await expect(page.locator('.game-shell__subtitle')).toHaveText('185년 1월 · 턴 10분');
|
||||||
|
await expect(page.locator('.execution-status')).toHaveText('동작 시각: 기록 없음');
|
||||||
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 베팅 진행중');
|
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 베팅 진행중');
|
||||||
await expect(page.locator('.vote-status')).toHaveText('설문: 진행 중인 설문 없음');
|
await expect(page.locator('.vote-status')).toHaveText('설문: 진행 중인 설문 없음');
|
||||||
const activityGeometry = await page.locator('.activity-status').evaluate((element) => {
|
const activityGeometry = await page.locator('.activity-status').evaluate((element) => {
|
||||||
|
const execution = element.querySelector<HTMLElement>('.execution-status');
|
||||||
const tournament = element.querySelector<HTMLElement>('.tournament-status');
|
const tournament = element.querySelector<HTMLElement>('.tournament-status');
|
||||||
const survey = element.querySelector<HTMLElement>('.vote-status');
|
const survey = element.querySelector<HTMLElement>('.vote-status');
|
||||||
if (!tournament || !survey) throw new Error('activity status is incomplete');
|
if (!execution || !tournament || !survey) throw new Error('activity status is incomplete');
|
||||||
return {
|
return {
|
||||||
width: element.getBoundingClientRect().width,
|
width: element.getBoundingClientRect().width,
|
||||||
|
executionWidth: execution.getBoundingClientRect().width,
|
||||||
tournamentWidth: tournament.getBoundingClientRect().width,
|
tournamentWidth: tournament.getBoundingClientRect().width,
|
||||||
surveyWidth: survey.getBoundingClientRect().width,
|
surveyWidth: survey.getBoundingClientRect().width,
|
||||||
columns: getComputedStyle(element).gridTemplateColumns,
|
columns: getComputedStyle(element).gridTemplateColumns,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
expect(activityGeometry).toMatchObject({
|
expect(activityGeometry.width).toBe(500);
|
||||||
width: 500,
|
expect(activityGeometry.executionWidth).toBeCloseTo(166.67, 0);
|
||||||
tournamentWidth: 250,
|
expect(activityGeometry.tournamentWidth).toBeCloseTo(166.67, 0);
|
||||||
surveyWidth: 250,
|
expect(activityGeometry.surveyWidth).toBeCloseTo(166.67, 0);
|
||||||
columns: '250px 250px',
|
expect(activityGeometry.columns.split(' ')).toHaveLength(3);
|
||||||
});
|
|
||||||
await expect
|
await expect
|
||||||
.poll(() =>
|
.poll(() =>
|
||||||
page
|
page
|
||||||
|
|||||||
@@ -109,6 +109,26 @@ const mapSelectedCityId = computed<number | null>(() => {
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const currentCityName = computed(() => {
|
||||||
|
const cityId = props.mapData?.myCity;
|
||||||
|
if (!cityId) return '-';
|
||||||
|
return props.mapLayout?.cityList.find((city) => city.id === cityId)?.name ?? '-';
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedMapTargetName = computed(() => {
|
||||||
|
if (presentation.value.mapTarget === 'city') {
|
||||||
|
const cityId = mapSelectedCityId.value;
|
||||||
|
if (!cityId) return '-';
|
||||||
|
return props.mapLayout?.cityList.find((city) => city.id === cityId)?.name ?? '-';
|
||||||
|
}
|
||||||
|
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
|
||||||
|
const nationId = values[nationTargetField.value.key];
|
||||||
|
if (typeof nationId !== 'number') return '-';
|
||||||
|
return props.mapData?.nationList.find((nation) => nation[0] === nationId)?.[1] ?? '-';
|
||||||
|
}
|
||||||
|
return '-';
|
||||||
|
});
|
||||||
|
|
||||||
const distanceFromMyCity = (destination: number): number | null => {
|
const distanceFromMyCity = (destination: number): number | null => {
|
||||||
const start = props.mapData?.myCity;
|
const start = props.mapData?.myCity;
|
||||||
if (!start || !props.mapLayout) return null;
|
if (!start || !props.mapLayout) return null;
|
||||||
@@ -260,9 +280,23 @@ watch(
|
|||||||
:selected-city-id="mapSelectedCityId"
|
:selected-city-id="mapSelectedCityId"
|
||||||
:detail-mode="true"
|
:detail-mode="true"
|
||||||
:fit-container="true"
|
:fit-container="true"
|
||||||
|
:show-current-city-marker="true"
|
||||||
@select-city="selectMapCity"
|
@select-city="selectMapCity"
|
||||||
/>
|
/>
|
||||||
<small>지도에서 도시를 클릭하거나 아래 목록에서 대상을 선택하세요.</small>
|
<small>지도에서 도시를 클릭하거나 아래 목록에서 대상을 선택하세요.</small>
|
||||||
|
<div class="map-selection-status" aria-live="polite" data-testid="command-map-selection-status">
|
||||||
|
<span class="current-city-status">
|
||||||
|
<span class="status-key">현재 도시</span>
|
||||||
|
<strong>{{ currentCityName }}</strong>
|
||||||
|
</span>
|
||||||
|
<span aria-hidden="true">→</span>
|
||||||
|
<span class="selected-target-status">
|
||||||
|
<span class="status-key">{{
|
||||||
|
presentation.mapTarget === 'nation' ? '선택 국가' : '선택 도시'
|
||||||
|
}}</span>
|
||||||
|
<strong>{{ selectedMapTargetName }}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div v-if="mapTargetSummary" class="map-target-summary" data-testid="command-map-target-summary">
|
<div v-if="mapTargetSummary" class="map-target-summary" data-testid="command-map-target-summary">
|
||||||
{{ mapTargetSummary }}
|
{{ mapTargetSummary }}
|
||||||
</div>
|
</div>
|
||||||
@@ -376,6 +410,41 @@ watch(
|
|||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.map-selection-status {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px 7px;
|
||||||
|
padding: 0 8px 5px;
|
||||||
|
color: rgba(232, 221, 196, 0.82);
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-selection-status > span:not([aria-hidden='true']) {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-key {
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 0 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-city-status .status-key {
|
||||||
|
border: 1px solid #82cfff;
|
||||||
|
background: rgba(5, 27, 43, 0.92);
|
||||||
|
color: #d9f3ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-target-status .status-key {
|
||||||
|
border: 1px solid rgba(255, 235, 150, 0.9);
|
||||||
|
background: rgba(201, 164, 90, 0.18);
|
||||||
|
color: #ffe996;
|
||||||
|
}
|
||||||
|
|
||||||
.command-guidance {
|
.command-guidance {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { formatServerDateTime } from '@sammo-ts/common';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { resolveTournamentStageName } from '../../utils/tournamentStatus';
|
import { resolveTournamentStageName } from '../../utils/tournamentStatus';
|
||||||
|
|
||||||
@@ -19,11 +20,17 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const tournamentStatus = computed(() => resolveTournamentStageName(props.tournamentStage));
|
const tournamentStatus = computed(() => resolveTournamentStageName(props.tournamentStage));
|
||||||
|
const lastExecutedStatus = computed(() =>
|
||||||
|
formatServerDateTime(props.status?.lastExecuted, { format: 'monthDayTime', fallback: '기록 없음' })
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="front-status" aria-label="접속 현황과 국가 방침">
|
<section class="front-status" aria-label="접속 현황과 국가 방침">
|
||||||
<div class="activity-status" aria-label="설문과 토너먼트 진행 현황">
|
<div class="activity-status" aria-label="동작 시각, 토너먼트와 설문 진행 현황">
|
||||||
|
<div class="status-row execution-status" :class="{ 'execution-status--empty': !status?.lastExecuted }">
|
||||||
|
동작 시각: {{ lastExecutedStatus }}
|
||||||
|
</div>
|
||||||
<div class="status-row tournament-status">
|
<div class="status-row tournament-status">
|
||||||
<RouterLink to="/tournament">
|
<RouterLink to="/tournament">
|
||||||
<span class="tournament-label">토너먼트: </span>{{ tournamentStatus }}
|
<span class="tournament-label">토너먼트: </span>{{ tournamentStatus }}
|
||||||
@@ -86,9 +93,8 @@ const tournamentStatus = computed(() => resolveTournamentStageName(props.tournam
|
|||||||
|
|
||||||
.activity-status {
|
.activity-status {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
width: 66.666667%;
|
width: 100%;
|
||||||
margin-left: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.activity-status .status-row {
|
.activity-status .status-row {
|
||||||
@@ -106,6 +112,14 @@ const tournamentStatus = computed(() => resolveTournamentStageName(props.tournam
|
|||||||
color: #ffc107;
|
color: #ffc107;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.execution-status {
|
||||||
|
color: cyan;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-status--empty {
|
||||||
|
color: magenta;
|
||||||
|
}
|
||||||
|
|
||||||
.vote-label {
|
.vote-label {
|
||||||
color: cyan;
|
color: cyan;
|
||||||
}
|
}
|
||||||
@@ -113,10 +127,4 @@ const tournamentStatus = computed(() => resolveTournamentStageName(props.tournam
|
|||||||
.vote-empty {
|
.vote-empty {
|
||||||
color: magenta;
|
color: magenta;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 991px) {
|
|
||||||
.activity-status {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ const props = withDefaults(
|
|||||||
selectedCityId?: number | null;
|
selectedCityId?: number | null;
|
||||||
detailMode?: boolean;
|
detailMode?: boolean;
|
||||||
fitContainer?: boolean;
|
fitContainer?: boolean;
|
||||||
|
showCurrentCityMarker?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
// Vue casts an absent Boolean prop to false unless undefined is an explicit default.
|
// Vue casts an absent Boolean prop to false unless undefined is an explicit default.
|
||||||
@@ -214,6 +215,20 @@ const cityViews = computed<CityView[]>(() => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const currentCityMarker = computed(() =>
|
||||||
|
props.showCurrentCityMarker ? (cityViews.value.find((city) => city.isMyCity) ?? null) : null
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentCityMarkerStyle = computed(() => {
|
||||||
|
const city = currentCityMarker.value;
|
||||||
|
if (!city) return undefined;
|
||||||
|
const verticalOffset = (effectiveDetailMode.value ? 22 : 14) * mapScale.value;
|
||||||
|
return {
|
||||||
|
left: `${city.x}px`,
|
||||||
|
top: `${city.y - verticalOffset}px`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const mapSeason = computed(() => {
|
const mapSeason = computed(() => {
|
||||||
if (!props.mapData) {
|
if (!props.mapData) {
|
||||||
return 'spring';
|
return 'spring';
|
||||||
@@ -433,6 +448,16 @@ const selectCity = (cityId: number) => {
|
|||||||
@leave="setHoveredCity(null)"
|
@leave="setHoveredCity(null)"
|
||||||
@select="selectCity"
|
@select="selectCity"
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
v-if="currentCityMarker"
|
||||||
|
class="current-city-marker"
|
||||||
|
:style="currentCityMarkerStyle"
|
||||||
|
data-testid="current-city-marker"
|
||||||
|
role="note"
|
||||||
|
:aria-label="`현재 도시 ${currentCityMarker.name}`"
|
||||||
|
>
|
||||||
|
현재
|
||||||
|
</div>
|
||||||
<div v-if="hoveredCity" class="map-tooltip" :style="tooltipPosition">
|
<div v-if="hoveredCity" class="map-tooltip" :style="tooltipPosition">
|
||||||
<div class="tooltip-title">{{ hoveredCityTitle }}</div>
|
<div class="tooltip-title">{{ hoveredCityTitle }}</div>
|
||||||
<div class="tooltip-body">{{ hoveredCity.nationId > 0 ? hoveredCity.nationName : '' }}</div>
|
<div class="tooltip-body">{{ hoveredCity.nationId > 0 ? hoveredCity.nationName : '' }}</div>
|
||||||
@@ -583,6 +608,38 @@ const selectCity = (cityId: number) => {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.current-city-marker {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 12;
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 30px;
|
||||||
|
border: 1px solid #82cfff;
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
background: rgba(5, 27, 43, 0.92);
|
||||||
|
color: #d9f3ff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 14px;
|
||||||
|
pointer-events: none;
|
||||||
|
text-align: center;
|
||||||
|
text-shadow: 0 1px #000;
|
||||||
|
transform: translate(-50%, -100%);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-city-marker::after {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 50%;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border: 4px solid transparent;
|
||||||
|
border-top-color: #82cfff;
|
||||||
|
content: '';
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
.tooltip-title {
|
.tooltip-title {
|
||||||
height: 15px;
|
height: 15px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -293,14 +293,16 @@ test('renders actual online, nation policy, and survey data with ref geometry an
|
|||||||
await expect(status).toContainText(marker);
|
await expect(status).toContainText(marker);
|
||||||
await expect(page.locator('.online-users')).toContainText('현황검증장수');
|
await expect(page.locator('.online-users')).toContainText('현황검증장수');
|
||||||
await expect(page.locator('.survey-notice')).toContainText('새로운 설문조사가 있습니다.');
|
await expect(page.locator('.survey-notice')).toContainText('새로운 설문조사가 있습니다.');
|
||||||
|
await expect(page.locator('.execution-status')).toContainText('동작 시각:');
|
||||||
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 경기 없음');
|
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 경기 없음');
|
||||||
await expect(page.locator('.vote-status')).toHaveText('설문: 검증 설문');
|
await expect(page.locator('.vote-status')).toHaveText('설문: 검증 설문');
|
||||||
const desktop = await status.evaluate((element) => {
|
const desktop = await status.evaluate((element) => {
|
||||||
const style = getComputedStyle(element);
|
const style = getComputedStyle(element);
|
||||||
const onlineRow = element.querySelector<HTMLElement>('.online-nations');
|
const onlineRow = element.querySelector<HTMLElement>('.online-nations');
|
||||||
|
const executionRow = element.querySelector<HTMLElement>('.execution-status');
|
||||||
const voteRow = element.querySelector<HTMLElement>('.vote-status');
|
const voteRow = element.querySelector<HTMLElement>('.vote-status');
|
||||||
const tournamentRow = element.querySelector<HTMLElement>('.tournament-status');
|
const tournamentRow = element.querySelector<HTMLElement>('.tournament-status');
|
||||||
if (!onlineRow || !voteRow || !tournamentRow) throw new Error('status row missing');
|
if (!onlineRow || !executionRow || !voteRow || !tournamentRow) throw new Error('status row missing');
|
||||||
return {
|
return {
|
||||||
rect: element.getBoundingClientRect().toJSON(),
|
rect: element.getBoundingClientRect().toJSON(),
|
||||||
fontSize: style.fontSize,
|
fontSize: style.fontSize,
|
||||||
@@ -311,6 +313,7 @@ test('renders actual online, nation policy, and survey data with ref geometry an
|
|||||||
borderTop: getComputedStyle(onlineRow).borderTop,
|
borderTop: getComputedStyle(onlineRow).borderTop,
|
||||||
padding: getComputedStyle(onlineRow).padding,
|
padding: getComputedStyle(onlineRow).padding,
|
||||||
},
|
},
|
||||||
|
executionRow: executionRow.getBoundingClientRect().toJSON(),
|
||||||
tournamentRow: tournamentRow.getBoundingClientRect().toJSON(),
|
tournamentRow: tournamentRow.getBoundingClientRect().toJSON(),
|
||||||
voteRow: voteRow.getBoundingClientRect().toJSON(),
|
voteRow: voteRow.getBoundingClientRect().toJSON(),
|
||||||
};
|
};
|
||||||
@@ -325,6 +328,9 @@ test('renders actual online, nation policy, and survey data with ref geometry an
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(desktop.onlineRow.rect.height).toBeCloseTo(36, 0);
|
expect(desktop.onlineRow.rect.height).toBeCloseTo(36, 0);
|
||||||
|
expect(desktop.executionRow.x).toBeCloseTo(0, 0);
|
||||||
|
expect(desktop.executionRow.width).toBeCloseTo(333.33, 0);
|
||||||
|
expect(desktop.executionRow.height).toBeCloseTo(36, 0);
|
||||||
expect(desktop.tournamentRow.x).toBeCloseTo(333.33, 0);
|
expect(desktop.tournamentRow.x).toBeCloseTo(333.33, 0);
|
||||||
expect(desktop.tournamentRow.width).toBeCloseTo(333.33, 0);
|
expect(desktop.tournamentRow.width).toBeCloseTo(333.33, 0);
|
||||||
expect(desktop.tournamentRow.height).toBeCloseTo(36, 0);
|
expect(desktop.tournamentRow.height).toBeCloseTo(36, 0);
|
||||||
@@ -335,8 +341,12 @@ test('renders actual online, nation policy, and survey data with ref geometry an
|
|||||||
|
|
||||||
await page.setViewportSize({ width: 500, height: 900 });
|
await page.setViewportSize({ width: 500, height: 900 });
|
||||||
await expect(status).toHaveCSS('width', '500px');
|
await expect(status).toHaveCSS('width', '500px');
|
||||||
await expect(page.locator('.tournament-status')).toHaveCSS('width', '250px');
|
const mobileActivity = await page.locator('.activity-status').evaluate((element) => ({
|
||||||
await expect(page.locator('.vote-status')).toHaveCSS('width', '250px');
|
columns: getComputedStyle(element).gridTemplateColumns,
|
||||||
|
widths: [...element.children].map((child) => child.getBoundingClientRect().width),
|
||||||
|
}));
|
||||||
|
expect(mobileActivity.columns.split(' ')).toHaveLength(3);
|
||||||
|
for (const width of mobileActivity.widths) expect(width).toBeCloseTo(166.67, 0);
|
||||||
|
|
||||||
failStatus = true;
|
failStatus = true;
|
||||||
await page.getByRole('button', { name: '새로고침', exact: true }).click();
|
await page.getByRole('button', { name: '새로고침', exact: true }).click();
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ try {
|
|||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
container: inspect('#container'),
|
container: inspect('#container'),
|
||||||
|
gameInfo: inspect('.gameInfo'),
|
||||||
|
tournamentStatus: inspect('.subTournamentState'),
|
||||||
|
lastExecuted: inspect('.subLastExecuted'),
|
||||||
onlineNations: inspect('.onlineNations'),
|
onlineNations: inspect('.onlineNations'),
|
||||||
onlineUsers: inspect('.onlineUsers'),
|
onlineUsers: inspect('.onlineUsers'),
|
||||||
nationNotice: inspect('.nationNotice'),
|
nationNotice: inspect('.nationNotice'),
|
||||||
|
|||||||
Reference in New Issue
Block a user