fix(gateway): 일시정지와 서버 중지 상태 계약 분리

This commit is contained in:
2026-08-15 17:12:13 +00:00
parent dc27766dd6
commit 4f841ad82f
16 changed files with 320 additions and 41 deletions
@@ -37,6 +37,8 @@ const installFixture = async (
initialActions?: RuntimeAction[];
afterRequestActions?: RuntimeAction[];
pendingProfileReads?: number;
profileStatus?: 'RUNNING' | 'PAUSED' | 'STOPPED';
currentScenario?: string | null;
} = {}
) => {
let requested = false;
@@ -151,7 +153,7 @@ const installFixture = async (
profileName: 'hwe:default',
profile: 'hwe',
instanceKey: 'default',
currentScenario: '1010',
currentScenario: options.currentScenario === undefined ? '1010' : options.currentScenario,
meta: {},
},
]);
@@ -163,10 +165,10 @@ const installFixture = async (
profileName: 'hwe:default',
profile: 'hwe',
instanceKey: 'default',
currentScenario: '1010',
scenario: '1010',
currentScenario: options.currentScenario === undefined ? '1010' : options.currentScenario,
scenario: options.currentScenario ?? 'default',
apiPort: 15015,
status: 'RUNNING',
status: options.profileStatus ?? 'RUNNING',
buildStatus: 'SUCCEEDED',
meta: {},
activeOperation: installActive
@@ -286,6 +288,34 @@ test('reports clock-shift acceptance separately from actual application', async
await expect(page.getByText('설문 생성은 해당 게임의 설문 관리 화면에서 진행해 주세요.')).toBeVisible();
});
test('distinguishes a turn pause from an inaccessible stopped server in operator controls', async ({ page }) => {
await installFixture(page, { profileStatus: 'PAUSED' });
await page.goto('/gateway/admin/servers/hwe%3Adefault');
await expect(page.getByTestId('profile-lifecycle-description')).toContainText('게임 조회와 예약턴 입력 가능');
await expect(page.getByRole('button', { name: '턴 재개' })).toBeEnabled();
await expect(page.getByRole('button', { name: '일시정지' })).toBeDisabled();
await expect(page.getByRole('button', { name: '중지', exact: true })).toBeEnabled();
});
test('shows an initialized STOPPED server as inaccessible and only restartable', async ({ page }) => {
await installFixture(page, { profileStatus: 'STOPPED' });
await page.goto('/gateway/admin/servers/hwe%3Adefault');
await expect(page.getByTestId('profile-lifecycle-description')).toContainText('게임 접근 불가');
await expect(page.getByRole('button', { name: '서버 재개' })).toBeEnabled();
await expect(page.getByRole('button', { name: '일시정지' })).toBeDisabled();
await expect(page.getByRole('button', { name: '중지', exact: true })).toBeDisabled();
});
test('separates an uninitialized database from an initialized stopped server', async ({ page }) => {
await installFixture(page, { profileStatus: 'STOPPED', currentScenario: null });
await page.goto('/gateway/admin/servers/hwe%3Adefault');
await expect(page.getByTestId('profile-lifecycle-description')).toHaveText('DB 초기화 전 · 게임 접근 불가');
await expect(page.getByRole('button', { name: '서버 재개' })).toBeDisabled();
});
test('blocks another clock shift while any recent action is pending', async ({ page }) => {
await installFixture(page, {
initialActions: [
@@ -53,7 +53,7 @@ type LobbyFixtureOptions = {
opentime?: string;
turntime?: string;
lobbyBundleFailures?: number;
profileStatus?: 'RUNNING' | 'PREOPEN' | 'PAUSED' | 'COMPLETED';
profileStatus?: 'RUNNING' | 'PREOPEN' | 'PAUSED' | 'COMPLETED' | 'STOPPED';
};
const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) => {
@@ -112,8 +112,17 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) =>
{
profileName: 'hwe:903',
profile: 'hwe',
instanceKey: '903',
currentScenario: '903',
scenario: '903',
status: profileStatus,
lifecycle: {
runtimeExpected: profileStatus !== 'STOPPED',
userAccessible: profileStatus !== 'STOPPED',
turnsRunning: profileStatus === 'RUNNING',
operatorResumable: profileStatus === 'PAUSED' || profileStatus === 'STOPPED',
dataInitialized: true,
},
apiPort: 15015,
runtime: {
apiRunning: true,
@@ -239,7 +248,7 @@ test('loads and labels a PAUSED profile whose runtime remains available', async
await page.goto('lobby');
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
const pausedStatus = row.getByTestId('profile-paused-status');
await expect(pausedStatus).toHaveText('턴 진행 일시정지');
await expect(pausedStatus).toHaveText('턴 일시정지 · 조회/예약턴 가능');
await expect(pausedStatus).toHaveCSS('color', 'oklch(0.879 0.169 91.605)');
await expect(row).toContainText('선택장수');
await expect(row).not.toContainText('정보를 불러오는 중');
@@ -249,6 +258,19 @@ test('loads and labels a PAUSED profile whose runtime remains available', async
await page.screenshot({ path: testInfo.outputPath('gateway-paused-profile-lobby.png'), fullPage: true });
});
test('does not contact a STOPPED game runtime and labels it inaccessible', async ({ page }, testInfo) => {
const gameOperations = await installFixture(page, { profileStatus: 'STOPPED' });
await page.goto('lobby');
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
await expect(row).toContainText('서버 중지 · 접근 불가');
await expect(row).not.toContainText('정보를 불러오는 중');
await expect(row.getByRole('button', { name: '입장' })).toHaveCount(0);
await expect(page.getByRole('tab', { name: 'hwe섭' })).toHaveCount(0);
expect(gameOperations).toEqual([]);
await page.screenshot({ path: testInfo.outputPath('gateway-stopped-profile-lobby.png'), fullPage: true });
});
test('automatically recovers profile details after a transient update outage', async ({ page }) => {
const gameOperations = await installFixture(page, { lobbyBundleFailures: 1 });
@@ -13,8 +13,23 @@ type ProfileFixture = {
color: string;
status: 'RUNNING' | 'PREOPEN' | 'PAUSED' | 'COMPLETED' | 'STOPPED';
apiPort: number;
lifecycle: {
runtimeExpected: boolean;
userAccessible: boolean;
turnsRunning: boolean;
operatorResumable: boolean;
dataInitialized: boolean;
};
};
const lifecycleFor = (status: ProfileFixture['status']): ProfileFixture['lifecycle'] => ({
runtimeExpected: status !== 'STOPPED',
userAccessible: status !== 'STOPPED',
turnsRunning: status === 'RUNNING',
operatorResumable: status === 'PAUSED' || status === 'STOPPED',
dataInitialized: true,
});
const profiles: ProfileFixture[] = [
{
profileName: 'che:2',
@@ -23,6 +38,7 @@ const profiles: ProfileFixture[] = [
color: '#ff8080',
status: 'RUNNING',
apiPort: 15003,
lifecycle: lifecycleFor('RUNNING'),
},
{
profileName: 'hwe:2',
@@ -31,6 +47,7 @@ const profiles: ProfileFixture[] = [
color: '#80c0ff',
status: 'PAUSED',
apiPort: 15015,
lifecycle: lifecycleFor('PAUSED'),
},
{
profileName: 'kwe:2',
@@ -39,6 +56,7 @@ const profiles: ProfileFixture[] = [
color: '#b0b0b0',
status: 'STOPPED',
apiPort: 15005,
lifecycle: lifecycleFor('STOPPED'),
},
];
@@ -58,6 +76,7 @@ const orderedProfiles: ProfileFixture[] = orderedProfileData.map(([profile, korN
color: '#b0b0b0',
status: 'STOPPED',
apiPort,
lifecycle: lifecycleFor('STOPPED'),
}));
const fulfill = async (route: Route, results: unknown[]): Promise<void> => {