Files
core2026/tools/frontend-legacy-parity/ingame-message-parity.spec.ts
T

400 lines
16 KiB
TypeScript

import { expect, test, type Page, type Route } from '@playwright/test';
import { canonicalFrontendFixture as fixture } from './fixtures/canonical.js';
const gamePort = process.env.FRONTEND_PARITY_GAME_PORT ?? '15102';
const response = (data: unknown) => ({ result: { data } });
const errorResponse = (path: string, message: string) => ({
error: {
message,
code: -32000,
data: { code: 'BAD_REQUEST', httpStatus: 400, path },
},
});
const operationNames = (route: Route): string[] => {
const pathname = new URL(route.request().url()).pathname;
return decodeURIComponent(pathname.slice(pathname.lastIndexOf('/trpc/') + 6)).split(',');
};
const general = {
id: 1,
name: '테스트장수',
npcState: 0,
nationId: 1,
cityId: 1,
troopId: 0,
picture: 'default.jpg',
imageServer: 0,
officerLevel: 1,
stats: { leadership: 80, strength: 70, intelligence: 90 },
gold: 1000,
rice: 1000,
crew: 500,
train: 100,
atmos: 100,
injury: 0,
experience: 1200,
dedication: 900,
items: { horse: null, weapon: null, book: null, item: null },
};
const generalContext = {
general,
city: {
id: 1,
name: '낙양',
level: 7,
nationId: 1,
population: 50000,
agriculture: 5000,
commerce: 5000,
security: 5000,
defence: 5000,
wall: 5000,
supplyState: 1,
frontState: 2,
},
nation: {
id: 1,
name: '테스트국',
color: '#d32f2f',
level: 5,
gold: 10000,
rice: 10000,
tech: 1200,
typeCode: 'che_군벌',
capitalCityId: 1,
},
settings: {},
penalties: {},
};
const target = (generalId: number, generalName: string, nationId: number, nationName: string, color: string) => ({
generalId,
generalName,
nationId,
nationName,
color,
icon: '/image/icons/default.jpg',
});
const ownTarget = target(1, '테스트장수', 1, '테스트국', '#d32f2f');
const foreignTarget = target(8, '상대장수', 2, '상대국', '#2457a6');
const messageTime = new Date().toISOString().replace('T', ' ').slice(0, 19);
const buildMessages = (permission: number) => ({
result: true,
public: [
{
id: 101,
msgType: 'public',
src: ownTarget,
dest: null,
text: '전체 메시지 본문',
option: {},
time: messageTime,
},
],
national: [
{
id: 102,
msgType: 'national',
src: ownTarget,
dest: target(0, '', 1, '테스트국', '#d32f2f'),
text: '국가 메시지 본문',
option: {},
time: messageTime,
},
],
private: [
{
id: 103,
msgType: 'private',
src: foreignTarget,
dest: ownTarget,
text: '개인 메시지 본문',
option: {},
time: messageTime,
},
],
diplomacy: [
{
id: 104,
msgType: 'diplomacy',
src: foreignTarget,
dest: target(0, '', 1, '테스트국', '#d32f2f'),
text: permission >= 3 ? '외교 메시지 본문' : '(외교 메시지입니다)',
option:
permission >= 3
? { action: 'noAggression', deletable: false }
: { action: 'noAggression', deletable: false, invalid: true },
time: messageTime,
},
],
sequence: 104,
nationId: 1,
generalName: general.name,
permission,
canRespondDiplomacy: permission >= 4 && general.officerLevel > 4,
latestRead: { private: 0, diplomacy: 0 },
});
const contacts = {
nation: [
{
nationId: 0,
mailbox: 9000,
name: '재야',
color: '#000000',
general: [],
},
{
nationId: 1,
mailbox: 9001,
name: '테스트국',
color: '#d32f2f',
general: [
[1, '테스트장수', 4],
[2, '아군군주', 1],
],
},
{
nationId: 2,
mailbox: 9002,
name: '상대국',
color: '#2457a6',
general: [
[8, '상대외교관', 4],
[9, '상대일반', 0],
],
},
],
};
const installFixture = async (
page: Page,
options: { permission: number; sendError?: string }
): Promise<Array<{ operation: string; body: unknown }>> => {
const mutations: Array<{ operation: string; body: unknown }> = [];
await page.addInitScript(
({ gameToken, profile }) => {
window.localStorage.setItem('sammo-game-token', gameToken);
window.localStorage.setItem('sammo-game-profile', profile);
},
{
gameToken: fixture.game.session.gameToken,
profile: fixture.game.session.profile,
}
);
await page.route('**/image/**', (route) => route.fulfill({ status: 204, body: '' }));
await page.route('**/che/api/events**', (route) => route.abort());
await page.route('**/che/api/trpc/**', async (route) => {
const body = route.request().postDataJSON();
const results = operationNames(route).map((operation) => {
if (operation === 'auth.status') return response({ userId: 'frontend-parity-user' });
if (operation === 'lobby.info') {
return response({ ...fixture.game.lobby, myGeneral: general });
}
if (operation === 'general.me') return response(generalContext);
if (operation === 'world.getMapLayout') return response(fixture.game.mapLayout);
if (operation === 'world.getMap') {
return response({ ...fixture.game.map, myCity: 1, myNation: 1 });
}
if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] });
if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') {
return response({ turns: [], revision: 0 });
}
if (operation === 'general.getFrontStatus') {
return response({
onlineUserCount: 1,
onlineNations: '테스트국(1)',
onlineGenerals: general.name,
nationNotice: '',
lastExecuted: null,
latestVote: null,
});
}
if (operation === 'general.getRecentRecords') {
return response({ global: [], general: [], history: [] });
}
if (operation === 'messages.getRecent') return response(buildMessages(options.permission));
if (operation === 'messages.getContacts') return response(contacts);
if (operation === 'board.getAccess') return response({ canMeeting: true, canSecret: true });
if (operation === 'tournament.getState') return response({ stage: 0 });
if (operation === 'public.recordAccess') return response({ recorded: true });
if (
operation === 'messages.send' ||
operation === 'messages.readLatest' ||
operation === 'messages.delete' ||
operation === 'messages.respond'
) {
mutations.push({ operation, body });
if (operation === 'messages.send' && options.sendError) {
return errorResponse(operation, options.sendError);
}
return response(operation === 'messages.respond' ? { result: true, reason: 'success' } : { ok: true });
}
return errorResponse(operation, `Unhandled message fixture operation: ${operation}`);
});
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(results),
});
});
return mutations;
};
const openMessages = async (page: Page, viewport: { width: number; height: number }) => {
await page.setViewportSize(viewport);
await page.goto(`http://127.0.0.1:${gamePort}/che/`);
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
if (viewport.width <= 1024) {
await page.getByRole('button', { name: '메시지', exact: true }).click();
}
await expect(page.locator('.MessagePanel')).toBeVisible();
};
for (const viewport of [
{ width: 1000, height: 900 },
{ width: 500, height: 900 },
]) {
test(`matches the reference message computed DOM at ${viewport.width}px Chromium viewport`, async ({ page }) => {
await installFixture(page, { permission: 4 });
await openMessages(page, viewport);
const geometry = await page.locator('.MessagePanel').evaluate((panel) => {
const required = (selector: string) => panel.querySelector<HTMLElement>(selector)!;
const rect = (element: Element) => {
const box = element.getBoundingClientRect();
return { x: box.x, y: box.y, width: box.width, height: box.height };
};
const panelStyle = getComputedStyle(panel);
const header = required('.BoardHeader');
const plate = required('.msg-plate');
const icon = required('.general-icon');
return {
panel: rect(panel),
inputForm: rect(required('.MessageInputForm')),
select: rect(required('.message-select')),
input: rect(required('.message-text')),
submit: rect(required('.message-send')),
publicSection: rect(required('.PublicTalk')),
nationalSection: rect(required('.NationalTalk')),
firstHeader: rect(header),
firstPlate: rect(plate),
firstIcon: rect(icon),
computed: {
panelDisplay: panelStyle.display,
panelColumns: panelStyle.gridTemplateColumns,
panelFontSize: panelStyle.fontSize,
headerColor: getComputedStyle(header).color,
headerOutlineWidth: getComputedStyle(header).outlineWidth,
plateBackgroundColor: getComputedStyle(plate).backgroundColor,
plateFontSize: getComputedStyle(plate).fontSize,
plateMinHeight: getComputedStyle(plate).minHeight,
iconObjectFit: getComputedStyle(icon).objectFit,
},
};
});
expect(geometry.panel.x).toBeCloseTo(0, 0);
expect(geometry.panel.width).toBeCloseTo(viewport.width, 0);
expect(geometry.inputForm.width).toBeCloseTo(viewport.width, 0);
expect(geometry.select.height).toBeCloseTo(35.5, 0);
expect(geometry.submit.height).toBeCloseTo(35.5, 0);
expect(geometry.firstHeader.height).toBeCloseTo(25, 0);
expect(geometry.firstPlate.height).toBeGreaterThanOrEqual(64);
expect(geometry.firstIcon).toMatchObject({ width: 64, height: 64 });
expect(geometry.computed).toMatchObject({
panelFontSize: '14px',
headerColor: 'rgb(255, 255, 255)',
headerOutlineWidth: '1px',
plateBackgroundColor: 'rgb(20, 28, 101)',
plateFontSize: '12.5px',
plateMinHeight: '64px',
iconObjectFit: 'fill',
});
if (viewport.width === 1000) {
expect(geometry.computed.panelDisplay).toBe('grid');
expect(geometry.computed.panelColumns).toBe('500px 500px');
expect(geometry.select.width).toBeCloseTo(166.66, 0);
expect(geometry.input.width).toBeCloseTo(666.66, 0);
expect(geometry.submit.width).toBeCloseTo(166.66, 0);
expect(geometry.publicSection.width).toBeCloseTo(500, 0);
expect(geometry.nationalSection.x).toBeCloseTo(500, 0);
} else {
expect(geometry.computed.panelDisplay).toBe('block');
expect(geometry.select.width).toBeCloseTo(250, 0);
expect(geometry.input.width).toBeCloseTo(500, 0);
expect(geometry.input.height).toBeCloseTo(33.5, 0);
expect(geometry.submit.width).toBeCloseTo(250, 0);
}
const submit = page.locator('.message-send');
await submit.hover();
expect(
await submit.evaluate((element) => ({
cursor: getComputedStyle(element).cursor,
backgroundColor: getComputedStyle(element).backgroundColor,
}))
).toEqual({ cursor: 'pointer', backgroundColor: 'rgb(55, 90, 127)' });
await submit.focus();
expect(
await submit.evaluate((element) => ({
outlineWidth: getComputedStyle(element).outlineWidth,
boxShadow: getComputedStyle(element).boxShadow,
}))
).toEqual({ outlineWidth: '0px', boxShadow: 'none' });
});
}
test('exposes ambassador targets, reply, read, delete, and successful send interactions', async ({ page }) => {
const mutations = await installFixture(page, { permission: 4 });
await openMessages(page, { width: 500, height: 900 });
const select = page.getByLabel('메시지 수신 대상');
await expect(select.locator('option[value="9002"]')).toHaveCount(1);
await expect(select.locator('option[value="8"]')).toBeDisabled();
await expect(select.locator('option[value="9"]')).toBeEnabled();
await page.locator('.PrivateTalk .msg-target').filter({ hasText: '상대장수' }).click();
await expect(select).toHaveValue('8');
await page.locator('.PrivateTalk').getByRole('button', { name: '모두 읽음' }).click();
await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.readLatest').length).toBe(1);
const deleteButton = page.locator('.PublicTalk .delete-message');
page.once('dialog', (dialog) => dialog.accept());
await deleteButton.click();
await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.delete').length).toBe(1);
await select.selectOption('9999');
await page.getByLabel('메시지 입력').fill('전송 성공');
await page.getByRole('button', { name: '서신전달&갱신' }).click();
await expect(page.getByLabel('메시지 입력')).toHaveValue('');
await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.send').length).toBe(1);
});
test('redacts diplomacy for a low-permission general and preserves the failed-send error flow', async ({ page }) => {
const mutations = await installFixture(page, {
permission: 2,
sendError: '공개 메세지를 보낼 수 없습니다.',
});
await openMessages(page, { width: 500, height: 900 });
const select = page.getByLabel('메시지 수신 대상');
await expect(select.locator('option[value="9002"]')).toHaveCount(0);
await expect(page.locator('.DiplomacyTalk')).toContainText('삭제된 메시지입니다');
await expect(page.locator('.DiplomacyTalk')).not.toContainText('외교 메시지 본문');
await expect(page.locator('.DiplomacyTalk .message-response button').first()).toBeDisabled();
await select.selectOption('9999');
await page.getByLabel('메시지 입력').fill('차단될 메시지');
await page.getByRole('button', { name: '서신전달&갱신' }).click();
await expect(page.getByLabel('메시지 입력')).toHaveValue('');
await expect(page.getByRole('alert').filter({ hasText: '공개 메세지를 보낼 수 없습니다.' })).toBeVisible();
await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.send').length).toBe(1);
});