merge: 최신 main을 부대편성 장수 패널 재사용에 통합한다
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 () => {
|
||||
const buildTable = (general: GeneralRow, nation: NationRow | null = buildNation()) =>
|
||||
buildTurnCommandTable({
|
||||
|
||||
@@ -3909,6 +3909,11 @@ for (const viewport of [
|
||||
path: '/inputOptions/context/actorGold',
|
||||
value: 10_000 + refreshIndex,
|
||||
},
|
||||
{
|
||||
op: 'replace',
|
||||
path: '/inputOptions/items/weapon/1/label',
|
||||
value: `청룡언월도 갱신 ${refreshIndex}`,
|
||||
},
|
||||
];
|
||||
await emitReadModelInvalidation(
|
||||
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.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 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
|
||||
.poll(() => page.evaluate(() => document.documentElement.scrollWidth))
|
||||
.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 [
|
||||
{ name: 'desktop', width: 1200, 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 pickerOpen = ref(false);
|
||||
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 commandArgsValid = ref(false);
|
||||
const expanded = ref(false);
|
||||
@@ -188,6 +193,7 @@ const openPicker = (turnIndex?: number) => {
|
||||
quickTarget.value = turnIndex ?? null;
|
||||
pickerOpen.value = true;
|
||||
selectedCommand.value = null;
|
||||
commandInputSnapshot.value = null;
|
||||
commandArgs.value = {};
|
||||
commandArgsValid.value = false;
|
||||
};
|
||||
@@ -195,6 +201,7 @@ const closePicker = () => {
|
||||
pickerOpen.value = false;
|
||||
quickTarget.value = null;
|
||||
selectedCommand.value = null;
|
||||
commandInputSnapshot.value = null;
|
||||
};
|
||||
|
||||
let previousBodyOverflow: string | null = null;
|
||||
@@ -245,11 +252,21 @@ const togglePicker = (turnIndex?: number) => {
|
||||
openPicker(turnIndex);
|
||||
};
|
||||
const selectCommand = (commandKey: string) => {
|
||||
const command = props.commandTable?.[props.scope]
|
||||
const table = props.commandTable;
|
||||
const command = table?.[props.scope]
|
||||
.flatMap((group) => group.values)
|
||||
.find((entry) => entry.key === commandKey);
|
||||
if (!command) return;
|
||||
if (!table || !command) return;
|
||||
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 = {};
|
||||
commandArgsValid.value = !command.reqArg;
|
||||
const needsInformationalConfirmation = commandArgumentPresentation(command.key).mapTarget === 'capital';
|
||||
@@ -263,6 +280,10 @@ const submitCommand = () => {
|
||||
emit('reserve-bulk', [entry]);
|
||||
pendingReservation.value = entry;
|
||||
};
|
||||
const returnToCommandList = () => {
|
||||
selectedCommand.value = null;
|
||||
commandInputSnapshot.value = null;
|
||||
};
|
||||
|
||||
const applyPattern = (raw: CommandPatternEntry[] | undefined) => {
|
||||
if (!raw?.length) return;
|
||||
@@ -735,31 +756,31 @@ const clickOutsideMenu = (event: Event) => {
|
||||
<RecruitmentCommandForm
|
||||
v-if="
|
||||
isRecruitmentCommand &&
|
||||
props.commandTable?.inputOptions.recruitment &&
|
||||
commandInputSnapshot?.options.recruitment &&
|
||||
(selectedCommand.key === 'che_징병' || selectedCommand.key === 'che_모병')
|
||||
"
|
||||
:command-key="selectedCommand.key"
|
||||
:info="props.commandTable.inputOptions.recruitment"
|
||||
:info="commandInputSnapshot.options.recruitment"
|
||||
@update:args="commandArgs = $event"
|
||||
@update:valid="commandArgsValid = $event"
|
||||
@submit="submitCommand"
|
||||
/>
|
||||
<CommandArgumentForm
|
||||
v-else-if="
|
||||
props.commandTable &&
|
||||
commandInputSnapshot &&
|
||||
(selectedCommand.reqArg ||
|
||||
commandArgumentPresentation(selectedCommand.key).mapTarget === 'capital')
|
||||
"
|
||||
:command-key="selectedCommand.key"
|
||||
:fields="selectedCommand.inputFields"
|
||||
:options="props.commandTable.inputOptions"
|
||||
:map-data="props.mapData"
|
||||
:map-layout="props.mapLayout"
|
||||
:options="commandInputSnapshot.options"
|
||||
:map-data="commandInputSnapshot.mapData"
|
||||
:map-layout="commandInputSnapshot.mapLayout"
|
||||
@update:args="commandArgs = $event"
|
||||
@update:valid="commandArgsValid = $event"
|
||||
/>
|
||||
<div class="picker-actions">
|
||||
<button :disabled="Boolean(pendingReservation)" @click="selectedCommand = null">
|
||||
<button :disabled="Boolean(pendingReservation)" @click="returnToCommandList">
|
||||
명령 다시 선택</button
|
||||
><button :disabled="!commandArgsValid || Boolean(pendingReservation)" @click="submitCommand">
|
||||
{{ pendingReservation ? '저장 중' : '입력' }}
|
||||
|
||||
Reference in New Issue
Block a user