315 lines
13 KiB
TypeScript
315 lines
13 KiB
TypeScript
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
import { basename, resolve } from 'node:path';
|
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
|
|
|
const response = (data: unknown) => ({ result: { data } });
|
|
const parityArtifactDir = process.env.MENU_PARITY_ARTIFACT_DIR;
|
|
const legacyImageRoot = process.env.LEGACY_IMAGE_ROOT;
|
|
const operationNames = (route: Route) =>
|
|
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
|
|
|
|
const persistParityArtifact = async (page: Page, name: string, geometry: unknown) => {
|
|
if (!parityArtifactDir) {
|
|
return;
|
|
}
|
|
await mkdir(parityArtifactDir, { recursive: true });
|
|
await Promise.all([
|
|
page.screenshot({ path: resolve(parityArtifactDir, `${name}.png`), fullPage: true }),
|
|
writeFile(resolve(parityArtifactDir, `${name}.json`), `${JSON.stringify(geometry, null, 2)}\n`),
|
|
]);
|
|
};
|
|
|
|
type FixtureState = {
|
|
permission: 'head' | 'member';
|
|
myset: number;
|
|
settingMutations: Array<Record<string, unknown>>;
|
|
};
|
|
|
|
const myGeneral = (state: FixtureState) => ({
|
|
general: {
|
|
id: 7,
|
|
name: '검증장수',
|
|
npcState: 0,
|
|
nationId: 1,
|
|
cityId: 1,
|
|
troopId: 0,
|
|
picture: null,
|
|
imageServer: 0,
|
|
officerLevel: state.permission === 'head' ? 5 : 1,
|
|
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
|
gold: 1_000,
|
|
rice: 2_000,
|
|
crew: 300,
|
|
train: 80,
|
|
atmos: 90,
|
|
injury: 0,
|
|
experience: 100,
|
|
dedication: 200,
|
|
items: { horse: 'che_명마', weapon: null, book: null, item: null },
|
|
},
|
|
city: { id: 1, name: '업', level: 8, nationId: 1 },
|
|
nation: { id: 1, name: '위', color: '#777777', level: 3 },
|
|
settings: {
|
|
tnmt: 0,
|
|
defence_train: 80,
|
|
use_treatment: 21,
|
|
use_auto_nation_turn: 1,
|
|
myset: state.myset,
|
|
},
|
|
penalties: {},
|
|
});
|
|
|
|
const battleCenter = (state: FixtureState) => ({
|
|
me: {
|
|
id: 7,
|
|
officerLevel: state.permission === 'head' ? 5 : 1,
|
|
permissionLevel: state.permission === 'head' ? 2 : 0,
|
|
},
|
|
nation: { id: 1, name: '위', color: '#777777', level: 3 },
|
|
currentYear: 185,
|
|
currentMonth: 1,
|
|
turnTermMinutes: 10,
|
|
generals: [
|
|
{
|
|
id: 7,
|
|
name: '검증장수',
|
|
npcState: 0,
|
|
officerLevel: state.permission === 'head' ? 5 : 1,
|
|
cityId: 1,
|
|
turnTime: '2026-01-01 00:10:00',
|
|
recentWar: '2026-01-01 00:00:00',
|
|
warnum: 3,
|
|
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
|
experience: 100,
|
|
dedication: 200,
|
|
injury: 0,
|
|
gold: 1_000,
|
|
rice: 2_000,
|
|
crew: 300,
|
|
train: 80,
|
|
atmos: 90,
|
|
},
|
|
{
|
|
id: 8,
|
|
name: '다른장수',
|
|
npcState: 2,
|
|
officerLevel: 1,
|
|
cityId: 1,
|
|
turnTime: '2026-01-01 00:20:00',
|
|
recentWar: null,
|
|
warnum: 0,
|
|
stats: { leadership: 50, strength: 50, intelligence: 50 },
|
|
experience: 0,
|
|
dedication: 0,
|
|
injury: 0,
|
|
gold: 500,
|
|
rice: 500,
|
|
crew: 100,
|
|
train: 60,
|
|
atmos: 60,
|
|
},
|
|
],
|
|
});
|
|
|
|
const install = async (page: Page, state: FixtureState) => {
|
|
await page.addInitScript(() => {
|
|
localStorage.setItem('sammo-game-token', 'menu-token');
|
|
localStorage.setItem('sammo-game-profile', 'che:default');
|
|
});
|
|
await page.route('**/image/game/**', async (route) => {
|
|
const filename = basename(new URL(route.request().url()).pathname);
|
|
if (legacyImageRoot && ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg'].includes(filename)) {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'image/jpeg',
|
|
body: await readFile(resolve(legacyImageRoot, filename)),
|
|
});
|
|
return;
|
|
}
|
|
await route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') });
|
|
});
|
|
await page.route('**/che/api/trpc/**', async (route) => {
|
|
const operations = operationNames(route);
|
|
const results = operations.map((operation) => {
|
|
if (operation === 'lobby.info') return response({ myGeneral: { id: 7, name: '검증장수' } });
|
|
if (operation === 'join.getConfig') return response({});
|
|
if (operation === 'general.me') return response(myGeneral(state));
|
|
if (operation === 'world.getState')
|
|
return response({
|
|
currentYear: 185,
|
|
currentMonth: 1,
|
|
tickSeconds: 600,
|
|
config: { npcMode: 0, const: { availableInstantAction: {} } },
|
|
meta: {
|
|
turntime: '2026-01-01T00:00:00.000Z',
|
|
opentime: '2025-12-01T00:00:00.000Z',
|
|
autorun_user: {},
|
|
},
|
|
});
|
|
if (operation === 'general.getMyLog')
|
|
return response({ type: 'generalAction', logs: [{ id: 1, text: '<Y>기록</>' }] });
|
|
if (operation === 'general.setMySetting') {
|
|
const raw = route.request().postDataJSON() as { input?: { json?: Record<string, unknown> } };
|
|
state.settingMutations.push(raw.input?.json ?? {});
|
|
state.myset = Math.max(0, state.myset - 1);
|
|
return response({ ok: true });
|
|
}
|
|
if (operation === 'nation.getBattleCenter') {
|
|
if (state.permission === 'member') {
|
|
return {
|
|
error: {
|
|
message: '권한이 부족합니다.',
|
|
code: -32000,
|
|
data: { code: 'FORBIDDEN', httpStatus: 403, path: operation },
|
|
},
|
|
};
|
|
}
|
|
return response(battleCenter(state));
|
|
}
|
|
if (operation === 'nation.getGeneralLog') {
|
|
const type = new URL(route.request().url()).searchParams.get('input')?.includes('generalAction')
|
|
? 'generalAction'
|
|
: operation;
|
|
return response({ type, generalId: 7, logs: [{ id: 1, text: '<Y>감찰 기록</>' }] });
|
|
}
|
|
return response({ ok: true });
|
|
});
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(operations.length === 1 ? results[0] : results),
|
|
});
|
|
});
|
|
};
|
|
|
|
test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in place', async ({ page }) => {
|
|
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [] };
|
|
await install(page, state);
|
|
await page.setViewportSize({ width: 1200, height: 900 });
|
|
await page.goto('my-page');
|
|
await expect(page.locator('.title-row')).toContainText('내 정 보');
|
|
await expect(page.locator('#set_my_setting')).toBeVisible();
|
|
|
|
const desktop = await page.locator('#container').evaluate((element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
const title = element.querySelector<HTMLElement>('.title-row')!.getBoundingClientRect();
|
|
const settings = element.querySelector<HTMLElement>('.settings-column')!.getBoundingClientRect();
|
|
const saveButton = element.querySelector<HTMLElement>('#set_my_setting')!;
|
|
const save = saveButton.getBoundingClientRect();
|
|
const customCss = element.querySelector<HTMLElement>('#custom_css')!.getBoundingClientRect();
|
|
const columns = getComputedStyle(element.querySelector('.top-grid')!).gridTemplateColumns;
|
|
return {
|
|
width: rect.width,
|
|
minWidth: getComputedStyle(element).minWidth,
|
|
fontSize: getComputedStyle(element).fontSize,
|
|
columns,
|
|
titleHeight: title.height,
|
|
settingsOffset: settings.x - rect.x,
|
|
saveWidth: save.width,
|
|
saveHeight: save.height,
|
|
saveBackground: getComputedStyle(saveButton).backgroundColor,
|
|
customCssWidth: customCss.width,
|
|
customCssHeight: customCss.height,
|
|
backgroundImage: getComputedStyle(element).backgroundImage,
|
|
sectionBackgroundImage: getComputedStyle(element.querySelector('.section-title')!).backgroundImage,
|
|
};
|
|
});
|
|
expect(desktop.width).toBe(1000);
|
|
expect(desktop.minWidth).toBe('500px');
|
|
expect(desktop.fontSize).toBe('14px');
|
|
expect(desktop.columns.split(' ')).toHaveLength(2);
|
|
expect(desktop.titleHeight).toBeCloseTo(54, 0);
|
|
expect(desktop.settingsOffset).toBeCloseTo(500, 0);
|
|
expect(desktop.saveWidth).toBe(160);
|
|
expect(desktop.saveHeight).toBe(30);
|
|
expect(desktop.saveBackground).toBe('rgb(34, 85, 0)');
|
|
expect(desktop.customCssWidth).toBe(420);
|
|
expect(desktop.customCssHeight).toBe(150);
|
|
expect(desktop.backgroundImage).toContain('back_walnut.jpg');
|
|
expect(desktop.sectionBackgroundImage).toContain('back_green.jpg');
|
|
await persistParityArtifact(page, 'core-my-page-desktop', desktop);
|
|
|
|
await page
|
|
.locator('select')
|
|
.filter({ has: page.locator('option[value="999"]') })
|
|
.selectOption('999');
|
|
await page.locator('#set_my_setting').click();
|
|
await expect.poll(() => state.settingMutations.length).toBe(1);
|
|
expect(state.settingMutations[0]).not.toHaveProperty('generalId');
|
|
|
|
await page.setViewportSize({ width: 500, height: 900 });
|
|
await page.reload();
|
|
const mobile = await page.locator('#container').evaluate((element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
const settings = element.querySelector<HTMLElement>('.settings-column')!.getBoundingClientRect();
|
|
return {
|
|
width: rect.width,
|
|
scrollWidth: document.documentElement.scrollWidth,
|
|
columns: getComputedStyle(element.querySelector('.top-grid')!).gridTemplateColumns,
|
|
settingsOffset: settings.x - rect.x,
|
|
settingsWidth: settings.width,
|
|
};
|
|
});
|
|
expect(mobile).toMatchObject({
|
|
width: 500,
|
|
scrollWidth: 500,
|
|
columns: '500px',
|
|
settingsOffset: 0,
|
|
settingsWidth: 500,
|
|
});
|
|
await persistParityArtifact(page, 'core-my-page-mobile', mobile);
|
|
});
|
|
|
|
test('감찰부 keeps the selector interaction and shows the permission error path', async ({ page }) => {
|
|
const head: FixtureState = { permission: 'head', myset: 3, settingMutations: [] };
|
|
await install(page, head);
|
|
await page.setViewportSize({ width: 1000, height: 900 });
|
|
await page.goto('battle-center');
|
|
await expect(page.getByRole('heading', { name: '감찰부' })).toBeVisible();
|
|
await expect(page.locator('.selector-row select').nth(1)).toHaveValue('8');
|
|
await page.getByRole('button', { name: '다음 ▶' }).click();
|
|
await expect(page.locator('.selector-row select').nth(1)).toHaveValue('7');
|
|
const geometry = await page.locator('.battle-page').evaluate((element) => {
|
|
const selector = element.querySelector<HTMLElement>('.selector-row')!;
|
|
const controls = [...selector.children].map((child) => (child as HTMLElement).getBoundingClientRect());
|
|
const logBlock = element.querySelector<HTMLElement>('.log-block')!.getBoundingClientRect();
|
|
return {
|
|
width: element.getBoundingClientRect().width,
|
|
fontSize: getComputedStyle(element).fontSize,
|
|
selectorColumns: getComputedStyle(selector).gridTemplateColumns,
|
|
selectorHeight: selector.getBoundingClientRect().height,
|
|
controlWidths: controls.map((control) => control.width),
|
|
logBlockWidth: logBlock.width,
|
|
backgroundImage: getComputedStyle(element).backgroundImage,
|
|
generalBackgroundImage: getComputedStyle(element.querySelector<HTMLElement>('.battle-general-card')!)
|
|
.backgroundImage,
|
|
};
|
|
});
|
|
expect(geometry.width).toBe(1000);
|
|
expect(geometry.fontSize).toBe('14px');
|
|
expect(geometry.selectorColumns.split(' ')).toHaveLength(4);
|
|
expect(geometry.selectorHeight).toBeCloseTo(36, 0);
|
|
expect(geometry.controlWidths[0]).toBeCloseTo(83.33, 0);
|
|
expect(geometry.controlWidths[1]).toBeCloseTo(333.33, 0);
|
|
expect(geometry.logBlockWidth).toBeCloseTo(500, 0);
|
|
expect(geometry.backgroundImage).toContain('back_walnut.jpg');
|
|
expect(geometry.generalBackgroundImage).toContain('back_blue.jpg');
|
|
await persistParityArtifact(page, 'core-battle-center-desktop', geometry);
|
|
|
|
await page.setViewportSize({ width: 500, height: 900 });
|
|
const mobileGeometry = await page.locator('.selector-row').evaluate((element) => ({
|
|
columns: getComputedStyle(element).gridTemplateColumns,
|
|
controlWidths: [...element.children].map((child) => (child as HTMLElement).getBoundingClientRect().width),
|
|
}));
|
|
expect(mobileGeometry.columns.split(' ')).toHaveLength(4);
|
|
expect(mobileGeometry.controlWidths[0]).toBeCloseTo(83.33, 0);
|
|
expect(mobileGeometry.controlWidths[1]).toBeCloseTo(125, 0);
|
|
await persistParityArtifact(page, 'core-battle-center-mobile', mobileGeometry);
|
|
|
|
await page.unrouteAll({ behavior: 'wait' });
|
|
const member: FixtureState = { permission: 'member', myset: 3, settingMutations: [] };
|
|
await install(page, member);
|
|
await page.reload();
|
|
await expect(page.locator('.error')).toContainText('권한이 부족합니다.');
|
|
});
|