Merge remote-tracking branch 'origin/main' into fix/map-touch-panels
This commit is contained in:
@@ -3815,7 +3815,7 @@ for (const width of [1200, 390]) {
|
|||||||
await results.getByRole('button').click();
|
await results.getByRole('button').click();
|
||||||
await expect(form.locator('#command-arg-destNationId')).toHaveValue('3');
|
await expect(form.locator('#command-arg-destNationId')).toHaveValue('3');
|
||||||
await input.fill('적국');
|
await input.fill('적국');
|
||||||
await expect(results.locator('button')).toHaveCount(1);
|
await expect(results.locator('button strong')).toHaveText(['적국']);
|
||||||
await results.getByRole('button').click();
|
await results.getByRole('button').click();
|
||||||
await expect(form.locator('#command-arg-destNationId')).toHaveValue('2');
|
await expect(form.locator('#command-arg-destNationId')).toHaveValue('2');
|
||||||
await input.press('Escape');
|
await input.press('Escape');
|
||||||
@@ -3871,3 +3871,68 @@ test('gift search indexes nullable names without matching the displayed no-troop
|
|||||||
await search.fill('피곤');
|
await search.fill('피곤');
|
||||||
await expect(results.locator('button')).toHaveCount(3);
|
await expect(results.locator('button')).toHaveCount(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('search reflects an active Korean IME composition after debounce without committing it', 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: 0, npcState: 0, officerLevel: 5 },
|
||||||
|
],
|
||||||
|
nationNames: new Map([[1, '피곤']]),
|
||||||
|
cityNames: new Map([[1, '업']]),
|
||||||
|
});
|
||||||
|
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');
|
||||||
|
await form.getByRole('button', { name: '검색 꺼짐', exact: true }).click();
|
||||||
|
const input = form.locator('input[type=search]');
|
||||||
|
const results = form.getByTestId('general-target-list');
|
||||||
|
await input.focus();
|
||||||
|
await input.evaluate((element) => {
|
||||||
|
element.setAttribute('data-composition-ends', '0');
|
||||||
|
element.addEventListener('compositionend', () =>
|
||||||
|
element.setAttribute(
|
||||||
|
'data-composition-ends',
|
||||||
|
String(Number(element.getAttribute('data-composition-ends')) + 1)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const cdp = await page.context().newCDPSession(page);
|
||||||
|
await cdp.send('Input.imeSetComposition', { text: 'ㅅ', selectionStart: 1, selectionEnd: 1 });
|
||||||
|
await expect(results.locator('button strong')).toHaveText(['선녀 (피곤 · 업)', '손권 (피곤 · 업)']);
|
||||||
|
await cdp.send('Input.imeSetComposition', { text: 'ㅅㄴ', selectionStart: 2, selectionEnd: 2 });
|
||||||
|
await expect(input).toHaveValue('ㅅㄴ');
|
||||||
|
await expect(results.locator('button strong')).toHaveText(['선녀 (피곤 · 업)']);
|
||||||
|
await expect(input).toBeFocused();
|
||||||
|
await expect(input).toHaveAttribute('data-composition-ends', '0');
|
||||||
|
// IME 확정 Enter를 검색창 닫기로 가로채지 않는다.
|
||||||
|
await input.dispatchEvent('keydown', { key: 'Enter', isComposing: true, keyCode: 229 });
|
||||||
|
await expect(input).toBeFocused();
|
||||||
|
await expect(input).toHaveAttribute('data-composition-ends', '0');
|
||||||
|
await form.screenshot({ path: testInfo.outputPath('active-ime-search.png') });
|
||||||
|
await writeFile(testInfo.outputPath('active-ime-search.html'), await form.evaluate((element) => element.outerHTML));
|
||||||
|
await cdp.send('Input.insertText', { text: 'ㅅㄴ' });
|
||||||
|
await expect(input).toHaveAttribute('data-composition-ends', '1');
|
||||||
|
await form.getByRole('button', { name: '지우기', exact: true }).click();
|
||||||
|
await expect(results.locator('button')).toHaveCount(3);
|
||||||
|
await input.fill('ㄱㅇ');
|
||||||
|
await form.getByRole('button', { name: '검색 켜짐', exact: true }).click();
|
||||||
|
await page.waitForTimeout(250); // 예약된 디바운스가 OFF 이후 재적용되지 않는지 확인한다.
|
||||||
|
await expect(results.locator('button')).toHaveCount(3);
|
||||||
|
await form.getByRole('button', { name: '검색 꺼짐', exact: true }).click();
|
||||||
|
await expect(input).toHaveValue('');
|
||||||
|
await expect(results.locator('button')).toHaveCount(3);
|
||||||
|
await cdp.detach();
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, reactive, watch, type CSSProperties } from 'vue';
|
import { computed, onBeforeUnmount, reactive, watch, type CSSProperties } from 'vue';
|
||||||
import { commandTargetDescription } from '../../utils/commandTargetDescription';
|
import { commandTargetDescription } from '../../utils/commandTargetDescription';
|
||||||
import { useStorage } from '@vueuse/core';
|
import { useStorage } from '@vueuse/core';
|
||||||
import { buildCommandOptionSearchIndex, matchesCommandTargetSearch } from '../../utils/commandTargetSearch';
|
import { buildCommandOptionSearchIndex, matchesCommandTargetSearch } from '../../utils/commandTargetSearch';
|
||||||
@@ -72,6 +72,41 @@ const optionsFor = (field: CommandInputField): CommandOption[] => {
|
|||||||
|
|
||||||
const searchEnabled = useStorage('sam.core.commandTargetSearch', false);
|
const searchEnabled = useStorage('sam.core.commandTargetSearch', false);
|
||||||
const searchQueries = reactive<Record<string, string>>({});
|
const searchQueries = reactive<Record<string, string>>({});
|
||||||
|
const searchDrafts = reactive<Record<string, string>>({});
|
||||||
|
const searchTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
const cancelSearchTimer = (key: string) => {
|
||||||
|
const timer = searchTimers.get(key);
|
||||||
|
if (timer !== undefined) clearTimeout(timer);
|
||||||
|
searchTimers.delete(key);
|
||||||
|
};
|
||||||
|
const flushSearch = (key: string) => {
|
||||||
|
cancelSearchTimer(key);
|
||||||
|
searchQueries[key] = searchDrafts[key] ?? '';
|
||||||
|
};
|
||||||
|
const updateSearchDraft = (key: string, event: Event) => {
|
||||||
|
searchDrafts[key] = (event.target as HTMLInputElement).value;
|
||||||
|
cancelSearchTimer(key);
|
||||||
|
// v-model은 한글 조합 중 input을 무시한다. 원본 input 값을 읽되 검색만 150ms 디바운스한다.
|
||||||
|
searchTimers.set(
|
||||||
|
key,
|
||||||
|
setTimeout(() => flushSearch(key), 150)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const clearFieldSearch = (key: string) => {
|
||||||
|
searchDrafts[key] = '';
|
||||||
|
flushSearch(key);
|
||||||
|
};
|
||||||
|
const onSearchKeydown = (key: string, event: KeyboardEvent) => {
|
||||||
|
if (event.isComposing || event.keyCode === 229) return;
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
flushSearch(key);
|
||||||
|
(event.target as HTMLInputElement).blur();
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
clearFieldSearch(key);
|
||||||
|
(event.target as HTMLInputElement).blur();
|
||||||
|
}
|
||||||
|
};
|
||||||
const isSearchable = (field: CommandInputField): boolean =>
|
const isSearchable = (field: CommandInputField): boolean =>
|
||||||
field.kind === 'select' && ['generals', 'cities', 'nations'].includes(field.optionSource ?? '');
|
field.kind === 'select' && ['generals', 'cities', 'nations'].includes(field.optionSource ?? '');
|
||||||
const hasSearchableFields = computed(() => props.fields.some(isSearchable));
|
const hasSearchableFields = computed(() => props.fields.some(isSearchable));
|
||||||
@@ -105,8 +140,11 @@ const filteredTargets = computed(
|
|||||||
const visibleOptionsFor = (field: CommandInputField): CommandOption[] =>
|
const visibleOptionsFor = (field: CommandInputField): CommandOption[] =>
|
||||||
filteredTargets.value.get(field.key) ?? optionsFor(field);
|
filteredTargets.value.get(field.key) ?? optionsFor(field);
|
||||||
const clearSearchQueries = () => {
|
const clearSearchQueries = () => {
|
||||||
|
for (const key of searchTimers.keys()) cancelSearchTimer(key);
|
||||||
|
for (const key of Object.keys(searchDrafts)) delete searchDrafts[key];
|
||||||
for (const key of Object.keys(searchQueries)) delete searchQueries[key];
|
for (const key of Object.keys(searchQueries)) delete searchQueries[key];
|
||||||
};
|
};
|
||||||
|
onBeforeUnmount(clearSearchQueries);
|
||||||
watch(searchEnabled, clearSearchQueries);
|
watch(searchEnabled, clearSearchQueries);
|
||||||
watch(() => props.commandKey, clearSearchQueries);
|
watch(() => props.commandKey, clearSearchQueries);
|
||||||
|
|
||||||
@@ -598,23 +636,23 @@ watch(
|
|||||||
<label :for="`command-search-${field.key}`">{{ field.label }} 검색</label>
|
<label :for="`command-search-${field.key}`">{{ field.label }} 검색</label>
|
||||||
<input
|
<input
|
||||||
:id="`command-search-${field.key}`"
|
:id="`command-search-${field.key}`"
|
||||||
v-model="searchQueries[field.key]"
|
:value="searchDrafts[field.key] ?? ''"
|
||||||
type="search"
|
type="search"
|
||||||
inputmode="search"
|
inputmode="search"
|
||||||
enterkeyhint="done"
|
enterkeyhint="done"
|
||||||
placeholder="이름 또는 초성"
|
placeholder="이름 또는 초성"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
:spellcheck="false"
|
:spellcheck="false"
|
||||||
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()"
|
@input="updateSearchDraft(field.key, $event)"
|
||||||
@keydown.esc.stop="
|
@compositionend="updateSearchDraft(field.key, $event)"
|
||||||
searchQueries[field.key] = '';
|
@blur="flushSearch(field.key)"
|
||||||
($event.target as HTMLInputElement).blur();
|
@keydown.enter="onSearchKeydown(field.key, $event)"
|
||||||
"
|
@keydown.esc.stop="onSearchKeydown(field.key, $event)"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="legacy-button legacy-button--secondary"
|
class="legacy-button legacy-button--secondary"
|
||||||
@click="searchQueries[field.key] = ''"
|
@click="clearFieldSearch(field.key)"
|
||||||
>
|
>
|
||||||
지우기
|
지우기
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user