fix(frontend): restore chief center command UI parity
This commit is contained in:
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user