턴 대상의 원본 이름과 누락값 표시를 분리해 검색 정확도 개선

This commit is contained in:
2026-09-14 14:24:59 +00:00
parent 8760eab1a9
commit 7522f5100e
11 changed files with 220 additions and 29 deletions
+50 -1
View File
@@ -2892,7 +2892,8 @@ for (const width of [1200, 500]) {
? {
...option,
label: `${longName} (업)`,
description: option.description?.replace('청룡대', longTroop),
description: option.description,
targetNames: { ...option.targetNames!, name: longName, troopName: longTroop },
}
: option
);
@@ -3660,6 +3661,10 @@ for (const width of [1200, 390]) {
await form.getByRole('button', { name: '검색 꺼짐', exact: true }).click();
const generalSearch = form.locator('#command-search-destGeneralId');
const citySearch = form.locator('#command-search-destCityId');
for (const query of ['ㅇㅇ', '없음', '통솔', '1,200']) {
await generalSearch.fill(query);
await expect(form.getByTestId('general-target-list').locator('button')).toHaveCount(0);
}
await generalSearch.fill('ㅊㄹㄷ');
await expect(form.getByTestId('general-target-list')).toContainText('청룡대');
await generalSearch.fill('ㄱㅇ');
@@ -3722,3 +3727,47 @@ for (const width of [1200, 390]) {
});
});
}
test('gift search indexes nullable names without matching the displayed no-troop fallback', async ({
page,
}, testInfo) => {
const targets = buildRefGeneralTargetOptions({
actorId: 1,
actorNationId: 1,
generals: [
{ id: 1, name: '관우', nationId: 1, cityId: 1, troopId: 0, npcState: 0, officerLevel: 5 },
{ id: 2, name: '원우', nationId: 1, cityId: 1, troopId: 0, npcState: 0, officerLevel: 5 },
{ id: 3, name: '조조', nationId: 1, cityId: 1, troopId: 3, npcState: 0, officerLevel: 5 },
],
nationNames: new Map([[1, '피곤']]),
cityNames: new Map([[1, '업']]),
troopNames: new Map([[3, '원위대']]),
});
await install(page, false, {
...commandTable,
general: [{ category: '인사', values: [buildGeneralCommand('che_증여', '증여')] }],
inputOptions: { ...inputOptions, ...targets },
});
await page.goto(gamePath('/'));
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
const picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: /증여/ }).click();
const form = picker.getByTestId('command-argument-form');
const results = form.getByTestId('general-target-list');
await expect(results.locator('button')).toHaveCount(3);
await expect(results.getByRole('button', { name: /관우/ })).toContainText('탑승 부대 없음');
await form.getByRole('button', { name: '검색 꺼짐', exact: true }).click();
const search = form.locator('input[type=search]');
await search.fill('ㅇㅇ');
await expect(results.locator('button strong')).toHaveText(['원우 (피곤 · 업)', '조조 (피곤 · 업)']);
await expect(form.locator('#command-arg-destGeneralId')).toHaveValue('1');
await form.screenshot({ path: testInfo.outputPath('gift-real-name-search.png') });
await writeFile(
testInfo.outputPath('gift-real-name-search.html'),
await form.evaluate((element) => element.outerHTML)
);
await search.fill('없음');
await expect(results.locator('button')).toHaveCount(0);
await search.fill('피곤');
await expect(results.locator('button')).toHaveCount(3);
});
@@ -1,6 +1,14 @@
export type CommandOption = {
value: string | number;
label: string;
/** 원본 이름. 없는 소속/부대/수도는 null이며 대체 문구는 화면에서 표시한다. */
targetNames?: {
name: string;
nationName?: string | null;
cityName?: string | null;
troopName?: string | null;
capitalName?: string | null;
};
color?: string;
description?: string;
availableNow?: boolean;
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, reactive, watch, type CSSProperties } from 'vue';
import { commandTargetDescription } from '../../utils/commandTargetDescription';
import { useStorage } from '@vueuse/core';
import { buildCommandTargetSearchIndex, matchesCommandTargetSearch } from '../../utils/commandTargetSearch';
import { buildCommandOptionSearchIndex, matchesCommandTargetSearch } from '../../utils/commandTargetSearch';
import MapViewer from './MapViewer.vue';
import NationColorSelect from './NationColorSelect.vue';
import { commandArgumentPresentation, resolveCommandArgumentMapTarget } from '../command/commandArgumentPresentation';
@@ -82,9 +83,7 @@ const targetIndexes = computed(
field.key,
optionsFor(field).map((option) => ({
option,
index: [option.label, option.description ?? '']
.filter(Boolean)
.flatMap(buildCommandTargetSearchIndex),
index: buildCommandOptionSearchIndex(option),
})),
])
)
@@ -578,7 +577,7 @@ watch(
<div
v-if="
field.kind === 'select' &&
(selectedOptionFor(field)?.description || selectedOptionFor(field)?.color)
(commandTargetDescription(commandKey, selectedOptionFor(field)) || selectedOptionFor(field)?.color)
"
class="option-detail"
:class="{ 'assignment-detail': commandKey === 'che_발령' && field.optionSource === 'generals' }"
@@ -589,7 +588,7 @@ watch(
:style="{ backgroundColor: selectedOptionFor(field)?.color }"
aria-hidden="true"
/>
<span>{{ selectedOptionFor(field)?.description }}</span>
<span>{{ commandTargetDescription(commandKey, selectedOptionFor(field)) }}</span>
</div>
<div
v-if="searchEnabled && isSearchable(field)"
@@ -603,7 +602,7 @@ watch(
type="search"
inputmode="search"
enterkeyhint="done"
placeholder="이름·정보 또는 초성"
placeholder="이름 또는 초성"
autocomplete="off"
:spellcheck="false"
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()"
@@ -653,7 +652,7 @@ watch(
<span class="target-state">{{
option.availableNow === false ? '현재 불가' : option.availableNow ? '우선 대상' : '대상'
}}</span>
<small>{{ option.description }}</small>
<small>{{ commandTargetDescription(commandKey, option) }}</small>
</button>
</div>
</div>
@@ -0,0 +1,16 @@
import type { CommandOption } from '../components/command/types';
export const commandTargetDescription = (commandKey: string, option?: CommandOption): string => {
if (!option) return '';
const description = option.description ?? '';
// 이전 API는 완성된 description을 주므로 새 원본 필드가 없으면 그대로 표시한다.
if (option.targetNames?.troopName === undefined || commandKey === 'che_포상' || commandKey === 'che_몰수') {
return description;
}
const troopName = option.targetNames.troopName ?? (option.troopId ? `#${option.troopId}` : '없음');
const leader = option.troopId && option.troopId === option.value ? ' (부대장)' : '';
const troop = `탑승 부대 ${troopName}${leader}`;
return commandKey === 'che_발령'
? [troop, description].filter(Boolean).join('\n')
: [description, troop].filter(Boolean).join(' · ');
};
@@ -1,3 +1,5 @@
import type { CommandOption } from '../components/command/types';
// Ref convertSearch초성: 원문, 두벌식 초성, 한글 초성, 두 단계 IME 겹자음 인덱스.
const initials = 'ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ';
const keyboard = 'rRseEfaqQtTdwWczxvg';
@@ -45,3 +47,9 @@ export const matchesCommandTargetSearch = (index: readonly string[], query: stri
const normalized = normalize(query);
return index.some((text) => text.includes(normalized));
};
// 구 API 응답은 표시 이름까지만 검색한다. description은 결코 검색하지 않는다.
export const buildCommandOptionSearchIndex = (option: Pick<CommandOption, 'label' | 'targetNames'>): string[] =>
(option.targetNames ? Object.values(option.targetNames) : [option.label])
.filter((name): name is string => typeof name === 'string' && Boolean(name.trim()))
.flatMap(buildCommandTargetSearchIndex);
@@ -0,0 +1,29 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { commandTargetDescription } from '../src/utils/commandTargetDescription.ts';
void test('nullable troop names receive display-only fallbacks and preserve leader and member information', () => {
const base = { value: 1, label: '관우', description: '금 100', targetNames: { name: '관우', troopName: null } };
assert.equal(commandTargetDescription('che_증여', base), '금 100 · 탑승 부대 없음');
assert.equal(commandTargetDescription('che_발령', base), '탑승 부대 없음\n금 100');
assert.equal(commandTargetDescription('che_포상', base), '금 100');
assert.equal(commandTargetDescription('che_몰수', base), '금 100');
assert.equal(commandTargetDescription('che_증여', { ...base, troopId: 99 }), '금 100 · 탑승 부대 #99');
assert.equal(
commandTargetDescription('che_증여', {
...base,
troopId: 1,
targetNames: { name: '관우', troopName: '청룡대' },
}),
'금 100 · 탑승 부대 청룡대 (부대장)'
);
assert.equal(
commandTargetDescription('che_증여', {
...base,
targetNames: undefined,
description: '금 100 · 탑승 부대 없음',
}),
'금 100 · 탑승 부대 없음'
);
assert.equal(commandTargetDescription('che_증여'), '');
});
@@ -1,6 +1,10 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { buildCommandTargetSearchIndex, matchesCommandTargetSearch } from '../src/utils/commandTargetSearch.ts';
import {
buildCommandTargetSearchIndex,
buildCommandOptionSearchIndex,
matchesCommandTargetSearch,
} from '../src/utils/commandTargetSearch.ts';
void test('Ref name index includes keyboard initials and both IME compound levels', () => {
assert.deepEqual(buildCommandTargetSearchIndex('강서'), ['강서', 'rt', 'ㄱㅅ', 'ㄳ']);
@@ -19,3 +23,34 @@ void test('literal, initial, keyboard and normalized queries preserve meaningful
assert.equal(matchesCommandTargetSearch(buildCommandTargetSearchIndex('쌍성'), 'ㅆㅅ'), true);
assert.equal(matchesCommandTargetSearch(buildCommandTargetSearchIndex('쌍성'), 'ㅅㅅ'), false);
});
void test('explicit names exclude descriptive copy and placeholders without blacklisting genuine names', () => {
const option = {
label: '관우 (무소속 · 재야)',
targetNames: { name: '관우', troopName: '청룡대', nationName: null, cityName: null },
description: '탑승 부대 없음 · 현재 불가 · 금 100',
};
const index = buildCommandOptionSearchIndex(option);
for (const query of ['ㅇㅇ', '없음', '무소속', '재야', '현재', '100'])
assert.equal(matchesCommandTargetSearch(index, query), false, query);
for (const query of ['관우', 'ㄱㅇ', '청룡대', 'ㅊㄹㄷ'])
assert.equal(matchesCommandTargetSearch(index, query), true, query);
assert.equal(
matchesCommandTargetSearch(
buildCommandOptionSearchIndex({ label: '별명', targetNames: { name: '없음', troopName: null } }),
'ㅇㅇ'
),
true
);
assert.equal(
matchesCommandTargetSearch(
buildCommandOptionSearchIndex({ label: '관우', ...{ description: '부대 없음' } }),
'ㅇㅇ'
),
false
);
assert.deepEqual(
buildCommandOptionSearchIndex({ label: '누락 안내', targetNames: { name: '', troopName: null } }),
[]
);
});