fix(game-ui): 모바일 턴 입력의 native select 갱신을 격리한다
This commit is contained in:
@@ -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({
|
||||||
|
|||||||
@@ -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 ? '저장 중' : '입력' }}
|
||||||
|
|||||||
Reference in New Issue
Block a user