개인·사령턴 대상 선택에 초성 검색과 검색 전환 추가
This commit is contained in:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user