개인·사령턴 대상 선택에 초성 검색과 검색 전환 추가

This commit is contained in:
2026-09-14 13:57:43 +00:00
parent 1b07c5ffe9
commit d50cff8640
4 changed files with 298 additions and 3 deletions
@@ -3601,3 +3601,114 @@ test('NPC prefixes stay single in chief command cards', async ({ page }, testInf
await page.screenshot({ path: testInfo.outputPath(`npc-chief-prefix-${width}.png`), fullPage: true });
}
});
for (const width of [1200, 390]) {
test.describe(`target search viewport ${width}`, () => {
test.use({ isMobile: width < 500, hasTouch: width < 500 });
test(`target search preserves selection and searches Korean initials in both editors at ${width}px`, async ({
page,
}, testInfo) => {
const requests = await install(page);
await page.setViewportSize({ width, height: 900 });
await page.goto(gamePath('/'));
const initialScale = await page.evaluate(() => visualViewport?.scale);
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
let picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: /화계/ }).click();
let form = picker.getByTestId('command-argument-form');
const toggle = form.getByRole('button', { name: '검색 꺼짐', exact: true });
await expect(form.locator('input[type=search]')).toHaveCount(0);
await toggle.click();
let input = form.locator('input[type=search]');
await expect(input).not.toBeFocused();
await expect(input).toHaveCSS('font-size', '16px');
await input.fill('ㅎㅊ');
let results = form.getByTestId('city-target-list');
await expect(results.locator('button')).toHaveCount(1);
await expect(results).toContainText('허창');
await results.getByRole('button').click();
await expect(form.locator('#command-arg-destCityId')).toHaveValue('2');
await input.fill('없는도시');
await expect(results).toContainText('검색 결과가 없습니다.');
await expect(form.locator('#command-arg-destCityId')).toHaveValue('2');
await form.getByRole('button', { name: '지우기', exact: true }).click();
await expect(results.locator('button')).toHaveCount(3);
await input.fill('GC');
await expect(results.locator('button')).toHaveCount(1);
await input.press('Enter');
await expect(input).not.toBeFocused();
await expect(picker).toBeVisible();
await form.getByRole('button', { name: '검색 켜짐', exact: true }).click();
await expect(input).toHaveCount(0);
await expect(form.locator('#command-arg-destCityId')).toHaveValue('2');
await picker.getByRole('button', { name: '입력', exact: true }).click();
await expect.poll(() => JSON.stringify(requests)).toContain('"destCityId":2');
await page.goto(gamePath('/chief-center'));
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
await picker.getByRole('button', { name: /발령/ }).click();
form = picker.getByTestId('command-argument-form');
await form.getByRole('button', { name: '검색 꺼짐', exact: true }).click();
const generalSearch = form.locator('#command-search-destGeneralId');
const citySearch = form.locator('#command-search-destCityId');
await generalSearch.fill('ㅊㄹㄷ');
await expect(form.getByTestId('general-target-list')).toContainText('청룡대');
await generalSearch.fill('ㄱㅇ');
await citySearch.fill('ㅎㅊ');
await expect(form.getByTestId('general-target-list').locator('button')).toHaveCount(1);
await expect(form.getByTestId('city-target-list').locator('button')).toHaveCount(1);
await form.getByTestId('general-target-list').getByRole('button').click();
await form.getByTestId('city-target-list').getByRole('button').click();
await expect(form.locator('#command-arg-destGeneralId')).toHaveValue('2');
await expect(form.locator('#command-arg-destCityId')).toHaveValue('2');
await page.evaluate(() => document.fonts.ready);
const geometry = await form.evaluate((element) => ({
html: element.outerHTML,
inputs: [...element.querySelectorAll('input[type=search]')].map((input) => {
const rect = input.getBoundingClientRect();
return {
x: rect.x,
right: rect.right,
width: rect.width,
fontSize: getComputedStyle(input).fontSize,
};
}),
viewport: { width: innerWidth, scale: visualViewport?.scale },
}));
expect(geometry.inputs.every((input) => input.x >= 0 && input.right <= geometry.viewport.width)).toBe(true);
expect(geometry.viewport.scale).toBe(initialScale);
await writeFile(testInfo.outputPath(`search-${width}.json`), JSON.stringify(geometry, null, 2));
await form.screenshot({ path: testInfo.outputPath(`search-${width}.png`) });
await picker.getByRole('button', { name: '입력', exact: true }).click();
await expect.poll(() => JSON.stringify(requests)).toContain('"destGeneralId":2,"destCityId":2');
await page.reload();
await page.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: /^(?:국가:)?외교$/, exact: true }).click();
await picker.getByRole('button', { name: /선전포고/ }).click();
form = picker.getByTestId('command-argument-form');
await expect(form.getByRole('button', { name: '검색 켜짐', exact: true })).toHaveAttribute(
'aria-pressed',
'true'
);
input = form.locator('input[type=search]');
await expect(input).toHaveValue('');
await expect(input).not.toBeFocused();
await input.fill('ㅂㄱㅊㄱ');
results = form.getByTestId('nation-target-list');
await expect(results.locator('button')).toHaveCount(1);
await expect(results).toContainText('현재 불가');
await results.getByRole('button').click();
await expect(form.locator('#command-arg-destNationId')).toHaveValue('3');
await input.fill('적국');
await expect(results.locator('button')).toHaveCount(1);
await results.getByRole('button').click();
await expect(form.locator('#command-arg-destNationId')).toHaveValue('2');
await input.press('Escape');
await expect(input).toHaveValue('');
await expect(picker).toBeVisible();
});
});
}
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { computed, reactive, watch, type CSSProperties } from 'vue';
import { useStorage } from '@vueuse/core';
import { buildCommandTargetSearchIndex, matchesCommandTargetSearch } from '../../utils/commandTargetSearch';
import MapViewer from './MapViewer.vue';
import NationColorSelect from './NationColorSelect.vue';
import { commandArgumentPresentation, resolveCommandArgumentMapTarget } from '../command/commandArgumentPresentation';
@@ -67,6 +69,48 @@ const optionsFor = (field: CommandInputField): CommandOption[] => {
return props.options[field.optionSource];
};
const searchEnabled = useStorage('sam.core.commandTargetSearch', false);
const searchQueries = reactive<Record<string, string>>({});
const isSearchable = (field: CommandInputField): boolean =>
field.kind === 'select' && ['generals', 'cities', 'nations'].includes(field.optionSource ?? '');
const hasSearchableFields = computed(() => props.fields.some(isSearchable));
// 후보나 정렬이 바뀔 때만 인덱스를 갱신하고 키 입력은 기존 인덱스를 검색한다.
const targetIndexes = computed(
() =>
new Map(
props.fields.filter(isSearchable).map((field) => [
field.key,
optionsFor(field).map((option) => ({
option,
index: [option.label, option.description ?? '']
.filter(Boolean)
.flatMap(buildCommandTargetSearchIndex),
})),
])
)
);
const filteredTargets = computed(
() =>
new Map(
[...targetIndexes.value].map(([key, entries]) => [
key,
entries
.filter(
(entry) =>
!searchEnabled.value || matchesCommandTargetSearch(entry.index, searchQueries[key] ?? '')
)
.map((entry) => entry.option),
])
)
);
const visibleOptionsFor = (field: CommandInputField): CommandOption[] =>
filteredTargets.value.get(field.key) ?? optionsFor(field);
const clearSearchQueries = () => {
for (const key of Object.keys(searchQueries)) delete searchQueries[key];
};
watch(searchEnabled, clearSearchQueries);
watch(() => props.commandKey, clearSearchQueries);
const defaultValue = (field: CommandInputField): unknown => {
if (field.kind === 'hidden') return field.constValue;
if (field.defaultValue !== undefined) return field.defaultValue;
@@ -431,6 +475,15 @@ watch(
<div v-if="resourceSummary.length" class="resource-summary" data-testid="command-resource-summary">
<span v-for="entry in resourceSummary" :key="entry">{{ entry }}</span>
</div>
<button
v-if="hasSearchableFields"
type="button"
class="legacy-button legacy-button--secondary target-search-toggle"
:aria-pressed="searchEnabled"
@click="searchEnabled = !searchEnabled"
>
{{ searchEnabled ? '검색 켜짐' : '검색 꺼짐' }}
</button>
<div v-for="field in visibleFields" :key="field.key" class="argument-row">
<label :for="`command-arg-${field.key}`">{{ field.label }}</label>
<input
@@ -555,13 +608,52 @@ watch(
<span>{{ selectedOptionFor(field)?.description }}</span>
</div>
<div
v-if="showOptionCards(field)"
v-if="searchEnabled && isSearchable(field)"
class="target-search"
:data-testid="`target-search-${field.key}`"
>
<label :for="`command-search-${field.key}`">{{ field.label }} 검색</label>
<input
:id="`command-search-${field.key}`"
v-model="searchQueries[field.key]"
type="search"
inputmode="search"
enterkeyhint="done"
placeholder="이름·정보 또는 초성"
autocomplete="off"
:spellcheck="false"
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()"
@keydown.esc.stop="
searchQueries[field.key] = '';
($event.target as HTMLInputElement).blur();
"
/>
<button
type="button"
class="legacy-button legacy-button--secondary"
@click="searchQueries[field.key] = ''"
>
지우기
</button>
<small role="status"
>검색 결과 {{ visibleOptionsFor(field).length }}개 / {{ optionsFor(field).length }}개</small
>
</div>
<div
v-if="showOptionCards(field) || (searchEnabled && isSearchable(field))"
class="target-option-list"
:class="{ 'assignment-target-list': commandKey === 'che_발령' && field.optionSource === 'generals' }"
:data-testid="field.optionSource === 'nations' ? 'nation-target-list' : 'general-target-list'"
:data-testid="
field.optionSource === 'nations'
? 'nation-target-list'
: field.optionSource === 'cities'
? 'city-target-list'
: 'general-target-list'
"
>
<span v-if="!visibleOptionsFor(field).length" class="target-search-empty">검색 결과가 없습니다.</span>
<button
v-for="option in optionsFor(field)"
v-for="option in visibleOptionsFor(field)"
:key="String(option.value)"
type="button"
class="target-option"
@@ -586,6 +678,30 @@ watch(
</template>
<style scoped>
.target-search-toggle {
margin: 6px 8px;
}
.target-search {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 4px 8px;
}
.target-search input {
flex: 1 1 140px;
width: 140px;
margin: 0;
font-size: 16px;
}
.target-search small {
flex-basis: 100%;
}
.target-search-empty {
padding: 8px;
}
.command-argument-form {
border: 1px solid rgba(201, 164, 90, 0.35);
font-size: 0.75rem;
@@ -0,0 +1,47 @@
// Ref convertSearch초성: 원문, 두벌식 초성, 한글 초성, 두 단계 IME 겹자음 인덱스.
const initials = 'ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ';
const keyboard = 'rRseEfaqQtTdwWczxvg';
const compoundPairs: Record<string, string> = {
: 'ㄳ',
: 'ㄵ',
: 'ㄶ',
: 'ㄼ',
: 'ㄺ',
: 'ㄽ',
: 'ㄻ',
: 'ㅀ',
: 'ㄾ',
: 'ㄿ',
: 'ㅄ',
};
const doublePairs: Record<string, string> = { : 'ㄲ', : 'ㄸ', : 'ㅃ', : 'ㅆ', : 'ㅉ' };
const normalize = (text: string): string => text.normalize('NFC').toLowerCase().replace(/\s+/gu, '');
const combine = (text: string, doubles: boolean): string => {
const chars = [...text];
const result: string[] = [];
for (let index = 0; index < chars.length; index += 1) {
const pair = chars[index]! + (chars[index + 1] ?? '');
const combined = compoundPairs[pair] ?? (doubles ? doublePairs[pair] : undefined);
result.push(combined ?? chars[index]!);
if (combined) index += 1;
}
return result.join('');
};
export const buildCommandTargetSearchIndex = (text: string): string[] => {
const compact = text.normalize('NFC').replace(/\s+/gu, '');
let hangul = '';
let alphabet = '';
for (const character of compact) {
const code = character.charCodeAt(0) - 0xac00;
const index = code >= 0 && code < 11172 ? Math.floor(code / 588) : -1;
hangul += index >= 0 ? initials[index] : character;
alphabet += index >= 0 ? keyboard[index] : character;
}
return [...new Set([compact, alphabet, hangul, combine(hangul, false), combine(hangul, true)].map(normalize))];
};
export const matchesCommandTargetSearch = (index: readonly string[], query: string): boolean => {
const normalized = normalize(query);
return index.some((text) => text.includes(normalized));
};
@@ -0,0 +1,21 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { buildCommandTargetSearchIndex, matchesCommandTargetSearch } from '../src/utils/commandTargetSearch.ts';
void test('Ref name index includes keyboard initials and both IME compound levels', () => {
assert.deepEqual(buildCommandTargetSearchIndex('강서'), ['강서', 'rt', 'ㄱㅅ', 'ㄳ']);
assert.deepEqual(buildCommandTargetSearchIndex('사서'), ['사서', 'tt', 'ㅅㅅ', 'ㅆ']);
assert.deepEqual(buildCommandTargetSearchIndex('강서사서'), ['강서사서', 'rttt', 'ㄱㅅㅅㅅ', 'ㄳㅅㅅ', 'ㄳㅆ']);
});
void test('literal, initial, keyboard and normalized queries preserve meaningful distinctions', () => {
const index = buildCommandTargetSearchIndex('허창 (적국)');
for (const query of ['', ' ', '허창', 'ㅎㅊ', 'GC', '허 창', '적국', 'ㅈㄱ']) {
assert.equal(matchesCommandTargetSearch(index, query), true, query);
}
for (const query of ['업', 'ㅎㄱ', '.*', '[']) assert.equal(matchesCommandTargetSearch(index, query), false, query);
assert.equal(matchesCommandTargetSearch(buildCommandTargetSearchIndex('관우'), '관우'), true);
assert.equal(matchesCommandTargetSearch(buildCommandTargetSearchIndex('여포NPC'), 'npc'), true);
assert.equal(matchesCommandTargetSearch(buildCommandTargetSearchIndex('쌍성'), 'ㅆㅅ'), true);
assert.equal(matchesCommandTargetSearch(buildCommandTargetSearchIndex('쌍성'), 'ㅅㅅ'), false);
});