fix(game-ui): 자동 갱신 중 커맨드 입력값 보존

같은 명령의 field 계약과 유효한 select option이 유지되는 동안 사용자가 편집한 값을 보존한다. 장수 동향과 명령표 realtime 갱신을 함께 발생시키는 desktop/mobile Chromium 회귀 테스트를 추가한다.
This commit is contained in:
2026-08-19 13:28:11 +00:00
parent 54a5b91ca9
commit f01661bc08
4 changed files with 380 additions and 12 deletions
+233 -7
View File
@@ -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,
}) => {
@@ -0,0 +1,22 @@
import type { CommandInputField, CommandOption } from './types';
export type CommandArgumentFieldContract = Pick<CommandInputField, 'kind' | 'optionSource'>;
export const commandArgumentFieldContract = (field: CommandInputField): CommandArgumentFieldContract => ({
kind: field.kind,
optionSource: field.optionSource,
});
export const shouldPreserveCommandArgumentValue = (
field: CommandInputField,
previousField: CommandArgumentFieldContract | undefined,
values: Readonly<Record<string, unknown>>,
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]);
};
@@ -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<Record<string, unknown>>({});
const previousFieldContracts = new Map<string, CommandArgumentFieldContract>();
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 }),
() => {
@@ -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
);
});