From f01661bc08dd64e9831da86d54717e45c908df62 Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 19 Aug 2026 13:28:11 +0000 Subject: [PATCH] =?UTF-8?q?fix(game-ui):=20=EC=9E=90=EB=8F=99=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=20=EC=A4=91=20=EC=BB=A4=EB=A7=A8=EB=93=9C=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=EA=B0=92=20=EB=B3=B4=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 같은 명령의 field 계약과 유효한 select option이 유지되는 동안 사용자가 편집한 값을 보존한다. 장수 동향과 명령표 realtime 갱신을 함께 발생시키는 desktop/mobile Chromium 회귀 테스트를 추가한다. --- app/game-frontend/e2e/mainNavigation.spec.ts | 240 +++++++++++++++++- .../command/commandArgumentDraft.ts | 22 ++ .../components/main/CommandArgumentForm.vue | 49 +++- .../test/commandArgumentDraft.test.ts | 81 ++++++ 4 files changed, 380 insertions(+), 12 deletions(-) create mode 100644 app/game-frontend/src/components/command/commandArgumentDraft.ts create mode 100644 app/game-frontend/test/commandArgumentDraft.test.ts diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 4387beb4..b3dc3348 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -47,6 +47,7 @@ type NavigationFixture = { refreshDelayMs?: number; accessLimitAfterCalls?: number; largeCommandTable?: boolean; + draftCommandTable?: boolean; refCommandCategories?: boolean; currentYear?: number; currentMonth?: number; @@ -154,8 +155,104 @@ const refCommandCategoryFixture = ['개인', '내정', '군사', '인사', '계 ], })); -const commandTableFixture = (large: boolean, blockedCount = 0, refCategories = false) => ({ - general: refCategories +const draftCommandGroups = [ + { + category: '국가', + values: [ + { + key: 'che_건국', + name: '건국', + reqArg: true, + possible: true, + status: 'needsInput' as const, + inputFields: [ + { key: 'nationName', label: '국가명', kind: 'text' as const, required: true, min: 1, max: 18 }, + { + key: 'nationType', + label: '국가 성향', + kind: 'select' as const, + required: true, + optionSource: 'nationTypes' as const, + }, + { + key: 'colorType', + label: '국기 색상', + kind: 'select' as const, + required: true, + optionSource: 'colors' as const, + }, + ], + }, + { + key: 'che_물자원조', + name: '물자 원조', + reqArg: true, + possible: true, + status: 'needsInput' as const, + inputFields: [ + { + key: 'destNationId', + label: '대상 국가', + kind: 'select' as const, + required: true, + optionSource: 'nations' as const, + }, + { + key: 'amountList', + label: '지원 물자', + kind: 'numberTuple' as const, + required: true, + min: 0, + step: 1, + tupleLabels: ['금', '쌀'], + }, + ], + }, + { + key: 'che_군량매매', + name: '군량 매매', + reqArg: true, + possible: true, + status: 'needsInput' as const, + inputFields: [ + { key: 'buyRice', label: '거래', kind: 'boolean' as const, required: true }, + { key: 'amount', label: '수량', kind: 'number' as const, required: true, min: 100, step: 100 }, + ], + }, + { + key: 'che_장비매매', + name: '장비 매매', + reqArg: true, + possible: true, + status: 'needsInput' as const, + inputFields: [ + { + key: 'itemType', + label: '장비 종류', + kind: 'select' as const, + required: true, + options: [ + { value: 'horse', label: '명마' }, + { value: 'weapon', label: '무기' }, + ], + }, + { + key: 'itemCode', + label: '장비', + kind: 'select' as const, + required: true, + optionSource: 'items' as const, + }, + ], + }, + ], + }, +]; + +const commandTableFixture = (large: boolean, blockedCount = 0, refCategories = false, draftCommands = false) => ({ + general: draftCommands + ? draftCommandGroups + : refCategories ? refCommandCategoryFixture : large ? ['내정', '군사', '계략'].map((category, categoryIndex) => ({ @@ -185,13 +282,43 @@ const commandTableFixture = (large: boolean, blockedCount = 0, refCategories = f nation: [], inputOptions: { cities: Array.from({ length: 20 }, (_, index) => ({ value: index + 1, label: `도시 ${index + 1}` })), - nations: [], + nations: draftCommands + ? [ + { value: 1, label: '아국', color: '#008000' }, + { value: 2, label: '적국', color: '#800000' }, + ] + : [], generals: [], crewTypes: [], armTypes: [], - nationTypes: [], - colors: [], - items: {}, + nationTypes: draftCommands ? [{ value: 'che_도적', label: '도적' }] : [], + colors: draftCommands + ? [ + { value: 0, label: '색상 1', color: '#FF0000' }, + { value: 15, label: '색상 16', color: '#6495ED' }, + ] + : [], + items: draftCommands + ? { + horse: [ + { value: 'None', label: '없음' }, + { value: '적토마', label: '적토마' }, + ], + weapon: [ + { value: 'None', label: '없음' }, + { value: '청룡언월도', label: '청룡언월도' }, + ], + } + : {}, + context: draftCommands + ? { + actorGold: 10_000, + actorRice: 20_000, + nationGold: 30_000, + nationRice: 40_000, + nationLevel: 3, + } + : undefined, }, }); @@ -455,7 +582,8 @@ const installFixture = async (page: Page, state: NavigationFixture) => { data: commandTableFixture( state.largeCommandTable === true, state.commandBlockedCount, - state.refCommandCategories === true + state.refCommandCategories === true, + state.draftCommandTable === true ), } : input.known.commandTable === currentCommandTableRevision @@ -2776,6 +2904,104 @@ test('mobile single document refreshes once and preserves tokens on lobby return expect(state.operations).not.toContain('auth.logout'); }); +for (const viewport of [ + { name: 'desktop', width: 1200, height: 900 }, + { name: 'mobile', width: 500, height: 900 }, +] as const) { + test(`preserves every command argument draft during realtime activity refreshes on ${viewport.name}`, async ({ + page, + }) => { + test.setTimeout(60_000); + 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(page); + await installFixture(page, state); + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + await waitForMain(page); + await expect + .poll(() => + page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime()) + ) + .toBe(true); + + await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + const picker = page.getByTestId('command-picker'); + await picker.getByRole('button', { name: '국가', exact: true }).click(); + + let refreshIndex = 0; + const refreshActivityAndCommands = async () => { + const callsBefore = state.generalMeCalls; + const operationsBefore = state.operations.length; + refreshIndex += 1; + state.commandTableRevision = String.fromCharCode(80 + refreshIndex).repeat(22); + state.commandTableOperations = [ + { + op: 'replace', + path: '/inputOptions/context/actorGold', + value: 10_000 + refreshIndex, + }, + ]; + await emitReadModelInvalidation( + page, + readModelInvalidation({ commands: true, records: true, frontStatus: true }) + ); + await expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBefore + 1); + await expect + .poll(() => state.operations.slice(operationsBefore).sort(), { timeout: 4_000 }) + .toEqual( + ['dashboard.getContextBundleDelta', 'general.getFrontStatus', 'general.getRecentRecords'].sort() + ); + }; + + await picker.getByRole('button', { name: '건국', exact: true }).click(); + await picker.getByLabel('국가명').fill('초안보존국'); + await picker.getByLabel('국기 색상').selectOption('15'); + await refreshActivityAndCommands(); + await expect(picker.getByLabel('국가명')).toHaveValue('초안보존국'); + await expect(picker.getByLabel('국기 색상')).toHaveValue('15'); + await expect(picker.getByLabel('국기 색상')).toHaveCSS('background-color', 'rgb(100, 149, 237)'); + await picker.screenshot({ path: test.info().outputPath(`command-draft-${viewport.name}.png`) }); + + await picker.getByRole('button', { name: '명령 다시 선택', exact: true }).click(); + await picker.getByRole('button', { name: '물자 원조', exact: true }).click(); + await picker.getByLabel('대상 국가').selectOption('2'); + await picker.getByLabel('금').fill('111'); + await picker.getByLabel('쌀').fill('222'); + await refreshActivityAndCommands(); + await expect(picker.getByLabel('대상 국가')).toHaveValue('2'); + await expect(picker.getByLabel('금')).toHaveValue('111'); + await expect(picker.getByLabel('쌀')).toHaveValue('222'); + + 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('수량').fill('700'); + await refreshActivityAndCommands(); + await expect(picker.getByRole('button', { name: '쌀 판매', exact: true })).toHaveClass(/selected/u); + await expect(picker.getByLabel('수량')).toHaveValue('700'); + + 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('청룡언월도'); + await refreshActivityAndCommands(); + await expect(picker.getByLabel('장비 종류', { exact: true })).toHaveValue('weapon'); + await expect(picker.getByLabel('장비', { exact: true })).toHaveValue('청룡언월도'); + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual( + viewport.width + ); + }); +} + test('realtime read-model events skip clock-only work, merge bursts, patch in place, and stop off-route', async ({ page, }) => { diff --git a/app/game-frontend/src/components/command/commandArgumentDraft.ts b/app/game-frontend/src/components/command/commandArgumentDraft.ts new file mode 100644 index 00000000..fc51b401 --- /dev/null +++ b/app/game-frontend/src/components/command/commandArgumentDraft.ts @@ -0,0 +1,22 @@ +import type { CommandInputField, CommandOption } from './types'; + +export type CommandArgumentFieldContract = Pick; + +export const commandArgumentFieldContract = (field: CommandInputField): CommandArgumentFieldContract => ({ + kind: field.kind, + optionSource: field.optionSource, +}); + +export const shouldPreserveCommandArgumentValue = ( + field: CommandInputField, + previousField: CommandArgumentFieldContract | undefined, + values: Readonly>, + options: readonly CommandOption[] +): boolean => { + if (field.kind === 'hidden' || !Object.prototype.hasOwnProperty.call(values, field.key)) return false; + if (!previousField || previousField.kind !== field.kind || previousField.optionSource !== field.optionSource) { + return false; + } + if (field.kind !== 'select') return true; + return options.some((option) => option.value === values[field.key]); +}; diff --git a/app/game-frontend/src/components/main/CommandArgumentForm.vue b/app/game-frontend/src/components/main/CommandArgumentForm.vue index 9000dfde..c6b7fc67 100644 --- a/app/game-frontend/src/components/main/CommandArgumentForm.vue +++ b/app/game-frontend/src/components/main/CommandArgumentForm.vue @@ -2,6 +2,11 @@ import { computed, reactive, watch, type CSSProperties } from 'vue'; import MapViewer from './MapViewer.vue'; import { commandArgumentPresentation } from '../command/commandArgumentPresentation'; +import { + commandArgumentFieldContract, + shouldPreserveCommandArgumentValue, + type CommandArgumentFieldContract, +} from '../command/commandArgumentDraft'; import { legacyNationTextColor } from '../../utils/legacyNationColor'; import type { CommandInputContext, @@ -28,6 +33,8 @@ const emit = defineEmits<{ }>(); const values = reactive>({}); +const previousFieldContracts = new Map(); +let initializedCommandKey: string | null = null; const presentation = computed(() => commandArgumentPresentation(props.commandKey)); const visibleFields = computed(() => props.fields.filter((entry) => entry.kind !== 'hidden')); @@ -87,11 +94,39 @@ const defaultValue = (field: CommandInputField): unknown => { return ''; }; -const initialize = () => { - for (const key of Object.keys(values)) delete values[key]; - for (const field of props.fields) values[field.key] = defaultValue(field); +const synchronizeValues = () => { + const commandChanged = initializedCommandKey !== props.commandKey; + initializedCommandKey = props.commandKey; + const activeKeys = new Set(props.fields.map((field) => field.key)); + for (const key of Object.keys(values)) { + if (!activeKeys.has(key)) delete values[key]; + } + for (const field of props.fields) { + const preserve = + !commandChanged && + shouldPreserveCommandArgumentValue( + field, + previousFieldContracts.get(field.key), + values, + optionsFor(field) + ); + if (!preserve) values[field.key] = defaultValue(field); + } const itemCodeField = props.fields.find((field) => field.key === 'itemCode'); - if (itemCodeField) values.itemCode = defaultValue(itemCodeField); + if ( + itemCodeField && + (commandChanged || + !shouldPreserveCommandArgumentValue( + itemCodeField, + previousFieldContracts.get(itemCodeField.key), + values, + optionsFor(itemCodeField) + )) + ) { + values.itemCode = defaultValue(itemCodeField); + } + previousFieldContracts.clear(); + for (const field of props.fields) previousFieldContracts.set(field.key, commandArgumentFieldContract(field)); }; const setSelectValue = (field: CommandInputField, rawValue: string) => { @@ -352,7 +387,11 @@ const isValid = computed(() => }) ); -watch(() => [props.commandKey, props.fields, props.options] as const, initialize, { immediate: true, deep: true }); +watch( + () => [props.commandKey, props.fields, props.options, props.mapData?.myCity, props.mapData?.myNation] as const, + synchronizeValues, + { immediate: true, deep: true } +); watch( () => ({ ...values }), () => { diff --git a/app/game-frontend/test/commandArgumentDraft.test.ts b/app/game-frontend/test/commandArgumentDraft.test.ts new file mode 100644 index 00000000..74153074 --- /dev/null +++ b/app/game-frontend/test/commandArgumentDraft.test.ts @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + commandArgumentFieldContract, + shouldPreserveCommandArgumentValue, +} from '../src/components/command/commandArgumentDraft.ts'; +import type { CommandInputField, CommandOption } from '../src/components/command/types.ts'; + +const field = (kind: CommandInputField['kind'], optionSource?: CommandInputField['optionSource']): CommandInputField => ({ + key: 'value', + label: '값', + kind, + required: true, + optionSource, +}); + +void test('preserves every user-editable non-select command argument across data refreshes', () => { + for (const [kind, value] of [ + ['text', '작성 중인 국명'], + ['number', 777], + ['boolean', false], + ['numberTuple', [111, 222]], + ] as const) { + const currentField = field(kind); + assert.equal( + shouldPreserveCommandArgumentValue( + currentField, + commandArgumentFieldContract(currentField), + { value }, + [] + ), + true, + kind + ); + } +}); + +void test('preserves a select only while its field contract and selected option remain available', () => { + const currentField = field('select', 'cities'); + const options: CommandOption[] = [ + { value: 1, label: '업' }, + { value: 2, label: '허창' }, + ]; + assert.equal( + shouldPreserveCommandArgumentValue( + currentField, + commandArgumentFieldContract(currentField), + { value: 2 }, + options + ), + true + ); + assert.equal( + shouldPreserveCommandArgumentValue( + currentField, + commandArgumentFieldContract(currentField), + { value: 3 }, + options + ), + false + ); + assert.equal( + shouldPreserveCommandArgumentValue(currentField, { kind: 'select', optionSource: 'nations' }, { value: 2 }, options), + false + ); +}); + +void test('reinitializes hidden, new, and changed-kind fields', () => { + const hidden = field('hidden'); + assert.equal( + shouldPreserveCommandArgumentValue(hidden, commandArgumentFieldContract(hidden), { value: 'old' }, []), + false + ); + const text = field('text'); + assert.equal(shouldPreserveCommandArgumentValue(text, undefined, { value: 'old' }, []), false); + assert.equal(shouldPreserveCommandArgumentValue(text, { kind: 'number' }, { value: 'old' }, []), false); + assert.equal( + shouldPreserveCommandArgumentValue(text, commandArgumentFieldContract(text), {}, []), + false + ); +});