merge: 최신 main을 자동 화면 폭 수정에 통합

This commit is contained in:
2026-08-21 01:13:27 +00:00
7 changed files with 304 additions and 67 deletions
+33 -7
View File
@@ -6,10 +6,7 @@ import {
} from '@sammo-ts/logic'; } from '@sammo-ts/logic';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import { buildTurnCommandInputFields, parseReservedTurnArgs } from '../src/turns/commandInput.js';
buildTurnCommandInputFields,
parseReservedTurnArgs,
} from '../src/turns/commandInput.js';
describe('turn command argument input', () => { describe('turn command argument input', () => {
it('builds supported fields for every argument-bearing command module', async () => { it('builds supported fields for every argument-bearing command module', async () => {
@@ -22,6 +19,9 @@ describe('turn command argument input', () => {
key: spec.key, key: spec.key,
fields: buildTurnCommandInputFields(spec), fields: buildTurnCommandInputFields(spec),
})); }));
const nationFields = nation
.filter((spec) => spec.reqArg)
.map((spec) => ({ key: spec.key, fields: buildTurnCommandInputFields(spec) }));
expect(argumentSpecs).toHaveLength(44); expect(argumentSpecs).toHaveLength(44);
expect(fields.every((entry) => entry.fields.length > 0)).toBe(true); 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: 'destNationId', kind: 'select', optionSource: 'nations' },
{ key: 'amountList', kind: 'numberTuple' }, { 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 () => { it('normalizes valid arguments and rejects malformed or wrong-scope commands', async () => {
@@ -50,8 +78,6 @@ describe('turn command argument input', () => {
amount: 200, amount: 200,
destGeneralId: 7, destGeneralId: 7,
}); });
await expect(parseReservedTurnArgs('general', 'che_포상', {})).rejects.toThrow( await expect(parseReservedTurnArgs('general', 'che_포상', {})).rejects.toThrow('Unknown general turn command');
'Unknown general turn command'
);
}); });
}); });
+127 -25
View File
@@ -522,6 +522,21 @@ const commandTable = {
buildCityCommand('che_백성동원', '백성동원'), buildCityCommand('che_백성동원', '백성동원'),
buildCityCommand('che_수몰', '수몰'), 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] }, { id: 2, name: '허창', level: 7, region: 2, x: 240, y: 180, path: [1] },
], ],
regionMap: { 1: '하북', 2: '예주' }, regionMap: { 1: '하북', 2: '예주' },
levelMap: { 8: '특' }, levelMap: { 8: '특', 7: '대' },
}); });
if (name === 'auth.status') return response({ ok: true }); if (name === 'auth.status') return response({ ok: true });
if (name === 'lobby.info') if (name === 'lobby.info')
@@ -953,9 +968,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 +992,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 +1295,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'),
}); });
@@ -1780,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 }); 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 install(page);
await page.setViewportSize({ width: 1200, height: 900 }); await page.setViewportSize({ width: 1200, height: 900 });
const cases = [ const cases = [
{ category: '인사', action: '발령', mode: 'city' },
{ category: '특수', action: '초토화', mode: 'city' }, { 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: '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) { for (const entry of cases) {
@@ -1805,24 +1825,106 @@ test('shows city or capital maps for every requested chief command', async ({ pa
await expect(map, `${entry.action} 지도`).toBeVisible(); await expect(map, `${entry.action} 지도`).toBeVisible();
await expect(form.getByTestId('command-argument-guidance')).toBeVisible(); await expect(form.getByTestId('command-argument-guidance')).toBeVisible();
if (entry.mode === 'capital') { await map.locator('.city-base').nth(1).click();
await expect(form.getByTestId('command-map-selection-status')).toContainText('현재 수도업'); if (entry.mode === 'city') {
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 expect(form.locator('#command-arg-destCityId')).toHaveValue('2'); await expect(form.locator('#command-arg-destCityId')).toHaveValue('2');
await expect(form.getByTestId('command-map-selection-status')).toContainText('선택 도시허창'); 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 }) => {
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 }) => {
@@ -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);
};
@@ -1,6 +1,10 @@
import type { CommandInputField } from './types';
export type CommandArgumentMapTarget = 'city' | 'nation' | 'capital';
export type CommandArgumentPresentation = { export type CommandArgumentPresentation = {
lines: string[]; lines: string[];
mapTarget?: 'city' | 'nation' | 'capital'; mapTarget?: CommandArgumentMapTarget;
}; };
const cityTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'city' }); const cityTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'city' });
@@ -98,4 +102,18 @@ const PRESENTATIONS: Record<string, CommandArgumentPresentation> = {
export const commandArgumentPresentation = (commandKey: string): CommandArgumentPresentation => export const commandArgumentPresentation = (commandKey: string): CommandArgumentPresentation =>
PRESENTATIONS[commandKey] ?? { lines: [] }; 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); export const presentedCommandKeys = (): string[] => Object.keys(PRESENTATIONS);
@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
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, resolveCommandArgumentMapTarget } 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');
@@ -162,20 +161,15 @@ const nationTargetField = computed(() =>
(field) => field.kind === 'select' && field.optionSource === 'nations' && field.key === 'destNationId' (field) => field.kind === 'select' && field.optionSource === 'nations' && field.key === 'destNationId'
) )
); );
const showMap = computed( const mapTarget = computed(() => resolveCommandArgumentMapTarget(props.commandKey, props.fields));
() => const showMap = computed(() => Boolean(props.mapData && props.mapLayout && mapTarget.value));
Boolean(props.mapData && props.mapLayout) &&
((presentation.value.mapTarget === 'city' && cityTargetField.value) ||
(presentation.value.mapTarget === 'nation' && nationTargetField.value) ||
presentation.value.mapTarget === 'capital')
);
const mapSelectedCityId = computed<number | null>(() => { const mapSelectedCityId = computed<number | null>(() => {
if (!props.mapData) return 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]; const value = values[cityTargetField.value.key];
return typeof value === 'number' ? value : null; 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]; const value = values[nationTargetField.value.key];
if (typeof value !== 'number') return null; if (typeof value !== 'number') return null;
return ( return (
@@ -184,7 +178,7 @@ const mapSelectedCityId = computed<number | null>(() => {
null null
); );
} }
if (presentation.value.mapTarget === 'capital') { if (mapTarget.value === 'capital') {
const myNation = props.mapData.myNation; const myNation = props.mapData.myNation;
return props.mapData.nationList.find((entry) => entry[0] === myNation)?.[3] ?? null; return props.mapData.nationList.find((entry) => entry[0] === myNation)?.[3] ?? null;
} }
@@ -198,17 +192,17 @@ const currentCityName = computed(() => {
}); });
const selectedMapTargetName = computed(() => { const selectedMapTargetName = computed(() => {
if (presentation.value.mapTarget === 'city') { if (mapTarget.value === 'city') {
const cityId = mapSelectedCityId.value; const cityId = mapSelectedCityId.value;
if (!cityId) return '-'; if (!cityId) return '-';
return props.mapLayout?.cityList.find((city) => city.id === cityId)?.name ?? '-'; 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]; const nationId = values[nationTargetField.value.key];
if (typeof nationId !== 'number') return '-'; if (typeof nationId !== 'number') return '-';
return props.mapData?.nationList.find((nation) => nation[0] === nationId)?.[1] ?? '-'; return props.mapData?.nationList.find((nation) => nation[0] === nationId)?.[1] ?? '-';
} }
if (presentation.value.mapTarget === 'capital') { if (mapTarget.value === 'capital') {
const cityId = mapSelectedCityId.value; const cityId = mapSelectedCityId.value;
if (!cityId) return '-'; if (!cityId) return '-';
return props.mapLayout?.cityList.find((city) => city.id === cityId)?.name ?? '-'; return props.mapLayout?.cityList.find((city) => city.id === cityId)?.name ?? '-';
@@ -240,7 +234,7 @@ const distanceFromMyCity = (destination: number): number | null => {
const mapTargetSummary = computed(() => { const mapTargetSummary = computed(() => {
if (!props.mapData || !props.mapLayout) return ''; 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 city = props.mapLayout.cityList.find((entry) => entry.id === mapSelectedCityId.value);
const dynamic = props.mapData.cityList.find((entry) => entry[0] === mapSelectedCityId.value); const dynamic = props.mapData.cityList.find((entry) => entry[0] === mapSelectedCityId.value);
if (!city) return ''; if (!city) return '';
@@ -256,7 +250,7 @@ const mapTargetSummary = computed(() => {
.filter(Boolean) .filter(Boolean)
.join(' · '); .join(' · ');
} }
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) { if (mapTarget.value === 'nation' && nationTargetField.value) {
const value = values[nationTargetField.value.key]; const value = values[nationTargetField.value.key];
if (typeof value !== 'number') return ''; if (typeof value !== 'number') return '';
const nation = props.mapData.nationList.find((entry) => entry[0] === value); const nation = props.mapData.nationList.find((entry) => entry[0] === value);
@@ -265,7 +259,7 @@ const mapTargetSummary = computed(() => {
const cityCount = props.mapData.cityList.filter((entry) => entry[3] === value).length; const cityCount = props.mapData.cityList.filter((entry) => entry[3] === value).length;
return `${nation[1]} · 수도 ${capital?.name ?? '-'} · 도시 ${cityCount.toLocaleString()}`; 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 city = props.mapLayout.cityList.find((entry) => entry.id === mapSelectedCityId.value);
const dynamic = props.mapData.cityList.find((entry) => entry[0] === mapSelectedCityId.value); const dynamic = props.mapData.cityList.find((entry) => entry[0] === mapSelectedCityId.value);
if (!city) return ''; if (!city) return '';
@@ -278,11 +272,11 @@ const mapTargetSummary = computed(() => {
const selectMapCity = (cityId: number) => { const selectMapCity = (cityId: number) => {
if (!props.mapData) return; if (!props.mapData) return;
if (presentation.value.mapTarget === 'city' && cityTargetField.value) { if (mapTarget.value === 'city' && cityTargetField.value) {
setSelectValue(cityTargetField.value, String(cityId)); setSelectValue(cityTargetField.value, String(cityId));
return; 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]; const nationId = props.mapData.cityList.find((entry) => entry[0] === cityId)?.[3];
if (nationId && nationId > 0) setSelectValue(nationTargetField.value, String(nationId)); if (nationId && nationId > 0) setSelectValue(nationTargetField.value, String(nationId));
} }
@@ -417,24 +411,20 @@ watch(
:detail-mode="true" :detail-mode="true"
:fit-container="true" :fit-container="true"
:show-current-city-marker="true" :show-current-city-marker="true"
:readonly="presentation.mapTarget === 'capital'" :readonly="mapTarget === 'capital'"
@select-city="selectMapCity" @select-city="selectMapCity"
/> />
<small v-if="presentation.mapTarget === 'capital'">현재 명령이 적용될 수도를 지도에서 확인하세요.</small> <small v-if="mapTarget === 'capital'">현재 명령이 적용될 수도를 지도에서 확인하세요.</small>
<small v-else>지도에서 도시를 클릭하거나 아래 목록에서 대상을 선택하세요.</small> <small v-else>지도에서 도시를 클릭하거나 아래 목록에서 대상을 선택하세요.</small>
<div class="map-selection-status" aria-live="polite" data-testid="command-map-selection-status"> <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> <span class="status-key">현재 도시</span>
<strong>{{ currentCityName }}</strong> <strong>{{ currentCityName }}</strong>
</span> </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="selected-target-status">
<span class="status-key">{{ <span class="status-key">{{
presentation.mapTarget === 'nation' mapTarget === 'nation' ? '선택 국가' : mapTarget === 'capital' ? '현재 수도' : '선택 도시'
? '선택 국가'
: presentation.mapTarget === 'capital'
? '현재 수도'
: '선택 도시'
}}</span> }}</span>
<strong>{{ selectedMapTargetName }}</strong> <strong>{{ selectedMapTargetName }}</strong>
</span> </span>
@@ -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]
);
});
@@ -4,6 +4,7 @@ import test from 'node:test';
import { import {
commandArgumentPresentation, commandArgumentPresentation,
presentedCommandKeys, presentedCommandKeys,
resolveCommandArgumentMapTarget,
} from '../src/components/command/commandArgumentPresentation.ts'; } from '../src/components/command/commandArgumentPresentation.ts';
const cityCommands = [ 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); 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
);
});