fix(frontend): restore chief center command UI parity

This commit is contained in:
2026-08-02 05:10:35 +00:00
parent 43ec211ff8
commit 4e1058ae86
8 changed files with 1009 additions and 130 deletions
@@ -0,0 +1,22 @@
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig } from '@playwright/test';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const frontendUrl = process.env.CHIEF_CENTER_LIVE_FRONTEND_URL ?? 'http://127.0.0.1:15160/hwe/';
export default defineConfig({
testDir: '.',
testMatch: ['chiefCenterLive.spec.ts'],
fullyParallel: false,
workers: 1,
timeout: 90_000,
expect: { timeout: 15_000 },
reporter: [['list']],
outputDir: resolve(repositoryRoot, 'test-results/chief-center-live'),
use: {
baseURL: frontendUrl,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
});
@@ -0,0 +1,205 @@
import { randomUUID } from 'node:crypto';
import { expect, test, type Browser, type Page } from '@playwright/test';
import { encryptGameSessionToken } from '../../../packages/common/dist/auth/gameToken.js';
import { createGamePostgresConnector } from '../../../packages/infra/dist/index.js';
const databaseUrl = process.env.CHIEF_CENTER_LIVE_DATABASE_URL;
const gameTokenSecret = process.env.CHIEF_CENTER_LIVE_GAME_SECRET;
const profile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'hwe:1010';
const hasLiveFixture = Boolean(databaseUrl && gameTokenSecret);
const gameSchema = profile.split(':', 1)[0] ?? '';
const resolveGameDatabaseUrl = (): string => {
const parsed = new URL(databaseUrl!);
const sourceSchema = parsed.searchParams.get('schema');
if (!gameSchema || (sourceSchema !== 'public' && sourceSchema !== gameSchema)) {
throw new Error(`Refusing unexpected chief-center schema: ${sourceSchema ?? '(missing)'}`);
}
parsed.searchParams.set('schema', gameSchema);
return parsed.toString();
};
const installSession = async (page: Page, userId: string, displayName: string): Promise<void> => {
const now = new Date();
const token = encryptGameSessionToken(
{
version: 1,
profile,
issuedAt: now.toISOString(),
expiresAt: new Date(now.getTime() + 3_600_000).toISOString(),
sessionId: `chief-center-live-${randomUUID()}`,
user: {
id: userId,
username: userId,
displayName,
roles: ['user'],
canUseGeneralPicture: false,
},
sanctions: {},
identity: {
kakaoVerified: true,
canCreateGeneral: true,
requiresKakaoVerification: false,
graceEndsAt: null,
},
},
gameTokenSecret!
);
await page.addInitScript(
({ gameToken, gameProfile }) => {
localStorage.setItem('sammo-game-token', gameToken);
localStorage.setItem('sammo-game-profile', gameProfile);
},
{ gameToken: token, gameProfile: profile }
);
};
const newPage = async (browser: Browser, userId: string, displayName: string): Promise<Page> => {
const context = await browser.newContext({
viewport: { width: 1365, height: 900 },
deviceScaleFactor: 1,
locale: 'ko-KR',
timezoneId: 'Asia/Seoul',
colorScheme: 'dark',
});
const page = await context.newPage();
await installSession(page, userId, displayName);
return page;
};
test('persists one chief command and exposes it to a normal nation user and another chief', async ({
browser,
}, testInfo) => {
test.skip(!hasLiveFixture, 'isolated chief-center PostgreSQL and token secret are required');
test.setTimeout(90_000);
const connector = createGamePostgresConnector({ url: resolveGameDatabaseUrl() });
await connector.connect();
const db = connector.prisma;
const editor = await db.general.findFirstOrThrow({ where: { name: 'GUI비교관리자' } });
const candidates = await db.general.findMany({
where: { nationId: editor.nationId, userId: null, id: { not: editor.id } },
orderBy: { id: 'asc' },
take: 2,
});
if (candidates.length !== 2) throw new Error('Two isolated visibility candidates are required.');
const [viewer, otherChief] = candidates;
const viewerUserId = `chief-center-viewer-${randomUUID()}`;
const otherChiefUserId = `chief-center-peer-${randomUUID()}`;
const originalTurns = await db.nationTurn.findMany({
where: { nationId: editor.nationId, officerLevel: editor.officerLevel },
orderBy: { turnIdx: 'asc' },
});
const originalRevision = await db.nationTurnRevision.findUnique({
where: {
nationId_officerLevel: { nationId: editor.nationId, officerLevel: editor.officerLevel },
},
});
let selectedTargetId: number | undefined;
try {
await db.$transaction([
db.general.update({
where: { id: viewer.id },
data: {
userId: viewerUserId,
officerLevel: 1,
npcState: 0,
meta: { ...(viewer.meta as Record<string, unknown>), belong: 999 },
penalty: {},
},
}),
db.general.update({
where: { id: otherChief.id },
data: { userId: otherChiefUserId, officerLevel: 10, npcState: 0, penalty: {} },
}),
]);
const editorPage = await newPage(browser, editor.userId!, '사령부입력자');
await editorPage.goto('chief-center');
await expect(editorPage.getByRole('heading', { name: '사령부', exact: true })).toBeVisible();
await editorPage.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
const picker = editorPage.getByTestId('chief-command-picker');
await expect(picker).toBeVisible();
await picker.getByRole('button', { name: '인사', exact: true }).click();
const reward = picker.getByRole('button', { name: /포상/ });
await expect(reward).toBeEnabled();
await reward.click();
const argumentForm = picker.getByTestId('command-argument-form');
await argumentForm.getByRole('button', { name: '쌀', exact: true }).click();
await argumentForm.locator('input[type=number]').fill('1');
const selectableGeneralIds = await argumentForm
.locator('select option')
.evaluateAll((options) =>
options
.map((option) => Number((option as HTMLOptionElement).value))
.filter((value) => Number.isInteger(value) && value > 0)
);
selectedTargetId = selectableGeneralIds.find((generalId) => generalId !== editor.id);
if (!selectedTargetId) throw new Error('No reward target is available in the live command table.');
await argumentForm.locator('select').selectOption(String(selectedTargetId));
await picker.getByRole('button', { name: '입력', exact: true }).click();
await expect(
editorPage.getByTestId('chief-command-editor').locator('.editor-turn-row strong').first()
).toHaveText('포상');
const persisted = await db.nationTurn.findUniqueOrThrow({
where: {
nationId_officerLevel_turnIdx: {
nationId: editor.nationId,
officerLevel: editor.officerLevel,
turnIdx: 0,
},
},
});
expect(persisted.actionCode).toBe('che_포상');
expect(persisted.arg).toEqual({ isGold: false, amount: 1, destGeneralId: selectedTargetId });
await editorPage.screenshot({ path: testInfo.outputPath('chief-editor-command-entered.png'), fullPage: true });
const viewerPage = await newPage(browser, viewerUserId, '일반국가원');
await viewerPage.goto('chief-center');
await expect(viewerPage.getByRole('heading', { name: '사령부', exact: true })).toBeVisible();
await expect(viewerPage.getByTestId('chief-command-editor')).toHaveCount(0);
await expect(viewerPage.locator('.chief-grid-row').first().getByText('포상', { exact: true })).toBeVisible();
await viewerPage.screenshot({ path: testInfo.outputPath('chief-normal-user-visible.png'), fullPage: true });
const peerPage = await newPage(browser, otherChiefUserId, '다른수뇌');
await peerPage.goto('chief-center');
await expect(peerPage.getByTestId('chief-command-editor')).toBeVisible();
await expect(peerPage.locator('.chief-grid-row').first().getByText('포상', { exact: true })).toBeVisible();
await peerPage.screenshot({ path: testInfo.outputPath('chief-peer-visible.png'), fullPage: true });
} finally {
await db.$transaction(async (transaction) => {
await transaction.nationTurn.deleteMany({
where: { nationId: editor.nationId, officerLevel: editor.officerLevel },
});
if (originalTurns.length) await transaction.nationTurn.createMany({ data: originalTurns });
await transaction.nationTurnRevision.deleteMany({
where: { nationId: editor.nationId, officerLevel: editor.officerLevel },
});
if (originalRevision) await transaction.nationTurnRevision.create({ data: originalRevision });
await transaction.general.update({
where: { id: viewer.id },
data: {
userId: viewer.userId,
officerLevel: viewer.officerLevel,
npcState: viewer.npcState,
meta: viewer.meta,
penalty: viewer.penalty,
},
});
await transaction.general.update({
where: { id: otherChief.id },
data: {
userId: otherChief.userId,
officerLevel: otherChief.officerLevel,
npcState: otherChief.npcState,
meta: otherChief.meta,
penalty: otherChief.penalty,
},
});
});
await connector.disconnect();
}
});
+17 -3
View File
@@ -211,6 +211,7 @@ const install = async (page: Page, rejectGeneral = false) => {
requests.push(body);
return response({
ok: true,
revision: 1,
turns: [{ index: 0, action: 'che_포상', args: { isGold: false, amount: 300, destGeneralId: 2 } }],
});
}
@@ -281,7 +282,7 @@ test('keeps the entered command visible and reports a server validation error',
});
test('keeps the shared main and chief shell geometry and interaction states', async ({ page }) => {
await install(page);
const requests = await install(page);
await page.setViewportSize({ width: 1000, height: 900 });
await page.goto('/');
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
@@ -329,10 +330,23 @@ test('keeps the shared main and chief shell geometry and interaction states', as
await page.locator('.main-nation-menu').first().locator('[data-navigation-id="chief-center"]').click();
await expect(page).toHaveURL(/\/che\/chief-center$/);
await expect(page.getByRole('heading', { name: '사령부', exact: true })).toBeVisible();
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
await expect(page.getByTestId('chief-command-picker')).toBeVisible();
await page.getByTestId('chief-command-picker').getByRole('button', { name: /포상/ }).click();
const chiefArgumentForm = page.getByTestId('chief-command-picker').getByTestId('command-argument-form');
await chiefArgumentForm.getByRole('button', { name: '쌀' }).click();
await chiefArgumentForm.locator('input[type=number]').fill('300');
await chiefArgumentForm.locator('select').selectOption('2');
await page.getByTestId('chief-command-picker').getByRole('button', { name: '입력', exact: true }).click();
await expect(page.getByTestId('chief-command-editor').locator('.editor-turn-row strong').first()).toHaveText(
'포상'
);
expect(JSON.stringify(requests)).toContain('"action":"che_포상"');
expect(JSON.stringify(requests)).toContain('"destGeneralId":2');
const chiefDesktop = await page.locator('.chief-page').evaluate((element) => ({
width: element.getBoundingClientRect().width,
padding: getComputedStyle(element).padding,
headerWidth: element.querySelector<HTMLElement>('.game-shell__header')!.getBoundingClientRect().width,
headerWidth: element.querySelector<HTMLElement>('.chief-top')!.getBoundingClientRect().width,
}));
expect(chiefDesktop).toEqual({ width: 1000, padding: '0px', headerWidth: 1000 });
@@ -340,7 +354,7 @@ test('keeps the shared main and chief shell geometry and interaction states', as
const chiefMobile = await page.locator('.chief-page').evaluate((element) => ({
width: element.getBoundingClientRect().width,
padding: getComputedStyle(element).padding,
headerWidth: element.querySelector<HTMLElement>('.game-shell__header')!.getBoundingClientRect().width,
headerWidth: element.querySelector<HTMLElement>('.chief-top')!.getBoundingClientRect().width,
}));
expect(chiefMobile).toEqual({ width: 500, padding: '0px', headerWidth: 500 });
});
@@ -16,6 +16,8 @@
"./npcPossessionLive.spec.ts",
"./npcPossession.live.playwright.config.mjs",
"./dieOnPrestartLive.spec.ts",
"./dieOnPrestart.live.playwright.config.mjs"
"./dieOnPrestart.live.playwright.config.mjs",
"./chiefCenterLive.spec.ts",
"./chiefCenter.live.playwright.config.mjs"
]
}