Files
core2026/app/game-frontend/e2e/npcPolicy.spec.ts
T

343 lines
14 KiB
TypeScript

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 = [
...(process.env.FRONTEND_PARITY_IMAGE_ROOT ? [resolve(process.env.FRONTEND_PARITY_IMAGE_ROOT)] : []),
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();
});