diff --git a/app/game-api/test/commandTable.test.ts b/app/game-api/test/commandTable.test.ts index bd957195..5c4de26c 100644 --- a/app/game-api/test/commandTable.test.ts +++ b/app/game-api/test/commandTable.test.ts @@ -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({ diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 0e0aa0ba..9f78ed3f 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -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 }, diff --git a/app/game-frontend/src/components/command/ReservedCommandEditor.vue b/app/game-frontend/src/components/command/ReservedCommandEditor.vue index b7ff6956..b0baa071 100644 --- a/app/game-frontend/src/components/command/ReservedCommandEditor.vue +++ b/app/game-frontend/src/components/command/ReservedCommandEditor.vue @@ -68,6 +68,11 @@ const dragKind = ref<'replace' | 'toggle' | null>(null); const quickTarget = ref(null); const pickerOpen = ref(false); const selectedCommand = ref(null); +const commandInputSnapshot = shallowRef<{ + options: CommandTable['inputOptions']; + mapData: CommandMapData | null; + mapLayout: CommandMapLayout | null; +} | null>(null); const commandArgs = ref>({}); 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