merge: 최신 main을 내 정보 진입 개선에 재통합
This commit is contained in:
@@ -6,10 +6,7 @@ import {
|
||||
} from '@sammo-ts/logic';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildTurnCommandInputFields,
|
||||
parseReservedTurnArgs,
|
||||
} from '../src/turns/commandInput.js';
|
||||
import { buildTurnCommandInputFields, parseReservedTurnArgs } from '../src/turns/commandInput.js';
|
||||
|
||||
describe('turn command argument input', () => {
|
||||
it('builds supported fields for every argument-bearing command module', async () => {
|
||||
@@ -22,6 +19,9 @@ describe('turn command argument input', () => {
|
||||
key: spec.key,
|
||||
fields: buildTurnCommandInputFields(spec),
|
||||
}));
|
||||
const nationFields = nation
|
||||
.filter((spec) => spec.reqArg)
|
||||
.map((spec) => ({ key: spec.key, fields: buildTurnCommandInputFields(spec) }));
|
||||
|
||||
expect(argumentSpecs).toHaveLength(44);
|
||||
expect(fields.every((entry) => entry.fields.length > 0)).toBe(true);
|
||||
@@ -32,6 +32,34 @@ describe('turn command argument input', () => {
|
||||
{ key: 'destNationId', kind: 'select', optionSource: 'nations' },
|
||||
{ key: 'amountList', kind: 'numberTuple' },
|
||||
]);
|
||||
|
||||
expect(
|
||||
nationFields
|
||||
.filter((entry) => entry.key !== 'che_발령')
|
||||
.flatMap((entry) =>
|
||||
entry.fields
|
||||
.filter((field) => field.optionSource === 'cities' || field.optionSource === 'nations')
|
||||
.map((field) => `${entry.key}:${field.optionSource}`)
|
||||
)
|
||||
.sort()
|
||||
).toEqual(
|
||||
[
|
||||
'che_급습:nations',
|
||||
'che_물자원조:nations',
|
||||
'che_백성동원:cities',
|
||||
'che_불가침제의:nations',
|
||||
'che_불가침파기제의:nations',
|
||||
'che_선전포고:nations',
|
||||
'che_수몰:cities',
|
||||
'che_이호경식:nations',
|
||||
'che_종전제의:nations',
|
||||
'che_천도:cities',
|
||||
'che_초토화:cities',
|
||||
'che_피장파장:nations',
|
||||
'che_허보:cities',
|
||||
'cr_인구이동:cities',
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes valid arguments and rejects malformed or wrong-scope commands', async () => {
|
||||
@@ -50,8 +78,6 @@ describe('turn command argument input', () => {
|
||||
amount: 200,
|
||||
destGeneralId: 7,
|
||||
});
|
||||
await expect(parseReservedTurnArgs('general', 'che_포상', {})).rejects.toThrow(
|
||||
'Unknown general turn command'
|
||||
);
|
||||
await expect(parseReservedTurnArgs('general', 'che_포상', {})).rejects.toThrow('Unknown general turn command');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -522,6 +522,21 @@ const commandTable = {
|
||||
buildCityCommand('che_백성동원', '백성동원'),
|
||||
buildCityCommand('che_수몰', '수몰'),
|
||||
buildCityCommand('che_허보', '허보'),
|
||||
buildNationCommand('che_이호경식', '이호경식'),
|
||||
buildNationCommand('che_급습', '급습'),
|
||||
{
|
||||
...buildNationCommand('che_피장파장', '피장파장'),
|
||||
inputFields: [
|
||||
...buildNationCommand('che_피장파장', '피장파장').inputFields,
|
||||
{
|
||||
key: 'commandType',
|
||||
label: '대응 명령',
|
||||
kind: 'select',
|
||||
required: true,
|
||||
options: [{ value: 'che_수몰', label: '수몰' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -815,7 +830,7 @@ const install = async (page: Page, rejectGeneral = false, commandTableResponse:
|
||||
{ id: 2, name: '허창', level: 7, region: 2, x: 240, y: 180, path: [1] },
|
||||
],
|
||||
regionMap: { 1: '하북', 2: '예주' },
|
||||
levelMap: { 8: '특' },
|
||||
levelMap: { 8: '특', 7: '대' },
|
||||
});
|
||||
if (name === 'auth.status') return response({ ok: true });
|
||||
if (name === 'lobby.info')
|
||||
@@ -1778,18 +1793,25 @@ test('uses the map to choose a nation target in the chief command window', async
|
||||
await page.screenshot({ path: test.info().outputPath('chief-nation-map-option.png'), fullPage: true });
|
||||
});
|
||||
|
||||
test('shows city or capital maps for every requested chief command', async ({ page }) => {
|
||||
test('shows a map and target details for every city or nation argument chief command except assignment', async ({
|
||||
page,
|
||||
}) => {
|
||||
await install(page);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
const cases = [
|
||||
{ category: '인사', action: '발령', mode: 'city' },
|
||||
{ category: '특수', action: '초토화', mode: 'city' },
|
||||
{ category: '특수', action: '천도', mode: 'city' },
|
||||
{ category: '특수', action: '증축', mode: 'capital' },
|
||||
{ category: '특수', action: '감축', mode: 'capital' },
|
||||
{ category: '전략', action: '수몰', mode: 'city' },
|
||||
{ category: '전략', action: '허보', mode: 'city' },
|
||||
{ category: '전략', action: '백성동원', mode: 'city' },
|
||||
{ category: '외교', action: '원조', mode: 'nation' },
|
||||
{ category: '외교', action: '불가침 제의', mode: 'nation' },
|
||||
{ category: '외교', action: '선전포고', mode: 'nation' },
|
||||
{ category: '외교', action: '종전 제의', mode: 'nation' },
|
||||
{ category: '외교', action: '불가침 파기 제의', mode: 'nation' },
|
||||
{ category: '전략', action: '이호경식', mode: 'nation' },
|
||||
{ category: '전략', action: '급습', mode: 'nation' },
|
||||
{ category: '전략', action: '피장파장', mode: 'nation' },
|
||||
];
|
||||
|
||||
for (const entry of cases) {
|
||||
@@ -1803,24 +1825,51 @@ test('shows city or capital maps for every requested chief command', async ({ pa
|
||||
await expect(map, `${entry.action} 지도`).toBeVisible();
|
||||
await expect(form.getByTestId('command-argument-guidance')).toBeVisible();
|
||||
|
||||
if (entry.mode === 'capital') {
|
||||
await expect(form.getByTestId('command-map-selection-status')).toContainText('현재 수도업');
|
||||
await expect(form.getByTestId('command-map-target-summary')).toContainText('현재 수도');
|
||||
await expect(map.locator('.city-base').first()).toHaveJSProperty('tagName', 'DIV');
|
||||
expect(
|
||||
await map
|
||||
.locator('.city-base')
|
||||
.first()
|
||||
.evaluate((node) => getComputedStyle(node).cursor)
|
||||
).toBe('default');
|
||||
} else {
|
||||
await map.locator('.city-base').nth(1).click();
|
||||
await map.locator('.city-base').nth(1).click();
|
||||
if (entry.mode === 'city') {
|
||||
await expect(form.locator('#command-arg-destCityId')).toHaveValue('2');
|
||||
await expect(form.getByTestId('command-map-selection-status')).toContainText('선택 도시허창');
|
||||
await expect(form.getByTestId('command-map-target-summary')).toContainText(
|
||||
'허창 · 적국 · 예주 · 대 · 현재 도시에서 1칸'
|
||||
);
|
||||
} else {
|
||||
await expect(form.locator('#command-arg-destNationId')).toHaveValue('2');
|
||||
await expect(form.getByTestId('command-map-selection-status')).toContainText('선택 국가적국');
|
||||
await expect(form.getByTestId('command-map-target-summary')).toContainText('적국 · 수도 허창 · 도시 1개');
|
||||
}
|
||||
await expect(form.getByTestId('current-city-marker')).toHaveAttribute('aria-label', '현재 도시 업');
|
||||
}
|
||||
|
||||
await page.screenshot({ path: test.info().outputPath('chief-command-map-guidance.png'), fullPage: true });
|
||||
await page.screenshot({ path: test.info().outputPath('chief-command-target-map-details.png'), fullPage: true });
|
||||
|
||||
await page.setViewportSize({ width: 500, height: 900 });
|
||||
await page.goto('/che/chief-center');
|
||||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||
const picker = page.getByTestId('command-picker');
|
||||
await picker.getByRole('button', { name: /^(?:국가:)?전략$/, exact: true }).click();
|
||||
await picker.getByRole('button', { name: /피장파장/ }).click();
|
||||
const form = picker.getByTestId('command-argument-form');
|
||||
const map = form.getByTestId('command-argument-map');
|
||||
const targetCity = map.locator('.city-base').nth(1);
|
||||
await targetCity.hover();
|
||||
expect(await targetCity.evaluate((node) => getComputedStyle(node).cursor)).toBe('pointer');
|
||||
await targetCity.focus();
|
||||
await expect(targetCity).toBeFocused();
|
||||
await targetCity.click();
|
||||
await expect(form.locator('#command-arg-destNationId')).toHaveValue('2');
|
||||
const geometry = await picker.evaluate((element) => ({
|
||||
pickerWidth: element.getBoundingClientRect().width,
|
||||
pickerOverflow: element.scrollWidth - element.clientWidth,
|
||||
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
mapInsidePicker:
|
||||
element.querySelector<HTMLElement>('[data-testid="command-argument-map"]')!.getBoundingClientRect().right <=
|
||||
element.getBoundingClientRect().right,
|
||||
}));
|
||||
expect(geometry).toEqual({ pickerWidth: 500, pickerOverflow: 0, documentOverflow: 0, mapInsidePicker: true });
|
||||
await page.screenshot({
|
||||
path: test.info().outputPath('chief-command-target-map-details-mobile.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('prioritizes own cities for assignment while retaining other map targets', async ({ page }) => {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { CommandInputField } from './types';
|
||||
|
||||
export type CommandArgumentMapTarget = 'city' | 'nation' | 'capital';
|
||||
|
||||
export type CommandArgumentPresentation = {
|
||||
lines: string[];
|
||||
mapTarget?: 'city' | 'nation' | 'capital';
|
||||
mapTarget?: CommandArgumentMapTarget;
|
||||
};
|
||||
|
||||
const cityTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'city' });
|
||||
@@ -98,4 +102,18 @@ const PRESENTATIONS: Record<string, CommandArgumentPresentation> = {
|
||||
export const commandArgumentPresentation = (commandKey: string): CommandArgumentPresentation =>
|
||||
PRESENTATIONS[commandKey] ?? { lines: [] };
|
||||
|
||||
/**
|
||||
* 대상 지도는 명령명 목록이 아니라 API가 내린 실제 인자 계약을 우선한다.
|
||||
* 인자가 없는 증축·감축의 수도 확인 지도만 presentation의 명시적 target을 사용한다.
|
||||
*/
|
||||
export const resolveCommandArgumentMapTarget = (
|
||||
commandKey: string,
|
||||
fields: readonly CommandInputField[]
|
||||
): CommandArgumentMapTarget | undefined => {
|
||||
const selectableTargets = fields.filter((field) => field.kind === 'select');
|
||||
if (selectableTargets.some((field) => field.optionSource === 'cities')) return 'city';
|
||||
if (selectableTargets.some((field) => field.optionSource === 'nations')) return 'nation';
|
||||
return commandArgumentPresentation(commandKey).mapTarget;
|
||||
};
|
||||
|
||||
export const presentedCommandKeys = (): string[] => Object.keys(PRESENTATIONS);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch, type CSSProperties } from 'vue';
|
||||
import MapViewer from './MapViewer.vue';
|
||||
import { commandArgumentPresentation } from '../command/commandArgumentPresentation';
|
||||
import { commandArgumentPresentation, resolveCommandArgumentMapTarget } from '../command/commandArgumentPresentation';
|
||||
import { commandCityOptions } from '../command/commandArgumentOptions';
|
||||
import {
|
||||
commandArgumentFieldContract,
|
||||
@@ -161,20 +161,15 @@ const nationTargetField = computed(() =>
|
||||
(field) => field.kind === 'select' && field.optionSource === 'nations' && field.key === 'destNationId'
|
||||
)
|
||||
);
|
||||
const showMap = computed(
|
||||
() =>
|
||||
Boolean(props.mapData && props.mapLayout) &&
|
||||
((presentation.value.mapTarget === 'city' && cityTargetField.value) ||
|
||||
(presentation.value.mapTarget === 'nation' && nationTargetField.value) ||
|
||||
presentation.value.mapTarget === 'capital')
|
||||
);
|
||||
const mapTarget = computed(() => resolveCommandArgumentMapTarget(props.commandKey, props.fields));
|
||||
const showMap = computed(() => Boolean(props.mapData && props.mapLayout && mapTarget.value));
|
||||
const mapSelectedCityId = computed<number | null>(() => {
|
||||
if (!props.mapData) return null;
|
||||
if (presentation.value.mapTarget === 'city' && cityTargetField.value) {
|
||||
if (mapTarget.value === 'city' && cityTargetField.value) {
|
||||
const value = values[cityTargetField.value.key];
|
||||
return typeof value === 'number' ? value : null;
|
||||
}
|
||||
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
|
||||
if (mapTarget.value === 'nation' && nationTargetField.value) {
|
||||
const value = values[nationTargetField.value.key];
|
||||
if (typeof value !== 'number') return null;
|
||||
return (
|
||||
@@ -183,7 +178,7 @@ const mapSelectedCityId = computed<number | null>(() => {
|
||||
null
|
||||
);
|
||||
}
|
||||
if (presentation.value.mapTarget === 'capital') {
|
||||
if (mapTarget.value === 'capital') {
|
||||
const myNation = props.mapData.myNation;
|
||||
return props.mapData.nationList.find((entry) => entry[0] === myNation)?.[3] ?? null;
|
||||
}
|
||||
@@ -197,17 +192,17 @@ const currentCityName = computed(() => {
|
||||
});
|
||||
|
||||
const selectedMapTargetName = computed(() => {
|
||||
if (presentation.value.mapTarget === 'city') {
|
||||
if (mapTarget.value === '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) {
|
||||
if (mapTarget.value === 'nation' && nationTargetField.value) {
|
||||
const nationId = values[nationTargetField.value.key];
|
||||
if (typeof nationId !== 'number') return '-';
|
||||
return props.mapData?.nationList.find((nation) => nation[0] === nationId)?.[1] ?? '-';
|
||||
}
|
||||
if (presentation.value.mapTarget === 'capital') {
|
||||
if (mapTarget.value === 'capital') {
|
||||
const cityId = mapSelectedCityId.value;
|
||||
if (!cityId) return '-';
|
||||
return props.mapLayout?.cityList.find((city) => city.id === cityId)?.name ?? '-';
|
||||
@@ -239,7 +234,7 @@ const distanceFromMyCity = (destination: number): number | null => {
|
||||
|
||||
const mapTargetSummary = computed(() => {
|
||||
if (!props.mapData || !props.mapLayout) return '';
|
||||
if (presentation.value.mapTarget === 'city' && mapSelectedCityId.value) {
|
||||
if (mapTarget.value === 'city' && mapSelectedCityId.value) {
|
||||
const city = props.mapLayout.cityList.find((entry) => entry.id === mapSelectedCityId.value);
|
||||
const dynamic = props.mapData.cityList.find((entry) => entry[0] === mapSelectedCityId.value);
|
||||
if (!city) return '';
|
||||
@@ -255,7 +250,7 @@ const mapTargetSummary = computed(() => {
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
|
||||
if (mapTarget.value === 'nation' && nationTargetField.value) {
|
||||
const value = values[nationTargetField.value.key];
|
||||
if (typeof value !== 'number') return '';
|
||||
const nation = props.mapData.nationList.find((entry) => entry[0] === value);
|
||||
@@ -264,7 +259,7 @@ const mapTargetSummary = computed(() => {
|
||||
const cityCount = props.mapData.cityList.filter((entry) => entry[3] === value).length;
|
||||
return `${nation[1]} · 수도 ${capital?.name ?? '-'} · 도시 ${cityCount.toLocaleString()}개`;
|
||||
}
|
||||
if (presentation.value.mapTarget === 'capital' && mapSelectedCityId.value) {
|
||||
if (mapTarget.value === 'capital' && mapSelectedCityId.value) {
|
||||
const city = props.mapLayout.cityList.find((entry) => entry.id === mapSelectedCityId.value);
|
||||
const dynamic = props.mapData.cityList.find((entry) => entry[0] === mapSelectedCityId.value);
|
||||
if (!city) return '';
|
||||
@@ -277,11 +272,11 @@ const mapTargetSummary = computed(() => {
|
||||
|
||||
const selectMapCity = (cityId: number) => {
|
||||
if (!props.mapData) return;
|
||||
if (presentation.value.mapTarget === 'city' && cityTargetField.value) {
|
||||
if (mapTarget.value === 'city' && cityTargetField.value) {
|
||||
setSelectValue(cityTargetField.value, String(cityId));
|
||||
return;
|
||||
}
|
||||
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
|
||||
if (mapTarget.value === 'nation' && nationTargetField.value) {
|
||||
const nationId = props.mapData.cityList.find((entry) => entry[0] === cityId)?.[3];
|
||||
if (nationId && nationId > 0) setSelectValue(nationTargetField.value, String(nationId));
|
||||
}
|
||||
@@ -416,24 +411,20 @@ watch(
|
||||
:detail-mode="true"
|
||||
:fit-container="true"
|
||||
:show-current-city-marker="true"
|
||||
:readonly="presentation.mapTarget === 'capital'"
|
||||
:readonly="mapTarget === 'capital'"
|
||||
@select-city="selectMapCity"
|
||||
/>
|
||||
<small v-if="presentation.mapTarget === 'capital'">현재 명령이 적용될 수도를 지도에서 확인하세요.</small>
|
||||
<small v-if="mapTarget === 'capital'">현재 명령이 적용될 수도를 지도에서 확인하세요.</small>
|
||||
<small v-else>지도에서 도시를 클릭하거나 아래 목록에서 대상을 선택하세요.</small>
|
||||
<div class="map-selection-status" aria-live="polite" data-testid="command-map-selection-status">
|
||||
<span v-if="presentation.mapTarget !== 'capital'" class="current-city-status">
|
||||
<span v-if="mapTarget !== 'capital'" class="current-city-status">
|
||||
<span class="status-key">현재 도시</span>
|
||||
<strong>{{ currentCityName }}</strong>
|
||||
</span>
|
||||
<span v-if="presentation.mapTarget !== 'capital'" aria-hidden="true">→</span>
|
||||
<span v-if="mapTarget !== 'capital'" aria-hidden="true">→</span>
|
||||
<span class="selected-target-status">
|
||||
<span class="status-key">{{
|
||||
presentation.mapTarget === 'nation'
|
||||
? '선택 국가'
|
||||
: presentation.mapTarget === 'capital'
|
||||
? '현재 수도'
|
||||
: '선택 도시'
|
||||
mapTarget === 'nation' ? '선택 국가' : mapTarget === 'capital' ? '현재 수도' : '선택 도시'
|
||||
}}</span>
|
||||
<strong>{{ selectedMapTargetName }}</strong>
|
||||
</span>
|
||||
|
||||
@@ -4,6 +4,7 @@ import test from 'node:test';
|
||||
import {
|
||||
commandArgumentPresentation,
|
||||
presentedCommandKeys,
|
||||
resolveCommandArgumentMapTarget,
|
||||
} from '../src/components/command/commandArgumentPresentation.ts';
|
||||
|
||||
const cityCommands = [
|
||||
@@ -82,3 +83,25 @@ void test('marks the same city and nation target families that Ref renders with
|
||||
assert.equal(commandArgumentPresentation(commandKey).mapTarget, 'capital', commandKey);
|
||||
}
|
||||
});
|
||||
|
||||
void test('derives selection maps from the actual city and nation argument contract', () => {
|
||||
assert.equal(
|
||||
resolveCommandArgumentMapTarget('future_city_command', [
|
||||
{ key: 'target', label: '도시', kind: 'select', required: true, optionSource: 'cities' },
|
||||
]),
|
||||
'city'
|
||||
);
|
||||
assert.equal(
|
||||
resolveCommandArgumentMapTarget('future_nation_command', [
|
||||
{ key: 'target', label: '국가', kind: 'select', required: true, optionSource: 'nations' },
|
||||
]),
|
||||
'nation'
|
||||
);
|
||||
assert.equal(resolveCommandArgumentMapTarget('che_증축', []), 'capital');
|
||||
assert.equal(
|
||||
resolveCommandArgumentMapTarget('future_general_command', [
|
||||
{ key: 'target', label: '장수', kind: 'select', required: true, optionSource: 'generals' },
|
||||
]),
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user