Merge branch 'main' into feature/nation-general-lists
# Conflicts: # app/game-frontend/package.json # app/game-frontend/src/router/index.ts
This commit is contained in:
@@ -0,0 +1,371 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
const imageRoots = [resolve(repositoryRoot, '../image/game'), resolve(repositoryRoot, '../../image/game')];
|
||||
|
||||
type AuctionFixture = {
|
||||
failResourceBid?: boolean;
|
||||
resourceBidCount: number;
|
||||
uniqueBidCount: number;
|
||||
};
|
||||
|
||||
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 url = new URL(route.request().url());
|
||||
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||
};
|
||||
|
||||
const readReferenceImage = async (filename: string): Promise<Buffer> => {
|
||||
for (const imageRoot of imageRoots) {
|
||||
try {
|
||||
return await readFile(resolve(imageRoot, filename));
|
||||
} catch {
|
||||
// The main checkout and nested feature worktrees have different parents.
|
||||
}
|
||||
}
|
||||
throw new Error(`Reference image not found: ${filename}`);
|
||||
};
|
||||
|
||||
const overview = {
|
||||
resourceAuctions: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'BUY_RICE',
|
||||
targetCode: '1000',
|
||||
status: 'OPEN',
|
||||
hostGeneralId: 11,
|
||||
hostName: '조조',
|
||||
isCallerHost: false,
|
||||
closeAt: '2026-07-27T02:30:00.000Z',
|
||||
detail: {
|
||||
title: '쌀 1000 경매',
|
||||
amount: 1000,
|
||||
isReverse: false,
|
||||
startBidAmount: 500,
|
||||
finishBidAmount: 1800,
|
||||
},
|
||||
highestBid: {
|
||||
amount: 750,
|
||||
bidderName: '관우',
|
||||
isCaller: false,
|
||||
eventAt: '2026-07-26T01:00:00.000Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'SELL_RICE',
|
||||
targetCode: '900',
|
||||
status: 'OPEN',
|
||||
hostGeneralId: 7,
|
||||
hostName: '유비',
|
||||
isCallerHost: true,
|
||||
closeAt: '2026-07-27T03:00:00.000Z',
|
||||
detail: {
|
||||
title: '금 900 경매',
|
||||
amount: 900,
|
||||
isReverse: false,
|
||||
startBidAmount: 600,
|
||||
finishBidAmount: 1700,
|
||||
},
|
||||
highestBid: null,
|
||||
},
|
||||
],
|
||||
uniqueAuctions: [
|
||||
{
|
||||
id: 10,
|
||||
type: 'UNIQUE_ITEM',
|
||||
targetCode: 'che_무기_12_칠성검',
|
||||
status: 'OPEN',
|
||||
hostGeneralId: null,
|
||||
hostName: '청룡',
|
||||
isCallerHost: false,
|
||||
closeAt: '2026-07-27T04:00:00.000Z',
|
||||
detail: {
|
||||
title: '칠성검 경매',
|
||||
startBidAmount: 5000,
|
||||
remainCloseDateExtensionCnt: 1,
|
||||
availableLatestBidCloseDate: '2026-07-27T04:30:00.000Z',
|
||||
},
|
||||
highestBid: {
|
||||
amount: 5500,
|
||||
bidderName: '백호',
|
||||
isCaller: false,
|
||||
eventAt: '2026-07-26T02:00:00.000Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
type: 'UNIQUE_ITEM',
|
||||
targetCode: 'che_서적_15_손자병법',
|
||||
status: 'FINISHED',
|
||||
hostGeneralId: null,
|
||||
hostName: '현무',
|
||||
isCallerHost: true,
|
||||
closeAt: '2026-07-25T04:00:00.000Z',
|
||||
detail: {
|
||||
title: '손자병법 경매',
|
||||
startBidAmount: 5000,
|
||||
remainCloseDateExtensionCnt: 0,
|
||||
availableLatestBidCloseDate: '2026-07-25T04:30:00.000Z',
|
||||
},
|
||||
highestBid: {
|
||||
amount: 6000,
|
||||
bidderName: '현무',
|
||||
isCaller: true,
|
||||
eventAt: '2026-07-25T03:00:00.000Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
callerAlias: '현무',
|
||||
remainPoint: 9000,
|
||||
recentLogs: [
|
||||
{
|
||||
id: 1,
|
||||
text: '<C>●</>경매 1번 거래가 성사되었습니다.',
|
||||
createdAt: '2026-07-25T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const uniqueDetail = {
|
||||
auction: {
|
||||
id: 10,
|
||||
targetCode: 'che_무기_12_칠성검',
|
||||
status: 'OPEN',
|
||||
hostName: '청룡',
|
||||
isCallerHost: false,
|
||||
closeAt: '2026-07-27T04:00:00.000Z',
|
||||
detail: {
|
||||
title: '칠성검 경매',
|
||||
startBidAmount: 5000,
|
||||
remainCloseDateExtensionCnt: 1,
|
||||
availableLatestBidCloseDate: '2026-07-27T04:30:00.000Z',
|
||||
},
|
||||
},
|
||||
bids: [
|
||||
{
|
||||
id: 101,
|
||||
amount: 5500,
|
||||
bidderName: '백호',
|
||||
isCaller: false,
|
||||
eventAt: '2026-07-26T02:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 100,
|
||||
amount: 5000,
|
||||
bidderName: '현무',
|
||||
isCaller: true,
|
||||
eventAt: '2026-07-26T01:00:00.000Z',
|
||||
},
|
||||
],
|
||||
callerAlias: '현무',
|
||||
remainPoint: 9000,
|
||||
};
|
||||
|
||||
const installFixture = async (page: Page, state: AuctionFixture) => {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem('sammo-game-token', 'ga_auction_playwright');
|
||||
window.localStorage.setItem('sammo-game-profile', 'che:default');
|
||||
});
|
||||
for (const filename of ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg']) {
|
||||
await page.route(`**/image/game/${filename}`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'image/jpeg',
|
||||
body: await readReferenceImage(filename),
|
||||
});
|
||||
});
|
||||
}
|
||||
await page.route('**/che/api/trpc/**', async (route) => {
|
||||
const results = operationNames(route).map((operation) => {
|
||||
if (operation === 'lobby.info') {
|
||||
return response({ myGeneral: { id: 7, name: '유비' } });
|
||||
}
|
||||
if (operation === 'join.getConfig') {
|
||||
return response({});
|
||||
}
|
||||
if (operation === 'auction.getOverview') {
|
||||
return response(overview);
|
||||
}
|
||||
if (operation === 'auction.getUniqueDetail') {
|
||||
return response(uniqueDetail);
|
||||
}
|
||||
if (operation === 'auction.bidBuyRice') {
|
||||
if (state.failResourceBid) {
|
||||
state.failResourceBid = false;
|
||||
return errorResponse(operation, '금이 부족합니다.');
|
||||
}
|
||||
state.resourceBidCount += 1;
|
||||
return response({ ok: true });
|
||||
}
|
||||
if (operation === 'auction.bidUnique') {
|
||||
state.uniqueBidCount += 1;
|
||||
return response({ ok: true });
|
||||
}
|
||||
if (operation === 'auction.openBuyRice' || operation === 'auction.openSellRice') {
|
||||
return response({ auctionId: 20, closeAt: '2026-07-28T00:00:00.000Z' });
|
||||
}
|
||||
return errorResponse(operation, `Unhandled fixture operation: ${operation}`);
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(results),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const gotoAuction = async (page: Page, suffix = 'auction') => {
|
||||
const lobbyResponse = page.waitForResponse((response) => response.url().includes('/trpc/lobby.info'));
|
||||
await page.goto(suffix);
|
||||
await lobbyResponse;
|
||||
await expect(page.locator('#container')).toBeVisible();
|
||||
};
|
||||
|
||||
test('resource auction preserves the legacy desktop structure, geometry, and interaction states', async ({ page }) => {
|
||||
const state = { failResourceBid: true, resourceBidCount: 0, uniqueBidCount: 0 };
|
||||
await installFixture(page, state);
|
||||
await page.setViewportSize({ width: 1000, height: 800 });
|
||||
await gotoAuction(page);
|
||||
await expect(page.getByRole('heading', { name: '경매장', exact: true })).toBeVisible();
|
||||
await expect(page.getByText('쌀 구매', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('쌀 판매', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('단가', { exact: true }).first()).toBeVisible();
|
||||
|
||||
const geometry = await page.locator('#container').evaluate((container) => {
|
||||
const box = (selector: string) => {
|
||||
const rect = container.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||
};
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const row = container.querySelector<HTMLElement>('.resource-row')!;
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
const cells = [...row.children].map((cell) => cell.getBoundingClientRect().width);
|
||||
const button = container.querySelector<HTMLElement>('.tab-button')!;
|
||||
const buttonStyle = getComputedStyle(button);
|
||||
return {
|
||||
container: { x: containerRect.x, width: containerRect.width },
|
||||
topBar: box('.top-back-bar'),
|
||||
row: { width: rowRect.width, height: rowRect.height },
|
||||
cells,
|
||||
button: {
|
||||
height: button.getBoundingClientRect().height,
|
||||
borderRadius: buttonStyle.borderRadius,
|
||||
cursor: buttonStyle.cursor,
|
||||
fontSize: buttonStyle.fontSize,
|
||||
},
|
||||
};
|
||||
});
|
||||
expect(geometry.container).toEqual({ x: 0, width: 1000 });
|
||||
expect(geometry.topBar).toMatchObject({ x: 0, width: 1000, height: 32 });
|
||||
expect(geometry.row).toEqual({ width: 1000, height: 22 });
|
||||
expect(geometry.cells[0]).toBeCloseTo(66.66, 1);
|
||||
expect(geometry.cells[1]).toBeCloseTo(133.34, 1);
|
||||
expect(geometry.cells[6]).toBeCloseTo(200, 1);
|
||||
expect(geometry.button).toEqual({
|
||||
height: 35.5,
|
||||
borderRadius: '5.25px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
});
|
||||
await page.screenshot({ path: 'test-results/auction/resource-desktop-initial.png', fullPage: true });
|
||||
|
||||
const firstRow = page.locator('.resource-row.clickable-row').first();
|
||||
await firstRow.click();
|
||||
const bidInput = page.getByRole('spinbutton', { name: '1번 경매 입찰가' });
|
||||
await bidInput.fill('800');
|
||||
await page.getByRole('button', { name: '입찰', exact: true }).click();
|
||||
await expect(page.getByRole('alert')).toContainText('금이 부족합니다.');
|
||||
await page.screenshot({ path: 'test-results/auction/resource-desktop-error.png', fullPage: true });
|
||||
expect(state.resourceBidCount).toBe(0);
|
||||
|
||||
await page.getByRole('button', { name: '입찰', exact: true }).click();
|
||||
await expect(page.getByRole('status')).toContainText('입찰했습니다.');
|
||||
expect(state.resourceBidCount).toBe(1);
|
||||
|
||||
await firstRow.hover();
|
||||
expect(await firstRow.evaluate((row) => getComputedStyle(row).cursor)).toBe('pointer');
|
||||
await page.screenshot({ path: 'test-results/auction/resource-desktop.png', fullPage: true });
|
||||
});
|
||||
|
||||
test('resource auction keeps the legacy 500px two-row grid', async ({ page }) => {
|
||||
await installFixture(page, { resourceBidCount: 0, uniqueBidCount: 0 });
|
||||
await page.setViewportSize({ width: 500, height: 800 });
|
||||
await gotoAuction(page);
|
||||
|
||||
const geometry = await page
|
||||
.locator('.resource-row')
|
||||
.first()
|
||||
.evaluate((row) => {
|
||||
const origin = row.getBoundingClientRect();
|
||||
const relative = (selector: string) => {
|
||||
const rect = row.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
|
||||
return { x: rect.x - origin.x, y: rect.y - origin.y, width: rect.width, height: rect.height };
|
||||
};
|
||||
return {
|
||||
row: { width: origin.width, height: origin.height },
|
||||
idx: relative('.idx'),
|
||||
host: relative('.host'),
|
||||
amount: relative('.amount'),
|
||||
close: relative('.close-date'),
|
||||
};
|
||||
});
|
||||
expect(geometry.row).toEqual({ width: 500, height: 43 });
|
||||
expect(geometry.idx).toEqual({ x: 0, y: 10.5, width: 41.65625, height: 21 });
|
||||
expect(geometry.host).toEqual({ x: 41.65625, y: 0, width: 125, height: 21 });
|
||||
expect(geometry.amount).toEqual({ x: 41.65625, y: 21, width: 125, height: 21 });
|
||||
expect(geometry.close).toEqual({ x: 416.65625, y: 10.5, width: 83.34375, height: 21 });
|
||||
await page.screenshot({ path: 'test-results/auction/resource-mobile.png', fullPage: true });
|
||||
});
|
||||
|
||||
test('unique auction separates ongoing and finished lists and auto-loads the legacy detail', async ({ page }) => {
|
||||
const state = { resourceBidCount: 0, uniqueBidCount: 0 };
|
||||
await installFixture(page, state);
|
||||
await page.setViewportSize({ width: 1000, height: 800 });
|
||||
await gotoAuction(page, 'auction?type=unique');
|
||||
|
||||
await expect(page.getByRole('heading', { name: '유니크 경매장', exact: true })).toBeVisible();
|
||||
await expect(page.locator('.caller-alias')).toContainText('내 가명: 현무');
|
||||
await expect(page.getByRole('heading', { name: '경매 10번 상세' })).toBeVisible();
|
||||
await expect(page.getByText('최대지연', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: '진행중인 경매 목록' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: '종료된 경매 목록' })).toBeVisible();
|
||||
await expect(page.getByText('남음', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('소진', { exact: true })).toBeVisible();
|
||||
|
||||
const aliasStyle = await page.locator('.caller-alias strong').evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return { color: style.color, fontWeight: style.fontWeight };
|
||||
});
|
||||
expect(aliasStyle).toEqual({ color: 'rgb(0, 255, 255)', fontWeight: '700' });
|
||||
|
||||
const input = page.getByRole('spinbutton', { name: '유산포인트' });
|
||||
await input.fill('5600');
|
||||
page.once('dialog', async (dialog) => {
|
||||
expect(dialog.message()).toBe('칠성검 경매에 5600유산포인트를 입찰하시겠습니까?');
|
||||
await dialog.accept();
|
||||
});
|
||||
await page.getByRole('button', { name: '입찰', exact: true }).click();
|
||||
await expect(page.getByRole('status')).toContainText('입찰이 완료되었습니다.');
|
||||
expect(state.uniqueBidCount).toBe(1);
|
||||
await page.screenshot({ path: 'test-results/auction/unique-desktop.png', fullPage: true });
|
||||
});
|
||||
|
||||
test('resource host cannot bid on the auction opened by its own general', async ({ page }) => {
|
||||
await installFixture(page, { resourceBidCount: 0, uniqueBidCount: 0 });
|
||||
await gotoAuction(page);
|
||||
|
||||
await page.locator('.resource-row.clickable-row').filter({ hasText: '유비' }).click();
|
||||
await expect(page.getByRole('button', { name: '입찰', exact: true })).toBeDisabled();
|
||||
});
|
||||
@@ -0,0 +1,320 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
const imageRoots = [resolve(repositoryRoot, '../image'), resolve(repositoryRoot, '../../image')];
|
||||
const artifactRoot = process.env.BATTLE_SIM_ARTIFACT_DIR;
|
||||
|
||||
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 url = new URL(route.request().url());
|
||||
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||
};
|
||||
|
||||
const readImage = async (relative: string): Promise<Buffer> => {
|
||||
for (const root of imageRoots) {
|
||||
try {
|
||||
return await readFile(resolve(root, relative));
|
||||
} catch {
|
||||
// Main checkout and feature worktrees have different image-root parents.
|
||||
}
|
||||
}
|
||||
throw new Error(`Reference image not found: ${relative}`);
|
||||
};
|
||||
|
||||
const simulatorOptions = {
|
||||
world: { startYear: 190, currentYear: 205, currentMonth: 8 },
|
||||
config: {
|
||||
maxTrainByWar: 120,
|
||||
maxAtmosByWar: 120,
|
||||
maxTrainByCommand: 100,
|
||||
maxAtmosByCommand: 100,
|
||||
},
|
||||
unitSet: {
|
||||
defaultCrewTypeId: 100,
|
||||
crewTypes: [
|
||||
{ id: 100, name: '보병', armType: 1 },
|
||||
{ id: 200, name: '궁병', armType: 2 },
|
||||
],
|
||||
},
|
||||
nationTypes: [{ key: 'che_중립', name: '중립', info: '특별한 효과 없음' }],
|
||||
warTraits: [{ key: 'che_필살', name: '필살', info: '필살 확률 증가' }],
|
||||
personalities: [{ key: 'che_대담', name: '대담', info: '공격적인 성격' }],
|
||||
items: { horse: [], weapon: [], book: [], item: [] },
|
||||
nationLevels: [
|
||||
{ level: 0, name: '방랑군' },
|
||||
{ level: 1, name: '소국' },
|
||||
],
|
||||
cityLevels: [
|
||||
{ level: 1, name: '소도시' },
|
||||
{ level: 5, name: '대도시' },
|
||||
],
|
||||
dexLevels: [
|
||||
{ level: 0, label: 'F', value: 0 },
|
||||
{ level: 1, label: 'E', value: 1000 },
|
||||
],
|
||||
};
|
||||
|
||||
const generalMe = {
|
||||
general: {
|
||||
id: 7,
|
||||
name: '유비',
|
||||
npcState: 0,
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
picture: '22.jpg',
|
||||
imageServer: 0,
|
||||
officerLevel: 12,
|
||||
stats: { leadership: 85, strength: 72, intelligence: 78 },
|
||||
gold: 1000,
|
||||
rice: 8765,
|
||||
crew: 4321,
|
||||
train: 99,
|
||||
atmos: 98,
|
||||
injury: 0,
|
||||
experience: 900,
|
||||
dedication: 100,
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
},
|
||||
city: { id: 1, level: 1, defence: 2222, wall: 3333 },
|
||||
nation: { id: 1, level: 1, tech: 4500, typeCode: 'che_중립', capitalCityId: 1 },
|
||||
settings: {},
|
||||
penalties: {},
|
||||
};
|
||||
|
||||
const importedGeneral = {
|
||||
general: {
|
||||
no: 7,
|
||||
name: '유비',
|
||||
officer_level: 12,
|
||||
explevel: 30,
|
||||
leadership: 85,
|
||||
strength: 72,
|
||||
intel: 78,
|
||||
horse: null,
|
||||
weapon: null,
|
||||
book: null,
|
||||
item: null,
|
||||
injury: 0,
|
||||
rice: 8765,
|
||||
personal: 'che_대담',
|
||||
special2: 'che_필살',
|
||||
crew: 4321,
|
||||
crewtype: 100,
|
||||
atmos: 98,
|
||||
train: 99,
|
||||
dex1: 1000,
|
||||
dex2: 0,
|
||||
dex3: 0,
|
||||
dex4: 0,
|
||||
dex5: 0,
|
||||
defence_train: 90,
|
||||
warnum: 12,
|
||||
killnum: 7,
|
||||
killcrew: 3456,
|
||||
},
|
||||
};
|
||||
|
||||
const simulationResult = {
|
||||
result: true,
|
||||
reason: 'success',
|
||||
datetime: '205-08',
|
||||
avgWar: 5,
|
||||
phase: 13,
|
||||
killed: 1234,
|
||||
maxKilled: 1400,
|
||||
minKilled: 1100,
|
||||
dead: 432,
|
||||
maxDead: 500,
|
||||
minDead: 400,
|
||||
attackerRice: 321,
|
||||
defenderRice: 654,
|
||||
attackerSkills: { 필살: 2 },
|
||||
defendersSkills: [{ 회피: 1 }],
|
||||
lastWarLog: {
|
||||
generalHistoryLog: '',
|
||||
generalActionLog: '',
|
||||
generalBattleResultLog: '<span>유비가 모의전에서 승리했습니다.</span>',
|
||||
generalBattleDetailLog: '<span>필살 발동, 피해 1,234</span>',
|
||||
nationalHistoryLog: '',
|
||||
globalHistoryLog: '',
|
||||
globalActionLog: '',
|
||||
},
|
||||
};
|
||||
|
||||
type Fixture = {
|
||||
hasGeneral: boolean;
|
||||
failNextSimulation?: boolean;
|
||||
queueFirst?: boolean;
|
||||
pollingCount: number;
|
||||
requests: string[];
|
||||
};
|
||||
|
||||
const installImages = async (page: Page) => {
|
||||
for (const filename of ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg']) {
|
||||
await page.route(`**/image/game/${filename}`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'image/jpeg',
|
||||
body: await readImage(`game/${filename}`),
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const installApi = async (page: Page, fixture: Fixture) => {
|
||||
await installImages(page);
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem('sammo-game-token', 'ga_battle_sim_playwright');
|
||||
window.localStorage.setItem('sammo-game-profile', 'che:default');
|
||||
});
|
||||
await page.route('**/che/api/trpc/**', async (route) => {
|
||||
const operations = operationNames(route);
|
||||
const results = operations.map((operation) => {
|
||||
fixture.requests.push(operation);
|
||||
if (operation === 'lobby.info') {
|
||||
return response({
|
||||
year: 205,
|
||||
month: 8,
|
||||
myGeneral: fixture.hasGeneral ? { name: '유비', picture: '22.jpg' } : null,
|
||||
});
|
||||
}
|
||||
if (operation === 'battle.getSimulatorContext') return response(simulatorOptions);
|
||||
if (operation === 'general.me') return response(fixture.hasGeneral ? generalMe : null);
|
||||
if (operation === 'battle.getGeneralList') {
|
||||
return response({
|
||||
myNationId: 1,
|
||||
myGeneralId: 7,
|
||||
nations: [{ id: 1, name: '촉', color: '#8fbc8f' }],
|
||||
generalsByNation: { 1: [{ id: 7, name: '유비', npcState: 0 }] },
|
||||
});
|
||||
}
|
||||
if (operation === 'battle.getGeneralDetail') return response(importedGeneral);
|
||||
if (operation === 'battle.simulate') {
|
||||
if (fixture.failNextSimulation) {
|
||||
fixture.failNextSimulation = false;
|
||||
return errorResponse(operation, '시뮬레이터 입력 오류');
|
||||
}
|
||||
if (fixture.queueFirst) {
|
||||
return response({ status: 'queued', jobId: 'job-playwright' });
|
||||
}
|
||||
return response({ status: 'completed', jobId: 'job-playwright', payload: simulationResult });
|
||||
}
|
||||
if (operation === 'battle.getSimulation') {
|
||||
fixture.pollingCount += 1;
|
||||
if (fixture.pollingCount === 1) {
|
||||
return response({ status: 'queued', jobId: 'job-playwright' });
|
||||
}
|
||||
return response({
|
||||
status: 'completed',
|
||||
jobId: 'job-playwright',
|
||||
payload: simulationResult,
|
||||
});
|
||||
}
|
||||
return errorResponse(operation, `Unhandled battle simulator fixture operation: ${operation}`);
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(results),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const gotoSimulator = async (page: Page) => {
|
||||
await page.goto('battle-simulator');
|
||||
await expect(page.getByRole('heading', { name: '전투 시뮬레이터' })).toBeVisible();
|
||||
await expect(page.getByLabel('시뮬레이터 데이터 안내')).toBeVisible();
|
||||
await expect(page.getByText('출병자 설정')).toBeVisible();
|
||||
};
|
||||
|
||||
test('operates independent/game presets, imports my general, and renders battle logs', async ({ page }) => {
|
||||
const fixture: Fixture = { hasGeneral: true, queueFirst: true, pollingCount: 0, requests: [] };
|
||||
await installApi(page, fixture);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await gotoSimulator(page);
|
||||
|
||||
const notice = page.getByLabel('시뮬레이터 데이터 안내');
|
||||
const noticeRect = await notice.boundingBox();
|
||||
expect(noticeRect?.width).toBeGreaterThan(900);
|
||||
expect(await notice.evaluate((element) => getComputedStyle(element).display)).toBe('flex');
|
||||
|
||||
await page.getByRole('button', { name: '독립 기본값' }).click();
|
||||
await expect(page.getByLabel('연도', { exact: true })).toHaveValue('190');
|
||||
await expect(page.getByLabel('월')).toHaveValue('1');
|
||||
|
||||
await page.getByRole('button', { name: '현재 게임 환경 적용' }).click();
|
||||
await expect(page.getByLabel('연도', { exact: true })).toHaveValue('205');
|
||||
await expect(page.getByLabel('월')).toHaveValue('8');
|
||||
|
||||
await page.getByRole('button', { name: '내 장수를 출병자로' }).click();
|
||||
await expect(page.getByLabel('이름').first()).toHaveValue('유비');
|
||||
await expect(page.getByLabel('병사').first()).toHaveValue('4321');
|
||||
|
||||
const battleButton = page.getByRole('button', { name: '전투', exact: true });
|
||||
await battleButton.hover();
|
||||
expect(await battleButton.evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer');
|
||||
await page.getByLabel('시드').fill('playwright-fixed-seed');
|
||||
await battleButton.click();
|
||||
|
||||
await expect(page.getByText('유비가 모의전에서 승리했습니다.')).toBeVisible();
|
||||
await expect(page.getByText('5', { exact: true })).toBeVisible();
|
||||
expect(fixture.pollingCount).toBe(2);
|
||||
expect(fixture.requests).toContain('battle.getSimulation');
|
||||
|
||||
if (artifactRoot) {
|
||||
await page.screenshot({
|
||||
path: resolve(artifactRoot, 'battle-simulator-core-desktop.png'),
|
||||
fullPage: true,
|
||||
animations: 'disabled',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps simulation available without a game general and preserves input after an API error', async ({ page }) => {
|
||||
const fixture: Fixture = {
|
||||
hasGeneral: false,
|
||||
failNextSimulation: true,
|
||||
pollingCount: 0,
|
||||
requests: [],
|
||||
};
|
||||
await installApi(page, fixture);
|
||||
await page.setViewportSize({ width: 500, height: 900 });
|
||||
await gotoSimulator(page);
|
||||
|
||||
await expect(page).toHaveURL(/battle-simulator/);
|
||||
await expect(page.getByRole('button', { name: '내 장수를 출병자로' })).toBeDisabled();
|
||||
await expect(page.getByRole('button', { name: '서버에서 가져오기' }).first()).toBeDisabled();
|
||||
|
||||
await page.getByLabel('시드').fill('keep-this-seed');
|
||||
await page.getByRole('button', { name: '전투', exact: true }).click();
|
||||
await expect(page.getByText('시뮬레이터 입력 오류')).toBeVisible();
|
||||
await expect(page.getByLabel('시드')).toHaveValue('keep-this-seed');
|
||||
|
||||
await page.getByRole('button', { name: '전투', exact: true }).click();
|
||||
await expect(page.getByText('유비가 모의전에서 승리했습니다.')).toBeVisible();
|
||||
await expect(page.getByText('시뮬레이터 입력 오류')).toHaveCount(0);
|
||||
|
||||
const notice = page.getByLabel('시뮬레이터 데이터 안내');
|
||||
expect(await notice.evaluate((element) => getComputedStyle(element).flexDirection)).toBe('column');
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
|
||||
if (artifactRoot) {
|
||||
await page.screenshot({
|
||||
path: resolve(artifactRoot, 'battle-simulator-core-mobile.png'),
|
||||
fullPage: true,
|
||||
animations: 'disabled',
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const refBaseUrl = process.env.REF_BATTLE_SIM_URL;
|
||||
const refPasswordFile = process.env.REF_USER_PASSWORD_FILE;
|
||||
const refUsername = process.env.REF_USER_ID ?? 'refuser1';
|
||||
const artifactRoot = process.env.BATTLE_SIM_ARTIFACT_DIR;
|
||||
const refTest = refBaseUrl && refPasswordFile ? test : test.skip;
|
||||
|
||||
refTest('runs the legacy simulator in the same Chromium and captures its rendered contract', async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
if (!refBaseUrl || !refPasswordFile) {
|
||||
throw new Error('REF_BATTLE_SIM_URL and REF_USER_PASSWORD_FILE are required');
|
||||
}
|
||||
const password = (await readFile(refPasswordFile, 'utf8')).trim();
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await page.goto(refBaseUrl, { waitUntil: 'networkidle' });
|
||||
await page.locator('#username').fill(refUsername);
|
||||
await page.locator('#password').fill(password);
|
||||
const globalSalt = await page.locator('#global_salt').inputValue();
|
||||
const passwordHash = createHash('sha512')
|
||||
.update(globalSalt + password + globalSalt)
|
||||
.digest('hex');
|
||||
const loginResponse = await page
|
||||
.context()
|
||||
.request.post(new URL('api.php?path=Login/LoginByID', refBaseUrl).toString(), {
|
||||
data: { username: refUsername, password: passwordHash },
|
||||
});
|
||||
expect(loginResponse.status()).toBe(200);
|
||||
await expect(loginResponse.json()).resolves.toMatchObject({ result: true });
|
||||
|
||||
await page.goto(new URL('hwe/battle_simulator.php', refBaseUrl).toString(), {
|
||||
waitUntil: 'networkidle',
|
||||
});
|
||||
const battleButton = page.locator('.btn-begin_battle');
|
||||
await expect(battleButton).toBeVisible();
|
||||
const container = page.locator('#container');
|
||||
const rect = await container.boundingBox();
|
||||
expect(rect?.width).toBeGreaterThanOrEqual(995);
|
||||
expect(rect?.width).toBeLessThanOrEqual(1005);
|
||||
|
||||
// A login with no game general leaves the legacy nation selects without a
|
||||
// selected option. Choose the first legal independent value before running.
|
||||
await page.locator('.form_nation_type').evaluateAll((elements) => {
|
||||
for (const element of elements) {
|
||||
const select = element as HTMLSelectElement;
|
||||
select.selectedIndex = 0;
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
});
|
||||
await expect(page.locator('.form_nation_type').first()).not.toHaveValue('');
|
||||
|
||||
await battleButton.hover();
|
||||
expect(await battleButton.evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer');
|
||||
const simulationResponse = page.waitForResponse(
|
||||
(response) => response.url().includes('/j_simulate_battle.php') && response.status() === 200,
|
||||
{ timeout: 90_000 }
|
||||
);
|
||||
await battleButton.click();
|
||||
await simulationResponse;
|
||||
await expect(page.locator('#generalBattleResultLog')).not.toBeEmpty();
|
||||
|
||||
if (artifactRoot) {
|
||||
await page.screenshot({
|
||||
path: resolve(artifactRoot, 'battle-simulator-ref-desktop.png'),
|
||||
fullPage: true,
|
||||
animations: 'disabled',
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -141,7 +141,8 @@ const parseSort = (route: Route): number => {
|
||||
|
||||
const install = async (
|
||||
page: Page,
|
||||
mode: 'general' | 'no-general' | 'error-after-load' = 'general'
|
||||
mode: 'general' | 'no-general' | 'error-after-load' = 'general',
|
||||
accessPages: string[] = []
|
||||
) => {
|
||||
let generalDirectoryCalls = 0;
|
||||
await page.addInitScript(() => {
|
||||
@@ -159,12 +160,21 @@ const install = async (
|
||||
route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') })
|
||||
);
|
||||
await page.route('**/che/api/trpc/**', async (route) => {
|
||||
const results = operationNames(route).map((operation) => {
|
||||
const requestBody = route.request().postDataJSON() as
|
||||
| Record<string, { json?: { page?: unknown }; page?: unknown }>
|
||||
| undefined;
|
||||
const results = operationNames(route).map((operation, operationIndex) => {
|
||||
if (operation === 'lobby.info') {
|
||||
return response({ myGeneral: mode === 'no-general' ? null : { id: 1, name: '조회자' } });
|
||||
}
|
||||
if (operation === 'join.getConfig') return response({});
|
||||
if (operation === 'world.getNationDirectory') return response(nationDirectory);
|
||||
if (operation === 'public.recordAccess') {
|
||||
const payload = requestBody?.[String(operationIndex)];
|
||||
const pageName = payload?.json?.page ?? payload?.page;
|
||||
if (typeof pageName === 'string') accessPages.push(pageName);
|
||||
return response({ recorded: true });
|
||||
}
|
||||
if (operation === 'world.getGeneralDirectory') {
|
||||
generalDirectoryCalls += 1;
|
||||
if (mode === 'error-after-load' && generalDirectoryCalls > 1) {
|
||||
@@ -191,7 +201,8 @@ const install = async (
|
||||
};
|
||||
|
||||
test('nation and general directories preserve the fixed legacy Chromium geometry', async ({ page }) => {
|
||||
await install(page);
|
||||
const accessPages: string[] = [];
|
||||
await install(page, 'general', accessPages);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
|
||||
const measurements: Record<string, unknown> = {};
|
||||
@@ -272,6 +283,7 @@ test('nation and general directories preserve the fixed legacy Chromium geometry
|
||||
).toBe(65);
|
||||
}
|
||||
}
|
||||
await expect.poll(() => accessPages).toEqual(expect.arrayContaining(['nation-list', 'general-list']));
|
||||
|
||||
const header = page.locator('.general-table thead td').first();
|
||||
expect(await header.evaluate((element) => getComputedStyle(element).backgroundImage)).toContain('back_green.jpg');
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
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>>;
|
||||
accessPages: string[];
|
||||
};
|
||||
|
||||
type TrpcRequestPayload = {
|
||||
json?: Record<string, unknown>;
|
||||
input?: { json?: 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', 'ga_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 rawRequestBody: unknown = route.request().postData() ? route.request().postDataJSON() : {};
|
||||
const requestBody =
|
||||
rawRequestBody && typeof rawRequestBody === 'object' ? (rawRequestBody as Record<string, unknown>) : {};
|
||||
const results = operations.map((operation, operationIndex) => {
|
||||
const rawPayload =
|
||||
requestBody[String(operationIndex)] ?? (operations.length === 1 ? requestBody : undefined);
|
||||
const payload =
|
||||
rawPayload && typeof rawPayload === 'object' ? (rawPayload as TrpcRequestPayload) : undefined;
|
||||
const jsonInput =
|
||||
payload?.json ?? payload?.input?.json ?? (payload as Record<string, unknown> | undefined) ?? {};
|
||||
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 === 'public.getTraffic')
|
||||
return response({
|
||||
history: [
|
||||
{ year: 185, month: 1, date: '2026-01-01T00:00:00.000Z', refresh: 120, online: 8 },
|
||||
{ year: 185, month: 2, date: '2026-01-01T00:10:00.000Z', refresh: 240, online: 12 },
|
||||
],
|
||||
maxRefresh: 240,
|
||||
maxOnline: 12,
|
||||
suspects: [
|
||||
{ generalId: null, name: '합계', refresh: 360, refreshScoreTotal: 36 },
|
||||
{ generalId: 7, name: '검증장수', refresh: 240, refreshScoreTotal: 24 },
|
||||
],
|
||||
});
|
||||
if (operation === 'general.getMyLog')
|
||||
return response({ type: 'generalAction', logs: [{ id: 1, text: '<Y>기록</>' }] });
|
||||
if (operation === 'general.setMySetting') {
|
||||
state.settingMutations.push(jsonInput);
|
||||
state.myset = Math.max(0, state.myset - 1);
|
||||
return response({ ok: true });
|
||||
}
|
||||
if (operation === 'public.recordAccess') {
|
||||
const pageName = typeof jsonInput.page === 'string' ? jsonInput.page : null;
|
||||
if (pageName) state.accessPages.push(pageName);
|
||||
return response({ recorded: 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 public 1016px chart geometry', async ({ page }) => {
|
||||
const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [], accessPages: [] };
|
||||
await install(page, state);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
await page.goto('traffic');
|
||||
await expect(page.locator('.chart-title').first()).toHaveText('접 속 량');
|
||||
await expect.poll(() => state.accessPages).toContain('traffic');
|
||||
|
||||
const geometry = await page.locator('#traffic-container').evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const title = element.querySelector<HTMLElement>('.title-table')!.getBoundingClientRect();
|
||||
const charts = [...element.querySelectorAll<HTMLElement>('.chart-table')].map((chart) =>
|
||||
chart.getBoundingClientRect()
|
||||
);
|
||||
const row = element.querySelector<HTMLElement>('.chart-row')!.getBoundingClientRect();
|
||||
const bar = element.querySelector<HTMLElement>('.big-bar')!.getBoundingClientRect();
|
||||
const suspect = element.querySelector<HTMLElement>('.suspect-table')!.getBoundingClientRect();
|
||||
return {
|
||||
width: rect.width,
|
||||
minWidth: getComputedStyle(element).minWidth,
|
||||
fontSize: getComputedStyle(element).fontSize,
|
||||
fontFamily: getComputedStyle(element).fontFamily,
|
||||
titleWidth: title.width,
|
||||
chartWidths: charts.map((chart) => chart.width),
|
||||
chartGap: charts[1]!.x - charts[0]!.right,
|
||||
rowHeight: row.height,
|
||||
barHeight: bar.height,
|
||||
suspectWidth: suspect.width,
|
||||
};
|
||||
});
|
||||
expect(geometry.width).toBe(1016);
|
||||
expect(geometry.minWidth).toBe('1016px');
|
||||
expect(geometry.fontSize).toBe('14px');
|
||||
expect(geometry.fontFamily).toContain('Pretendard');
|
||||
expect(geometry.titleWidth).toBe(1000);
|
||||
expect(geometry.chartWidths).toEqual([483, 483]);
|
||||
expect(geometry.chartGap).toBe(26);
|
||||
expect(geometry.rowHeight).toBe(31);
|
||||
expect(geometry.barHeight).toBe(30);
|
||||
expect(geometry.suspectWidth).toBeGreaterThanOrEqual(994);
|
||||
await persistParityArtifact(page, 'traffic-desktop', geometry);
|
||||
|
||||
await page.setViewportSize({ width: 500, height: 900 });
|
||||
const mobileWidth = await page
|
||||
.locator('#traffic-container')
|
||||
.evaluate((element) => element.getBoundingClientRect().width);
|
||||
expect(mobileWidth).toBe(1016);
|
||||
});
|
||||
|
||||
test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in place', async ({ page }) => {
|
||||
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
|
||||
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();
|
||||
await expect.poll(() => state.accessPages).toContain('my-page');
|
||||
|
||||
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: [], accessPages: [] };
|
||||
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: [], accessPages: [] };
|
||||
await install(page, member);
|
||||
await page.reload();
|
||||
await expect(page.locator('.error')).toContainText('권한이 부족합니다.');
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
import { mkdir, readFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
type FixtureState = {
|
||||
permissionLevel: number;
|
||||
failNextMutation?: boolean;
|
||||
failLoad?: boolean;
|
||||
mutations: string[];
|
||||
};
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
const artifactRoot = process.env.NPC_POLICY_PARITY_ARTIFACT_DIR
|
||||
? resolve(process.env.NPC_POLICY_PARITY_ARTIFACT_DIR)
|
||||
: null;
|
||||
const imageRoots = [resolve(repositoryRoot, '../image'), resolve(repositoryRoot, '../../image')];
|
||||
const referenceAsset = async (relativePath: string): Promise<Buffer> => {
|
||||
for (const root of imageRoots) {
|
||||
try {
|
||||
return await readFile(resolve(root, relativePath));
|
||||
} catch {
|
||||
// Nested worktrees and the primary checkout have different image parents.
|
||||
}
|
||||
}
|
||||
throw new Error(`Reference image not found: ${relativePath}`);
|
||||
};
|
||||
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
const errorResponse = (path: string, message: string, code = 'BAD_REQUEST') => ({
|
||||
error: { message, code: -32000, data: { code, httpStatus: code === 'FORBIDDEN' ? 403 : 400, path } },
|
||||
});
|
||||
const operationName = (route: Route): string => {
|
||||
const url = new URL(route.request().url());
|
||||
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6));
|
||||
};
|
||||
const fulfillJson = (route: Route, body: unknown) =>
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
|
||||
|
||||
const nationPriority = [
|
||||
'불가침제의',
|
||||
'선전포고',
|
||||
'천도',
|
||||
'유저장긴급포상',
|
||||
'부대전방발령',
|
||||
'유저장구출발령',
|
||||
'유저장후방발령',
|
||||
'부대유저장후방발령',
|
||||
'유저장전방발령',
|
||||
'유저장포상',
|
||||
'부대구출발령',
|
||||
'부대후방발령',
|
||||
'NPC긴급포상',
|
||||
'NPC구출발령',
|
||||
'NPC후방발령',
|
||||
'NPC포상',
|
||||
'NPC전방발령',
|
||||
'유저장내정발령',
|
||||
'NPC내정발령',
|
||||
'NPC몰수',
|
||||
];
|
||||
const generalPriority = [
|
||||
'NPC사망대비',
|
||||
'귀환',
|
||||
'금쌀구매',
|
||||
'출병',
|
||||
'긴급내정',
|
||||
'전투준비',
|
||||
'전방워프',
|
||||
'NPC헌납',
|
||||
'징병',
|
||||
'후방워프',
|
||||
'전쟁내정',
|
||||
'소집해제',
|
||||
'일반내정',
|
||||
'내정워프',
|
||||
];
|
||||
const policy = {
|
||||
reqNationGold: 10_000,
|
||||
reqNationRice: 12_000,
|
||||
CombatForce: {},
|
||||
SupportForce: [],
|
||||
DevelopForce: [],
|
||||
reqHumanWarUrgentGold: 0,
|
||||
reqHumanWarUrgentRice: 0,
|
||||
reqHumanWarRecommandGold: 0,
|
||||
reqHumanWarRecommandRice: 0,
|
||||
reqHumanDevelGold: 10_000,
|
||||
reqHumanDevelRice: 10_000,
|
||||
reqNPCWarGold: 0,
|
||||
reqNPCWarRice: 0,
|
||||
reqNPCDevelGold: 0,
|
||||
reqNPCDevelRice: 500,
|
||||
minimumResourceActionAmount: 1_000,
|
||||
maximumResourceActionAmount: 10_000,
|
||||
minNPCWarLeadership: 40,
|
||||
minWarCrew: 1_500,
|
||||
minNPCRecruitCityPopulation: 50_000,
|
||||
safeRecruitCityPopulationRatio: 0.5,
|
||||
properWarTrainAtmos: 90,
|
||||
cureThreshold: 10,
|
||||
};
|
||||
|
||||
const policyFixture = (state: FixtureState) => ({
|
||||
nationId: 1,
|
||||
nationName: '위',
|
||||
nationLevel: 3,
|
||||
defaultNationPolicy: policy,
|
||||
currentNationPolicy: policy,
|
||||
zeroPolicy: {
|
||||
...policy,
|
||||
reqHumanWarUrgentGold: 7_600,
|
||||
reqHumanWarUrgentRice: 7_600,
|
||||
reqHumanWarRecommandGold: 15_200,
|
||||
reqHumanWarRecommandRice: 15_200,
|
||||
reqNPCWarGold: 2_700,
|
||||
reqNPCWarRice: 2_700,
|
||||
reqNPCDevelGold: 540,
|
||||
},
|
||||
defaultNationPriority: nationPriority,
|
||||
currentNationPriority: nationPriority,
|
||||
availableNationPriorityItems: nationPriority,
|
||||
defaultGeneralActionPriority: generalPriority,
|
||||
currentGeneralActionPriority: generalPriority,
|
||||
availableGeneralActionPriorityItems: generalPriority,
|
||||
lastSetters: {
|
||||
policy: { setter: null, date: null },
|
||||
nation: { setter: null, date: null },
|
||||
general: { setter: null, date: null },
|
||||
},
|
||||
defaultStatMax: 70,
|
||||
defaultStatNpcMax: 75,
|
||||
permissionLevel: state.permissionLevel,
|
||||
});
|
||||
|
||||
const installFixture = async (page: Page, state: FixtureState) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('sammo-game-token', 'ga_npc_policy_playwright');
|
||||
localStorage.setItem('sammo-game-profile', 'che:default');
|
||||
});
|
||||
for (const filename of ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg']) {
|
||||
await page.route(`**/image/game/${filename}`, async (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'image/jpeg',
|
||||
body: await referenceAsset(`game/${filename}`),
|
||||
})
|
||||
);
|
||||
}
|
||||
await page.route('**/che/api/trpc/**', async (route) => {
|
||||
const operations = operationName(route).split(',');
|
||||
const results = operations.map((operation) => {
|
||||
if (operation === 'lobby.info') return response({ myGeneral: { id: 22, name: '정책담당' } });
|
||||
if (operation === 'join.getConfig') return response({});
|
||||
if (operation === 'npc.getPolicy') {
|
||||
return state.failLoad
|
||||
? errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN')
|
||||
: response(policyFixture(state));
|
||||
}
|
||||
if (
|
||||
operation === 'npc.setNationPolicy' ||
|
||||
operation === 'npc.setNationPriority' ||
|
||||
operation === 'npc.setGeneralPriority'
|
||||
) {
|
||||
state.mutations.push(operation);
|
||||
if (state.failNextMutation) {
|
||||
state.failNextMutation = false;
|
||||
return errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN');
|
||||
}
|
||||
return response({ ok: true });
|
||||
}
|
||||
return errorResponse(operation, `Unhandled fixture operation: ${operation}`);
|
||||
});
|
||||
await fulfillJson(route, results);
|
||||
});
|
||||
};
|
||||
|
||||
const gotoPolicy = async (page: Page) => {
|
||||
await page.goto('npc-control');
|
||||
};
|
||||
|
||||
const screenshot = async (page: Page, name: string) => {
|
||||
if (!artifactRoot) return;
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
await page.screenshot({ path: resolve(artifactRoot, name), fullPage: true });
|
||||
};
|
||||
|
||||
test('desktop geometry, typography, textures, drag, focus, tooltip, and successful save match the reference', async ({
|
||||
page,
|
||||
}) => {
|
||||
const state: FixtureState = { permissionLevel: 4, mutations: [] };
|
||||
await installFixture(page, state);
|
||||
await page.setViewportSize({ width: 1000, height: 900 });
|
||||
await gotoPolicy(page);
|
||||
await expect(page.locator('#container')).toBeVisible();
|
||||
|
||||
const computed = await page.evaluate(() => {
|
||||
const measure = (selector: string) => {
|
||||
const element = document.querySelector<HTMLElement>(selector)!;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
display: style.display,
|
||||
gridTemplateColumns: style.gridTemplateColumns,
|
||||
fontFamily: style.fontFamily,
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
color: style.color,
|
||||
backgroundImage: style.backgroundImage,
|
||||
backgroundColor: style.backgroundColor,
|
||||
};
|
||||
};
|
||||
return {
|
||||
body: measure('body'),
|
||||
container: measure('#container'),
|
||||
topBar: measure('.top-back-bar'),
|
||||
section: measure('.section_bar'),
|
||||
form: measure('.form_list'),
|
||||
field: measure('.policy-field'),
|
||||
input: measure('.field-row input'),
|
||||
control: measure('.control_bar'),
|
||||
reset: measure('.reset_btn'),
|
||||
submit: measure('.submit_btn'),
|
||||
priorityPanel: measure('.priority-panel'),
|
||||
priorityList: measure('.priority-list'),
|
||||
inactiveHeader: measure('.inactive-header'),
|
||||
activeItem: measure('.priority-column:nth-child(2) .priority-item'),
|
||||
help: measure('.help-button'),
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
};
|
||||
});
|
||||
|
||||
expect(computed.body).toMatchObject({ width: 1000, fontSize: '14px', lineHeight: '21px' });
|
||||
expect(computed.body.fontFamily).toContain('Pretendard');
|
||||
expect(computed.container).toMatchObject({ x: 0, y: 32, width: 1000 });
|
||||
expect(computed.container.backgroundImage).toContain('back_walnut.jpg');
|
||||
expect(computed.topBar).toMatchObject({ width: 1000, height: 32 });
|
||||
expect(computed.section).toMatchObject({ x: 1, y: 33, width: 998, height: 23 });
|
||||
expect(computed.section.backgroundImage).toContain('back_green.jpg');
|
||||
expect(computed.form).toMatchObject({ x: 9, width: 982 });
|
||||
expect(computed.form.gridTemplateColumns).toBe('491px 491px');
|
||||
expect(computed.field.width).toBeCloseTo(491, 0);
|
||||
expect(computed.input).toMatchObject({ width: 224, height: 34 });
|
||||
expect(computed.reset).toMatchObject({ width: 150, height: 35.5, backgroundColor: 'rgb(48, 48, 48)' });
|
||||
expect(computed.submit).toMatchObject({ width: 150, height: 35.5, backgroundColor: 'rgb(55, 90, 127)' });
|
||||
expect(computed.priorityPanel.width).toBeCloseTo(499, 0);
|
||||
expect(computed.priorityList.width).toBeCloseTo(229, 0);
|
||||
expect(computed.inactiveHeader).toMatchObject({ height: 37, backgroundColor: 'rgb(214, 214, 214)' });
|
||||
expect(computed.activeItem.height).toBe(37);
|
||||
expect(computed.help).toMatchObject({ width: 24, height: 22.375 });
|
||||
expect(computed.documentWidth).toBe(1000);
|
||||
await screenshot(page, 'core-npc-policy-desktop-baseline.png');
|
||||
|
||||
const goldInput = page.getByLabel('국가 권장 금');
|
||||
await goldInput.focus();
|
||||
await expect(goldInput).toBeFocused();
|
||||
expect(await goldInput.evaluate((element) => getComputedStyle(element).outlineStyle)).not.toBe('none');
|
||||
|
||||
const help = page.getByRole('button', { name: '불가침제의 설명' });
|
||||
await help.hover();
|
||||
await expect.poll(() => help.evaluate((element) => getComputedStyle(element, '::after').opacity)).toBe('1');
|
||||
|
||||
const active = page.locator('.priority-panel').first().locator('.priority-column').nth(1).getByText('불가침제의');
|
||||
await active.dragTo(
|
||||
page.locator('.priority-panel').first().locator('.priority-column').first().locator('.priority-list')
|
||||
);
|
||||
await expect(
|
||||
page.locator('.priority-panel').first().locator('.priority-column').first().getByText('불가침제의')
|
||||
).toBeVisible();
|
||||
|
||||
await goldInput.fill('12345');
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await page.locator('#container > .control_bar').getByRole('button', { name: '설정' }).click();
|
||||
await expect(page.getByRole('status')).toContainText('NPC 정책이 반영되었습니다.');
|
||||
expect(state.mutations).toContain('npc.setNationPolicy');
|
||||
await screenshot(page, 'core-npc-policy-desktop.png');
|
||||
});
|
||||
|
||||
test('500px layout stacks policy fields and priority panels like the reference', async ({ page }) => {
|
||||
await installFixture(page, { permissionLevel: 4, mutations: [] });
|
||||
await page.setViewportSize({ width: 500, height: 900 });
|
||||
await gotoPolicy(page);
|
||||
await expect(page.locator('#container')).toBeVisible();
|
||||
|
||||
const geometry = await page.evaluate(() => {
|
||||
const rect = (selector: string) => {
|
||||
const value = document.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
|
||||
return { x: value.x, y: value.y, width: value.width, height: value.height };
|
||||
};
|
||||
return {
|
||||
container: rect('#container'),
|
||||
form: rect('.form_list'),
|
||||
firstField: rect('.policy-field'),
|
||||
panels: [...document.querySelectorAll<HTMLElement>('.priority-panel')].map((element) => {
|
||||
const value = element.getBoundingClientRect();
|
||||
return { x: value.x, y: value.y, width: value.width };
|
||||
}),
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
};
|
||||
});
|
||||
|
||||
expect(geometry.container).toMatchObject({ x: 0, y: 32, width: 500 });
|
||||
expect(geometry.form).toMatchObject({ x: 9, width: 482 });
|
||||
expect(geometry.firstField.width).toBeCloseTo(482, 0);
|
||||
expect(geometry.panels).toHaveLength(2);
|
||||
expect(geometry.panels[0]).toMatchObject({ x: 1, width: 498 });
|
||||
expect(geometry.panels[1]?.x).toBe(1);
|
||||
expect(geometry.panels[1]?.y).toBeGreaterThan(geometry.panels[0]?.y ?? 0);
|
||||
expect(geometry.documentWidth).toBe(500);
|
||||
await screenshot(page, 'core-npc-policy-mobile.png');
|
||||
});
|
||||
|
||||
test('a read-level user sees enabled legacy controls but a forbidden save retains the draft', async ({ page }) => {
|
||||
const state: FixtureState = { permissionLevel: 1, failNextMutation: true, mutations: [] };
|
||||
await installFixture(page, state);
|
||||
await gotoPolicy(page);
|
||||
|
||||
const input = page.getByLabel('국가 권장 금');
|
||||
await expect(input).toBeEnabled();
|
||||
await input.fill('23456');
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await page.locator('#container > .control_bar').getByRole('button', { name: '설정' }).click();
|
||||
await expect(page.getByRole('alert')).toContainText('권한이 부족합니다.');
|
||||
await expect(input).toHaveValue('23456');
|
||||
expect(state.mutations).toEqual(['npc.setNationPolicy']);
|
||||
});
|
||||
|
||||
test('a user below secret read permission receives a recoverable page error', async ({ page }) => {
|
||||
await installFixture(page, { permissionLevel: 0, failLoad: true, mutations: [] });
|
||||
await gotoPolicy(page);
|
||||
await expect(page.getByRole('alert')).toContainText('권한이 부족합니다.');
|
||||
await expect(page.locator('#container')).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '다시 시도' })).toBeVisible();
|
||||
});
|
||||
@@ -12,9 +12,14 @@ export default defineConfig({
|
||||
'troop.spec.ts',
|
||||
'board.spec.ts',
|
||||
'inGameInfo.spec.ts',
|
||||
'inGameMenus.spec.ts',
|
||||
'nationOffices.spec.ts',
|
||||
'directoryLists.spec.ts',
|
||||
'nationGeneralSecret.spec.ts',
|
||||
'npcPolicy.spec.ts',
|
||||
'auction.spec.ts',
|
||||
'battleSimulator.spec.ts',
|
||||
'battleSimulatorRef.spec.ts',
|
||||
],
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
|
||||
Reference in New Issue
Block a user