feat: complete in-game message parity
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
|
||||
import { canonicalFrontendFixture as fixture } from './fixtures/canonical';
|
||||
|
||||
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 === '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([]);
|
||||
}
|
||||
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 === '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.locator('.error')).toHaveText('공개 메세지를 보낼 수 없습니다.');
|
||||
await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.send').length).toBe(1);
|
||||
});
|
||||
Reference in New Issue
Block a user