343 lines
14 KiB
TypeScript
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();
|
|
});
|