fix(game-ui): 모바일 턴 입력의 native select 갱신을 격리한다

This commit is contained in:
2026-08-21 22:50:21 +00:00
parent 7066d13c5b
commit b04f7b5534
3 changed files with 285 additions and 11 deletions
+32
View File
@@ -190,6 +190,38 @@ describe('buildTurnCommandTable', () => {
}); });
}); });
it('keeps every default general and chief argument command inside the shared frontend field contract', async () => {
const table = await buildTurnCommandTable({
worldState: buildWorldState(),
general: buildGeneral(),
city: buildCity(),
nation: buildNation(),
nationGenerals: null,
});
const supportedKinds = new Set(['text', 'number', 'boolean', 'select', 'numberTuple', 'hidden']);
for (const [scope, groups] of [
['general', table.general],
['nation', table.nation],
] as const) {
for (const command of groups.flatMap((group) => group.values)) {
if (!command.reqArg) continue;
expect(command.inputFields.length, `${scope}:${command.key}`).toBeGreaterThan(0);
expect(new Set(command.inputFields.map((field) => field.key)).size, `${scope}:${command.key}`).toBe(
command.inputFields.length
);
for (const field of command.inputFields) {
expect(supportedKinds.has(field.kind), `${scope}:${command.key}:${field.key}`).toBe(true);
if (field.kind === 'select') {
expect(Boolean(field.options?.length || field.optionSource), `${scope}:${command.key}:${field.key}`).toBe(
true
);
}
}
}
}
});
it('projects the Ref availability boundaries for force move, retirement, and resignation', async () => { it('projects the Ref availability boundaries for force move, retirement, and resignation', async () => {
const buildTable = (general: GeneralRow, nation: NationRow | null = buildNation()) => const buildTable = (general: GeneralRow, nation: NationRow | null = buildNation()) =>
buildTurnCommandTable({ buildTurnCommandTable({
+223 -2
View File
@@ -3909,6 +3909,11 @@ for (const viewport of [
path: '/inputOptions/context/actorGold', path: '/inputOptions/context/actorGold',
value: 10_000 + refreshIndex, value: 10_000 + refreshIndex,
}, },
{
op: 'replace',
path: '/inputOptions/items/weapon/1/label',
value: `청룡언월도 갱신 ${refreshIndex}`,
},
]; ];
await emitReadModelInvalidation( await emitReadModelInvalidation(
page, page,
@@ -3952,16 +3957,232 @@ for (const viewport of [
await picker.getByRole('button', { name: '명령 다시 선택', exact: true }).click(); await picker.getByRole('button', { name: '명령 다시 선택', exact: true }).click();
await picker.getByRole('button', { name: '장비 매매', exact: true }).click(); await picker.getByRole('button', { name: '장비 매매', exact: true }).click();
await picker.getByLabel('장비 종류', { exact: true }).selectOption('weapon'); await picker.getByLabel('장비 종류', { exact: true }).selectOption('weapon');
await picker.getByLabel('장비', { exact: true }).selectOption('청룡언월도'); const equipment = picker.getByLabel('장비', { exact: true });
await equipment.selectOption('청룡언월도');
const optionLabelBeforeRefresh = await equipment
.locator('option[value="청룡언월도"]')
.textContent();
await equipment.evaluate((element) => {
const select = element as HTMLSelectElement;
const valueDescriptor = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value');
if (!valueDescriptor?.get || !valueDescriptor.set) throw new Error('native select value accessors missing');
const probe = {
node: select,
valueWrites: 0,
mutations: 0,
observer: null as MutationObserver | null,
};
Object.defineProperty(select, 'value', {
configurable: true,
get: () => valueDescriptor.get?.call(select),
set: (value: string) => {
probe.valueWrites += 1;
valueDescriptor.set?.call(select, value);
},
});
probe.observer = new MutationObserver((records) => {
probe.mutations += records.length;
});
probe.observer.observe(select, {
attributes: true,
characterData: true,
childList: true,
subtree: true,
});
select.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerType: 'touch' }));
select.focus();
Object.defineProperty(window, '__nativeCommandSelectProbe', {
configurable: true,
value: probe,
});
});
const focusedGeometryBefore = await equipment.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
color: style.color,
backgroundColor: style.backgroundColor,
fontFamily: style.fontFamily,
fontSize: style.fontSize,
lineHeight: style.lineHeight,
outline: style.outline,
};
});
await refreshActivityAndCommands(); await refreshActivityAndCommands();
await expect(picker.getByLabel('장비 종류', { exact: true })).toHaveValue('weapon'); await expect(picker.getByLabel('장비 종류', { exact: true })).toHaveValue('weapon');
await expect(picker.getByLabel('장비', { exact: true })).toHaveValue('청룡언월도'); await expect(equipment).toHaveValue('청룡언월도');
expect(await equipment.locator('option[value="청룡언월도"]').textContent()).toBe(optionLabelBeforeRefresh);
expect(
await equipment.evaluate((element) => {
const probe = (
window as unknown as {
__nativeCommandSelectProbe: {
node: HTMLSelectElement;
valueWrites: number;
mutations: number;
};
}
).__nativeCommandSelectProbe;
return {
sameNode: probe.node === element,
focused: document.activeElement === element,
valueWrites: probe.valueWrites,
mutations: probe.mutations,
};
})
).toEqual({ sameNode: true, focused: true, valueWrites: 0, mutations: 0 });
expect(
await equipment.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
color: style.color,
backgroundColor: style.backgroundColor,
fontFamily: style.fontFamily,
fontSize: style.fontSize,
lineHeight: style.lineHeight,
outline: style.outline,
};
})
).toEqual(focusedGeometryBefore);
await picker.screenshot({ path: test.info().outputPath(`native-select-refresh-${viewport.name}.png`) });
await picker.getByRole('button', { name: '명령 다시 선택', exact: true }).click();
await picker.getByRole('button', { name: '장비 매매', exact: true }).click();
await picker.getByLabel('장비 종류', { exact: true }).selectOption('weapon');
await expect(picker.getByLabel('장비', { exact: true }).locator('option[value="청룡언월도"]')).toHaveText(
`청룡언월도 갱신 ${refreshIndex}`
);
await expect await expect
.poll(() => page.evaluate(() => document.documentElement.scrollWidth)) .poll(() => page.evaluate(() => document.documentElement.scrollWidth))
.toBeLessThanOrEqual(viewport.width); .toBeLessThanOrEqual(viewport.width);
}); });
} }
test('keeps an Android Chromium native command select untouched while a turn signal refreshes options', async ({
browser,
}) => {
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
screen: { width: 390, height: 844 },
deviceScaleFactor: 2,
hasTouch: true,
isMobile: true,
userAgent:
'Mozilla/5.0 (Linux; Android 15; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Mobile Safari/537.36',
});
try {
const mobilePage = await context.newPage();
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
nationLevel: 3,
stage: 0,
npcMode: 1,
generalMeCalls: 0,
operations: [],
draftCommandTable: true,
reservedTurns: Array.from({ length: 30 }, (_, index) => ({ index, action: '휴식', args: {} })),
};
await installRealtimeHarness(mobilePage);
await installFixture(mobilePage, state);
await waitForMain(mobilePage);
await expect
.poll(() =>
mobilePage.evaluate(
() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime()
)
)
.toBe(true);
await mobilePage.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
const picker = mobilePage.getByTestId('command-picker');
await picker.getByRole('button', { name: '국가', exact: true }).click();
await picker.getByRole('button', { name: '장비 매매', exact: true }).click();
await picker.getByLabel('장비 종류', { exact: true }).selectOption('weapon');
const equipment = picker.getByLabel('장비', { exact: true });
await equipment.selectOption('청룡언월도');
await equipment.evaluate((element) => {
const select = element as HTMLSelectElement;
const valueDescriptor = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value');
if (!valueDescriptor?.get || !valueDescriptor.set) throw new Error('native select value accessors missing');
const probe = { node: select, valueWrites: 0, mutations: 0 };
Object.defineProperty(select, 'value', {
configurable: true,
get: () => valueDescriptor.get?.call(select),
set: (value: string) => {
probe.valueWrites += 1;
valueDescriptor.set?.call(select, value);
},
});
new MutationObserver((records) => {
probe.mutations += records.length;
}).observe(select, { attributes: true, characterData: true, childList: true, subtree: true });
select.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerType: 'touch' }));
select.focus();
Object.defineProperty(window, '__nativeCommandSelectProbe', { configurable: true, value: probe });
});
const callsBefore = state.generalMeCalls;
state.commandTableRevision = 'Z'.repeat(22);
state.commandTableOperations = [
{
op: 'replace',
path: '/inputOptions/items/weapon/1/label',
value: '청룡언월도 최신 조건',
},
];
await emitReadModelInvalidation(
mobilePage,
readModelInvalidation({ commands: true, records: true, frontStatus: true })
);
await expect.poll(() => state.generalMeCalls).toBe(callsBefore + 1);
expect(
await equipment.evaluate((element) => {
const probe = (
window as unknown as {
__nativeCommandSelectProbe: {
node: HTMLSelectElement;
valueWrites: number;
mutations: number;
};
}
).__nativeCommandSelectProbe;
return {
sameNode: probe.node === element,
focused: document.activeElement === element,
value: (element as HTMLSelectElement).value,
option: (element as HTMLSelectElement).selectedOptions[0]?.textContent,
valueWrites: probe.valueWrites,
mutations: probe.mutations,
};
})
).toEqual({
sameNode: true,
focused: true,
value: '청룡언월도',
option: '청룡언월도',
valueWrites: 0,
mutations: 0,
});
await picker.screenshot({ path: test.info().outputPath('native-select-refresh-android-chromium.png') });
expect(
await mobilePage.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
).toBeLessThanOrEqual(1);
await picker.getByRole('button', { name: '명령 다시 선택', exact: true }).click();
await picker.getByRole('button', { name: '장비 매매', exact: true }).click();
await picker.getByLabel('장비 종류', { exact: true }).selectOption('weapon');
await expect(picker.getByLabel('장비', { exact: true }).locator('option[value="청룡언월도"]')).toHaveText(
'청룡언월도 최신 조건'
);
} finally {
await context.close();
}
});
for (const viewport of [ for (const viewport of [
{ name: 'desktop', width: 1200, height: 900 }, { name: 'desktop', width: 1200, height: 900 },
{ name: 'mobile', width: 500, height: 900 }, { name: 'mobile', width: 500, height: 900 },
@@ -68,6 +68,11 @@ const dragKind = ref<'replace' | 'toggle' | null>(null);
const quickTarget = ref<number | null>(null); const quickTarget = ref<number | null>(null);
const pickerOpen = ref(false); const pickerOpen = ref(false);
const selectedCommand = ref<CommandAvailability | null>(null); const selectedCommand = ref<CommandAvailability | null>(null);
const commandInputSnapshot = shallowRef<{
options: CommandTable['inputOptions'];
mapData: CommandMapData | null;
mapLayout: CommandMapLayout | null;
} | null>(null);
const commandArgs = ref<Record<string, unknown>>({}); const commandArgs = ref<Record<string, unknown>>({});
const commandArgsValid = ref(false); const commandArgsValid = ref(false);
const expanded = ref(false); const expanded = ref(false);
@@ -188,6 +193,7 @@ const openPicker = (turnIndex?: number) => {
quickTarget.value = turnIndex ?? null; quickTarget.value = turnIndex ?? null;
pickerOpen.value = true; pickerOpen.value = true;
selectedCommand.value = null; selectedCommand.value = null;
commandInputSnapshot.value = null;
commandArgs.value = {}; commandArgs.value = {};
commandArgsValid.value = false; commandArgsValid.value = false;
}; };
@@ -195,6 +201,7 @@ const closePicker = () => {
pickerOpen.value = false; pickerOpen.value = false;
quickTarget.value = null; quickTarget.value = null;
selectedCommand.value = null; selectedCommand.value = null;
commandInputSnapshot.value = null;
}; };
let previousBodyOverflow: string | null = null; let previousBodyOverflow: string | null = null;
@@ -245,11 +252,21 @@ const togglePicker = (turnIndex?: number) => {
openPicker(turnIndex); openPicker(turnIndex);
}; };
const selectCommand = (commandKey: string) => { const selectCommand = (commandKey: string) => {
const command = props.commandTable?.[props.scope] const table = props.commandTable;
const command = table?.[props.scope]
.flatMap((group) => group.values) .flatMap((group) => group.values)
.find((entry) => entry.key === commandKey); .find((entry) => entry.key === commandKey);
if (!command) return; if (!table || !command) return;
selectedCommand.value = command; selectedCommand.value = command;
// Ref opens argument commands on a separate processing page. Keep the same
// isolation while this inline form is open: patching a focused <select>
// during a realtime refresh can reset an iOS/Android native picker before
// its tentative wheel selection has emitted `change`.
commandInputSnapshot.value = {
options: table.inputOptions,
mapData: props.mapData,
mapLayout: props.mapLayout,
};
commandArgs.value = {}; commandArgs.value = {};
commandArgsValid.value = !command.reqArg; commandArgsValid.value = !command.reqArg;
const needsInformationalConfirmation = commandArgumentPresentation(command.key).mapTarget === 'capital'; const needsInformationalConfirmation = commandArgumentPresentation(command.key).mapTarget === 'capital';
@@ -263,6 +280,10 @@ const submitCommand = () => {
emit('reserve-bulk', [entry]); emit('reserve-bulk', [entry]);
pendingReservation.value = entry; pendingReservation.value = entry;
}; };
const returnToCommandList = () => {
selectedCommand.value = null;
commandInputSnapshot.value = null;
};
const applyPattern = (raw: CommandPatternEntry[] | undefined) => { const applyPattern = (raw: CommandPatternEntry[] | undefined) => {
if (!raw?.length) return; if (!raw?.length) return;
@@ -735,31 +756,31 @@ const clickOutsideMenu = (event: Event) => {
<RecruitmentCommandForm <RecruitmentCommandForm
v-if=" v-if="
isRecruitmentCommand && isRecruitmentCommand &&
props.commandTable?.inputOptions.recruitment && commandInputSnapshot?.options.recruitment &&
(selectedCommand.key === 'che_징병' || selectedCommand.key === 'che_모병') (selectedCommand.key === 'che_징병' || selectedCommand.key === 'che_모병')
" "
:command-key="selectedCommand.key" :command-key="selectedCommand.key"
:info="props.commandTable.inputOptions.recruitment" :info="commandInputSnapshot.options.recruitment"
@update:args="commandArgs = $event" @update:args="commandArgs = $event"
@update:valid="commandArgsValid = $event" @update:valid="commandArgsValid = $event"
@submit="submitCommand" @submit="submitCommand"
/> />
<CommandArgumentForm <CommandArgumentForm
v-else-if=" v-else-if="
props.commandTable && commandInputSnapshot &&
(selectedCommand.reqArg || (selectedCommand.reqArg ||
commandArgumentPresentation(selectedCommand.key).mapTarget === 'capital') commandArgumentPresentation(selectedCommand.key).mapTarget === 'capital')
" "
:command-key="selectedCommand.key" :command-key="selectedCommand.key"
:fields="selectedCommand.inputFields" :fields="selectedCommand.inputFields"
:options="props.commandTable.inputOptions" :options="commandInputSnapshot.options"
:map-data="props.mapData" :map-data="commandInputSnapshot.mapData"
:map-layout="props.mapLayout" :map-layout="commandInputSnapshot.mapLayout"
@update:args="commandArgs = $event" @update:args="commandArgs = $event"
@update:valid="commandArgsValid = $event" @update:valid="commandArgsValid = $event"
/> />
<div class="picker-actions"> <div class="picker-actions">
<button :disabled="Boolean(pendingReservation)" @click="selectedCommand = null"> <button :disabled="Boolean(pendingReservation)" @click="returnToCommandList">
명령 다시 선택</button 명령 다시 선택</button
><button :disabled="!commandArgsValid || Boolean(pendingReservation)" @click="submitCommand"> ><button :disabled="!commandArgsValid || Boolean(pendingReservation)" @click="submitCommand">
{{ pendingReservation ? '저장 ' : '입력' }} {{ pendingReservation ? '저장 ' : '입력' }}