merge: 최신 main 변경을 계략 명령 브랜치에 통합

# Conflicts:
#	app/game-api/test/commandTable.test.ts
#	app/game-frontend/e2e/commandArguments.spec.ts
This commit is contained in:
2026-08-15 19:13:17 +00:00
44 changed files with 1408 additions and 285 deletions
+114 -3
View File
@@ -314,7 +314,7 @@ const chiefCenter = {
})),
};
const install = async (page: Page, rejectGeneral = false) => {
const install = async (page: Page, rejectGeneral = false, commandTableResponse: unknown = commandTable) => {
const requests: unknown[] = [];
const generalTurns = turns(30);
const nationTurns = turns(12);
@@ -364,7 +364,7 @@ const install = async (page: Page, rejectGeneral = false) => {
? {
kind: 'snapshot',
revision: 'BBBBBBBBBBBBBBBBBBBBBB',
data: commandTable,
data: commandTableResponse,
}
: { kind: 'unchanged', revision: 'BBBBBBBBBBBBBBBBBBBBBB' },
boardAccess: initial
@@ -420,7 +420,7 @@ const install = async (page: Page, rejectGeneral = false) => {
myCity: 1,
myNation: 1,
});
if (name === 'turns.getCommandTable') return response(commandTable);
if (name === 'turns.getCommandTable') return response(commandTableResponse);
if (name === 'nation.getChiefCenter') return response(chiefCenter);
if (name === 'turns.reserved.getGeneral')
return response({ turns: generalTurns, revision: generalRevision });
@@ -507,6 +507,117 @@ test('renders and accepts every Ref strategy command at mobile width', async ({
await picker.screenshot({ path: test.info().outputPath('all-strategy-commands-mobile.png') });
});
test('reserves force move, retirement, and resignation from the user command picker', async ({ page }) => {
const specialCommandTable = {
general: [
{
category: '개인',
values: [
{
key: 'che_은퇴',
name: '은퇴',
reqArg: false,
possible: false,
status: 'blocked',
reason: '나이가 60세 이상이어야 합니다.',
inputFields: [],
},
],
},
{
category: '인사',
values: [
{
key: 'che_강행',
name: '강행',
reqArg: true,
possible: true,
status: 'available',
inputFields: [
{
key: 'destCityId',
label: '대상 도시',
kind: 'select',
required: true,
optionSource: 'cities',
},
],
},
],
},
{
category: '국가',
values: [
{
key: 'che_하야',
name: '하야',
reqArg: false,
possible: true,
status: 'available',
inputFields: [],
},
],
},
],
nation: [],
inputOptions,
};
const requests = await install(page, false, specialCommandTable);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('/');
const editor = page.locator('[data-command-scope="general"]');
await editor.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
let picker = page.getByTestId('command-picker');
const retirement = picker.getByRole('button', { name: '은퇴', exact: true });
await expect(retirement).toHaveClass(/blocked/);
await expect(retirement).toHaveAttribute('title', '나이가 60세 이상이어야 합니다.');
await retirement.hover();
await retirement.focus();
await expect(retirement).toBeFocused();
await picker.screenshot({ path: test.info().outputPath('special-user-commands-desktop-1200.png') });
await retirement.click();
await expect(editor.locator('.action-column > div').nth(0)).toHaveText('은퇴');
await editor.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: '국가', exact: true }).click();
await picker.getByRole('button', { name: '하야', exact: true }).click();
await expect(editor.locator('.action-column > div').nth(1)).toHaveText('하야');
await editor.getByRole('button', { name: '3턴 명령 입력', exact: true }).click();
picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: '인사', exact: true }).click();
await picker.getByRole('button', { name: '강행', exact: true }).click();
const forceMoveForm = picker.getByTestId('command-argument-form');
await expect(forceMoveForm.getByTestId('command-argument-guidance')).toContainText('선택한 도시로 강행합니다.');
await forceMoveForm.locator('select').selectOption('2');
await picker.getByRole('button', { name: '입력', exact: true }).click();
await expect(editor.locator('.action-column > div').nth(2)).toHaveText('강행');
const serialized = JSON.stringify(requests);
expect(serialized).toContain('"action":"che_은퇴","args":{}');
expect(serialized).toContain('"action":"che_하야","args":{}');
expect(serialized).toContain('"action":"che_강행","args":{"destCityId":2}');
await page.setViewportSize({ width: 500, height: 900 });
await editor.getByRole('button', { name: '4턴 명령 입력', exact: true }).click();
picker = page.getByTestId('command-picker');
await expect(picker.locator('.category-btn')).toHaveText(['개인', '인사', '국가']);
const mobileGeometry = await picker.evaluate((element) => ({
width: element.getBoundingClientRect().width,
horizontalOverflow: element.scrollWidth - element.clientWidth,
categoryColumns: getComputedStyle(element.querySelector<HTMLElement>('.category-list')!).gridTemplateColumns,
}));
expect(mobileGeometry.width).toBeLessThanOrEqual(500);
expect(mobileGeometry.horizontalOverflow).toBeLessThanOrEqual(0);
expect(mobileGeometry.categoryColumns.split(' ')).toHaveLength(3);
await picker.getByRole('button', { name: '개인', exact: true }).click();
await expect(picker.getByRole('button', { name: '은퇴', exact: true })).toBeVisible();
await picker.screenshot({ path: test.info().outputPath('special-user-commands-mobile-500.png') });
});
test('enters general and nation command arguments and sends exact values', async ({ page }) => {
const requests = await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
+52 -7
View File
@@ -179,26 +179,54 @@ test('prioritizes core general fields and keeps context and inheritance progress
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
top: rect.top,
height: rect.height,
backgroundColor: style.backgroundColor,
color: style.color,
borderTopWidth: style.borderTopWidth,
borderRightWidth: style.borderRightWidth,
borderBottomWidth: style.borderBottomWidth,
borderBottomColor: style.borderBottomColor,
marginTop: style.marginTop,
fontSize: style.fontSize,
fontWeight: style.fontWeight,
cursor: style.cursor,
};
});
expect(defaultButtonStyle).toEqual({
expect(defaultButtonStyle).toMatchObject({
height: 40,
backgroundColor: 'rgb(0, 88, 44)',
color: 'rgb(255, 255, 255)',
borderTopWidth: '0px',
borderRightWidth: '1px',
borderBottomWidth: '4px',
borderBottomColor: 'rgb(0, 79, 40)',
marginTop: '0px',
fontSize: '14px',
fontWeight: '700',
cursor: 'pointer',
});
await randomButton.hover();
await expect
.poll(() => randomButton.evaluate((element) => getComputedStyle(element).backgroundColor))
.toBe('rgb(0, 109, 55)');
const hoverButtonStyle = await randomButton.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
top: rect.top,
height: rect.height,
backgroundColor: style.backgroundColor,
borderBottomWidth: style.borderBottomWidth,
borderBottomColor: style.borderBottomColor,
marginTop: style.marginTop,
};
});
expect(hoverButtonStyle).toMatchObject({
top: defaultButtonStyle.top + 1,
height: 39,
backgroundColor: 'rgb(0, 88, 44)',
borderBottomWidth: '3px',
borderBottomColor: 'rgb(0, 79, 40)',
marginTop: '1px',
});
await page.screenshot({ path: testInfo.outputPath('join-stat-actions-hover-desktop.png'), fullPage: true });
await randomButton.focus();
await expect(randomButton).toBeFocused();
@@ -210,9 +238,26 @@ test('prioritizes core general fields and keeps context and inheritance progress
randomButtonBox!.y + randomButtonBox!.height / 2
);
await page.mouse.down();
await expect
.poll(() => randomButton.evaluate((element) => getComputedStyle(element).backgroundColor))
.toBe('rgb(0, 69, 35)');
const activeButtonStyle = await randomButton.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
top: rect.top,
height: rect.height,
backgroundColor: style.backgroundColor,
borderBottomWidth: style.borderBottomWidth,
borderBottomColor: style.borderBottomColor,
marginTop: style.marginTop,
};
});
expect(activeButtonStyle).toMatchObject({
top: defaultButtonStyle.top + 2,
height: 38,
backgroundColor: 'rgb(0, 88, 44)',
borderBottomWidth: '2px',
borderBottomColor: 'rgb(0, 79, 40)',
marginTop: '2px',
});
await page.screenshot({ path: testInfo.outputPath('join-stat-actions-active-desktop.png'), fullPage: true });
await page.mouse.up();
const setRandomValues = async (values: number[]) => {
+104 -14
View File
@@ -3,6 +3,13 @@ import { resolve } from 'node:path';
import { expect, test, type Page, type Route } from '@playwright/test';
const response = (data: unknown) => ({ result: { data } });
const errorResponse = (path: string, message: string) => ({
error: {
message,
code: -32029,
data: { code: 'TOO_MANY_REQUESTS', httpStatus: 429, path },
},
});
const artifactRoot = process.env.MAIN_NAVIGATION_ARTIFACT_DIR;
const autoRefreshArtifactRoot = process.env.AUTO_REFRESH_ARTIFACT_DIR;
const productionBundle = process.env.PLAYWRIGHT_FRONTEND_MODE === 'production';
@@ -21,6 +28,8 @@ type NavigationFixture = {
operations: string[];
generalName?: string;
generalTurnTime?: string;
serverTime?: string;
clockMode?: 'realtime' | 'manual';
cityDefence?: number;
cityState?: number;
nationRate?: number;
@@ -31,6 +40,7 @@ type NavigationFixture = {
commandBlockedCount?: number;
forceSnapshotCalls?: number;
refreshDelayMs?: number;
accessLimitAfterCalls?: number;
largeCommandTable?: boolean;
refCommandCategories?: boolean;
currentYear?: number;
@@ -363,11 +373,21 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
year: state.currentYear ?? 185,
month: state.currentMonth ?? 1,
turnTerm: 10,
serverTime: state.serverTime ?? '2026-08-13T00:00:00.000Z',
clockMode: state.clockMode ?? 'realtime',
scenarioTitle: state.scenarioTitle ?? '',
});
}
if (operation === 'dashboard.getContextBundleDelta') {
state.generalMeCalls += 1;
if (state.accessLimitAfterCalls !== undefined && state.generalMeCalls > state.accessLimitAfterCalls) {
return errorResponse(
operation,
'접속 제한중입니다. 1턴 이내에 너무 많은 갱신을 하셨습니다. ' +
'(다음 접속 가능 시각: 2026-08-15 12:34:56) ' +
'자신의 턴이 되면 다시 접속 가능합니다. 잠시 쉬어보세요.'
);
}
const input = operationInput(route, index);
const include = input.include ?? {};
const forceSnapshot = input.forceSnapshot === true;
@@ -499,7 +519,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
operations.forEach((operation, index) => {
if (operation !== 'dashboard.getContextBundleDelta') return;
const item = results[index];
if (!item) return;
if (!item || !('result' in item)) return;
const data = item.result.data as {
context?: { kind: string };
commandTable?: { kind: string };
@@ -787,7 +807,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
});
test('main general card and command clock render the next turn with second precision', async ({ page }) => {
test('main general card uses local turn time and command clock tracks corrected server time', async ({ page }) => {
const state: NavigationFixture = {
officerLevel: 0,
permission: 0,
@@ -797,22 +817,29 @@ test('main general card and command clock render the next turn with second preci
generalMeCalls: 0,
operations: [],
generalName: 'Administrator',
generalTurnTime: '2026-08-13T00:07:06.713Z',
generalTurnTime: '2026-08-13T00:09:10.713Z',
serverTime: '2026-08-13T00:07:06.250Z',
clockMode: 'realtime',
currentYear: 179,
currentMonth: 8,
};
await installFixture(page, state);
await page.clock.install({ time: new Date('2026-08-13T00:00:00.000Z') });
const cdp = await page.context().newCDPSession(page);
await cdp.send('Emulation.setTimezoneOverride', { timezoneId: 'Asia/Seoul' });
await page.setViewportSize({ width: 1200, height: 900 });
await waitForMain(page);
const title = page.locator('[data-main-target="general"] .general-title').first();
await expect(title).toContainText('Administrator');
await expect(title).toContainText('용장');
await expect(title).toContainText('09:07:06');
await expect(title).not.toContainText('00:07');
await expect(title).toContainText('09:09:10');
await expect(title).not.toContainText('00:09');
const commandClock = page.locator('[data-main-target="commands"] [data-command-current-time]').first();
await expect(commandClock).toHaveText('09:07:06');
await expect(commandClock).not.toHaveText('00:07');
await page.clock.runFor(1_000);
await expect(commandClock).toHaveText('09:07:07');
const generalCard = page.locator('[data-main-target="general"] [data-general-basic-card]').first();
await expect(generalCard).toContainText('수비 함(훈사80)');
await expect(generalCard).toContainText('5 턴');
@@ -857,9 +884,9 @@ test('main general card and command clock render the next turn with second preci
const target = resolve(artifactRoot);
await mkdir(target, { recursive: true });
await Promise.all([
page.screenshot({ path: resolve(target, 'main-turn-time-seoul-desktop-1200.png'), fullPage: true }),
page.screenshot({ path: resolve(target, 'main-turn-time-local-desktop-1200.png'), fullPage: true }),
writeFile(
resolve(target, 'main-turn-time-seoul-desktop-1200.json'),
resolve(target, 'main-turn-time-local-desktop-1200.json'),
`${JSON.stringify({ title: desktopGeometry, commandClock: desktopClockGeometry }, null, 2)}\n`
),
]);
@@ -867,9 +894,9 @@ test('main general card and command clock render the next turn with second preci
await page.setViewportSize({ width: 500, height: 900 });
const mobileTitle = page.locator('[data-main-target="general"] .general-title').first();
await expect(mobileTitle).toContainText('09:07:06');
await expect(mobileTitle).toContainText('09:09:10');
const mobileCommandClock = page.locator('[data-main-target="commands"] [data-command-current-time]').first();
await expect(mobileCommandClock).toHaveText('09:07:06');
await expect(mobileCommandClock).toHaveText('09:07:07');
const mobileGeometry = {
title: await mobileTitle.evaluate((element) => ({
width: element.getBoundingClientRect().width,
@@ -895,15 +922,23 @@ test('main general card and command clock render the next turn with second preci
if (artifactRoot) {
await Promise.all([
page.screenshot({
path: resolve(artifactRoot, 'main-turn-time-seoul-mobile-500.png'),
path: resolve(artifactRoot, 'main-turn-time-local-mobile-500.png'),
fullPage: true,
}),
writeFile(
resolve(artifactRoot, 'main-turn-time-seoul-mobile-500.json'),
resolve(artifactRoot, 'main-turn-time-local-mobile-500.json'),
`${JSON.stringify(mobileGeometry, null, 2)}\n`
),
]);
}
state.clockMode = 'manual';
state.serverTime = '2026-08-13T00:08:30.000Z';
await page.reload();
const frozenClock = page.locator('[data-main-target="commands"] [data-command-current-time]').first();
await expect(frozenClock).toHaveText('09:08:30');
await page.clock.runFor(2_000);
await expect(frozenClock).toHaveText('09:08:30');
});
test('pure NPC message senders are not rendered as reply targets', async ({ page }) => {
@@ -2041,7 +2076,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
});
await expect
.poll(() => state.operations.slice(operationsBeforeSurvey), { timeout: 3_000 })
.toEqual(['general.getFrontStatus']);
.toEqual(['dashboard.getContextBundleDelta', 'general.getFrontStatus']);
const profile = await page.evaluate(() => {
const probe = (
@@ -2199,6 +2234,55 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
expect(state.generalMeCalls).toBe(callsAfterLeavingMain);
});
test('access limit stops automatic main refresh and closes realtime until a manual retry can pass', async ({
page,
}) => {
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
nationLevel: 3,
stage: 0,
npcMode: 1,
generalMeCalls: 0,
operations: [],
accessLimitAfterCalls: 1,
};
await installRealtimeHarness(page);
await installFixture(page, state);
await page.setViewportSize({ width: 1200, height: 900 });
await waitForMain(page);
await expect
.poll(() =>
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
)
.toBe(true);
const operationsBeforeLimit = state.operations.length;
await emitReadModelInvalidation(page, readModelInvalidation({ records: true, map: true }));
await expect(page.getByRole('alert')).toContainText('접속 제한중입니다.');
await expect
.poll(() =>
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
)
.toBe(false);
expect(state.operations.slice(operationsBeforeLimit)).toEqual(['dashboard.getContextBundleDelta']);
const operationsAfterLimit = state.operations.length;
await emitReadModelInvalidation(page, readModelInvalidation({ context: true, commands: true }));
await new Promise((resolve) => setTimeout(resolve, 300));
expect(state.operations).toHaveLength(operationsAfterLimit);
state.accessLimitAfterCalls = undefined;
await page.getByRole('button', { name: '갱 신' }).click();
await expect(page.getByRole('alert')).toHaveCount(0);
await expect
.poll(() =>
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
)
.toBe(true);
});
test('global activity, world history, and a month boundary refresh their visible main slices', async ({ page }) => {
const state: NavigationFixture = {
officerLevel: 5,
@@ -2226,7 +2310,10 @@ test('global activity, world history, and a month boundary refresh their visible
const operationsBeforeGlobal = state.operations.length;
await emitReadModelInvalidation(page, readModelInvalidation({ records: true }));
await expect(page.locator('[data-main-target="global-records"]')).toContainText('자동 갱신된 장수 동향');
expect(state.operations.slice(operationsBeforeGlobal)).toEqual(['general.getRecentRecords']);
expect(state.operations.slice(operationsBeforeGlobal)).toEqual([
'dashboard.getContextBundleDelta',
'general.getRecentRecords',
]);
state.worldHistory = [
{ id: 5, text: '자동 갱신된 중원 정세' },
@@ -2235,7 +2322,10 @@ test('global activity, world history, and a month boundary refresh their visible
const operationsBeforeHistory = state.operations.length;
await emitReadModelInvalidation(page, readModelInvalidation({ records: true }));
await expect(page.locator('[data-main-target="world-history"]')).toContainText('자동 갱신된 중원 정세');
expect(state.operations.slice(operationsBeforeHistory)).toEqual(['general.getRecentRecords']);
expect(state.operations.slice(operationsBeforeHistory)).toEqual([
'dashboard.getContextBundleDelta',
'general.getRecentRecords',
]);
state.currentMonth = 2;
const operationsBeforeMonth = state.operations.length;