feat: complete nation personnel and finance parity
This commit is contained in:
@@ -0,0 +1,420 @@
|
||||
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 Role = 'leader' | 'head' | 'member';
|
||||
type FixtureState = {
|
||||
role: Role;
|
||||
failNextRate?: boolean;
|
||||
failPersonnelLoad?: boolean;
|
||||
rate: number;
|
||||
appointedGeneralId?: number;
|
||||
};
|
||||
|
||||
const artifactRoot = process.env.OFFICE_PARITY_ARTIFACT_DIR ? resolve(process.env.OFFICE_PARITY_ARTIFACT_DIR) : null;
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
const imageRoots = [resolve(repositoryRoot, '../image/game'), resolve(repositoryRoot, '../../image/game')];
|
||||
const referenceImage = async (filename: string): Promise<Buffer> => {
|
||||
for (const root of imageRoots) {
|
||||
try {
|
||||
return await readFile(resolve(root, filename));
|
||||
} catch {
|
||||
// Nested worktrees and the primary checkout have different image parents.
|
||||
}
|
||||
}
|
||||
throw new Error(`Reference image not found: ${filename}`);
|
||||
};
|
||||
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 general = (id: number, name: string, officerLevel: number, overrides: Record<string, unknown> = {}) => ({
|
||||
id,
|
||||
name,
|
||||
npcState: 0,
|
||||
officerLevel,
|
||||
cityId: 1,
|
||||
cityName: '허창',
|
||||
troopId: 0,
|
||||
troopName: null,
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
officerCity: officerLevel >= 2 && officerLevel <= 4 ? 1 : 0,
|
||||
officerCityName: officerLevel >= 2 && officerLevel <= 4 ? '허창' : null,
|
||||
stats: { leadership: 70, strength: 70, intelligence: 70 },
|
||||
experience: 100,
|
||||
dedication: 200,
|
||||
injury: 0,
|
||||
gold: 1000,
|
||||
rice: 1000,
|
||||
crew: 100,
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
belong: 10,
|
||||
permission: 'normal',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const personnelFixture = (state: FixtureState) => {
|
||||
const officerLevel = state.role === 'leader' ? 12 : state.role === 'head' ? 5 : 1;
|
||||
const fullGenerals = [
|
||||
general(1, '조조', 12),
|
||||
general(2, '순욱', 11),
|
||||
general(3, '하후돈', 4),
|
||||
general(4, '곽가', 3),
|
||||
general(5, '정욱', 2),
|
||||
general(6, '장료', 1),
|
||||
general(7, '허저', 1, { permission: 'ambassador' }),
|
||||
];
|
||||
const visibleGenerals =
|
||||
state.role === 'member' ? fullGenerals.filter((entry) => entry.officerLevel >= 2) : fullGenerals;
|
||||
return {
|
||||
me: {
|
||||
id: state.role === 'leader' ? 1 : state.role === 'head' ? 2 : 6,
|
||||
officerLevel,
|
||||
canManage: state.role !== 'member',
|
||||
canChangePermissions: state.role === 'leader',
|
||||
canKick: state.role !== 'member',
|
||||
},
|
||||
nation: {
|
||||
id: 1,
|
||||
name: '작위검증국',
|
||||
color: '#777777',
|
||||
level: 3,
|
||||
typeCode: 'che_법가',
|
||||
capitalCityId: 1,
|
||||
chiefSet: 0,
|
||||
},
|
||||
chiefStatMin: 65,
|
||||
generals: visibleGenerals,
|
||||
chiefAssignments: { 12: fullGenerals[0], 11: fullGenerals[1] },
|
||||
cityAssignments: [
|
||||
{
|
||||
id: 1,
|
||||
name: '허창',
|
||||
level: 7,
|
||||
region: 2,
|
||||
officerSet: 1 << 3,
|
||||
officers: { 4: fullGenerals[2], 3: fullGenerals[3], 2: fullGenerals[4] },
|
||||
},
|
||||
{ id: 2, name: '낙양', level: 6, region: 2, officerSet: 0, officers: { 4: null, 3: null, 2: null } },
|
||||
],
|
||||
awards: {
|
||||
tigers: [{ id: 3, name: '하후돈', value: 12 }],
|
||||
eagles: [{ id: 4, name: '곽가', value: 9 }],
|
||||
},
|
||||
permissionCandidates:
|
||||
state.role === 'leader'
|
||||
? {
|
||||
ambassadors: [
|
||||
{ id: 6, name: '장료', npcState: 0, permission: 'normal', maxPermission: 4 },
|
||||
{ id: 7, name: '허저', npcState: 0, permission: 'ambassador', maxPermission: 4 },
|
||||
],
|
||||
auditors: [{ id: 6, name: '장료', npcState: 0, permission: 'normal', maxPermission: 4 }],
|
||||
}
|
||||
: { ambassadors: [], auditors: [] },
|
||||
};
|
||||
};
|
||||
|
||||
const financeFixture = (state: FixtureState) => ({
|
||||
editable: state.role === 'leader' || state.role === 'head',
|
||||
nationMsg: '<p>백성을 편안하게 한다.</p>',
|
||||
scoutMsg: '<p>천하의 인재를 구합니다.</p>',
|
||||
nationId: 1,
|
||||
officerLevel: state.role === 'leader' ? 12 : state.role === 'head' ? 5 : 1,
|
||||
year: 185,
|
||||
month: 1,
|
||||
nationsList: [
|
||||
{
|
||||
id: 1,
|
||||
name: '작위검증국',
|
||||
color: '#777777',
|
||||
level: 3,
|
||||
power: 1234,
|
||||
generalCount: 7,
|
||||
cityCount: 2,
|
||||
diplomacy: { state: 7, term: null },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '촉',
|
||||
color: '#008000',
|
||||
level: 3,
|
||||
power: 987,
|
||||
generalCount: 5,
|
||||
cityCount: 1,
|
||||
diplomacy: { state: 2, term: 6 },
|
||||
},
|
||||
],
|
||||
gold: 10000,
|
||||
rice: 20000,
|
||||
income: { gold: { city: 2000, war: 300 }, rice: { city: 1800, wall: 200 } },
|
||||
outcome: 1000,
|
||||
policy: { rate: state.rate, bill: 100, secretLimit: 3, blockScout: false, blockWar: false },
|
||||
warSettingCnt: { remain: 5, inc: 2, max: 10 },
|
||||
});
|
||||
|
||||
const installFixture = async (page: Page, state: FixtureState) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('sammo-game-token', 'ga_office_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 referenceImage(filename) })
|
||||
);
|
||||
}
|
||||
await page.route('**/image/icons/**', async (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'image/jpeg',
|
||||
body: await readFile(resolve(repositoryRoot, '../../image/icons/default.jpg')),
|
||||
})
|
||||
);
|
||||
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: 1, name: '조조' } });
|
||||
if (operation === 'join.getConfig') return response({});
|
||||
if (operation === 'nation.getPersonnelInfo') {
|
||||
return state.failPersonnelLoad
|
||||
? errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN')
|
||||
: response(personnelFixture(state));
|
||||
}
|
||||
if (operation === 'nation.getStratFinan') return response(financeFixture(state));
|
||||
if (operation === 'nation.appoint') {
|
||||
state.appointedGeneralId = 6;
|
||||
return response({ ok: true });
|
||||
}
|
||||
if (operation === 'nation.kick' || operation === 'nation.changePermission') return response({ ok: true });
|
||||
if (operation === 'nation.setRate') {
|
||||
if (state.failNextRate) {
|
||||
state.failNextRate = false;
|
||||
return errorResponse(operation, '세율을 변경할 수 없습니다.');
|
||||
}
|
||||
state.rate = 25;
|
||||
return response({ ok: true });
|
||||
}
|
||||
if (
|
||||
[
|
||||
'nation.setNotice',
|
||||
'nation.setScoutMsg',
|
||||
'nation.setBill',
|
||||
'nation.setSecretLimit',
|
||||
'nation.setBlockScout',
|
||||
].includes(operation)
|
||||
)
|
||||
return response({ ok: true });
|
||||
if (operation === 'nation.setBlockWar') return response({ availableCnt: 4 });
|
||||
return errorResponse(operation, `Unhandled fixture operation: ${operation}`);
|
||||
});
|
||||
await fulfillJson(route, results);
|
||||
});
|
||||
};
|
||||
|
||||
const gotoOffice = async (page: Page, path: 'nation/personnel' | 'nation/finance') => {
|
||||
const lobbyResponse = page.waitForResponse((response) => response.url().includes('/trpc/lobby.info'));
|
||||
await page.goto(path);
|
||||
await lobbyResponse;
|
||||
};
|
||||
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('personnel matches the 1000px legacy table geometry, textures, image, and interaction states', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFixture(page, { role: 'leader', rate: 20 });
|
||||
await page.setViewportSize({ width: 1000, height: 900 });
|
||||
await gotoOffice(page, 'nation/personnel');
|
||||
await expect(page.getByText('작위검증국')).toBeVisible();
|
||||
|
||||
const computed = await page.locator('#personnel-container').evaluate((container) => {
|
||||
const box = (selector?: string) => {
|
||||
const element = selector ? container.querySelector<HTMLElement>(selector)! : container;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
fontFamily: style.fontFamily,
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
backgroundImage: style.backgroundImage,
|
||||
color: style.color,
|
||||
};
|
||||
};
|
||||
return {
|
||||
container: box(),
|
||||
heading: box('.heading-table'),
|
||||
status: box('.chief-status'),
|
||||
icon: box('.general-icon'),
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
};
|
||||
});
|
||||
expect(computed.container.width).toBe(1000);
|
||||
expect(computed.heading.width).toBe(1000);
|
||||
expect(computed.heading.height).toBeCloseTo(56, 0);
|
||||
expect(computed.status.width).toBe(1000);
|
||||
expect(computed.icon.width).toBeCloseTo(64.7, 0);
|
||||
expect(computed.icon.height).toBeCloseTo(64, 0);
|
||||
expect(computed.container.fontFamily).toContain('Pretendard');
|
||||
expect(computed.container.fontSize).toBe('14px');
|
||||
expect(computed.container.lineHeight).toBe('18.2px');
|
||||
expect(computed.status.backgroundImage).toContain('back_walnut.jpg');
|
||||
expect(computed.documentWidth).toBe(1000);
|
||||
|
||||
const appointButton = page.getByRole('button', { name: '임명' }).first();
|
||||
expect(await appointButton.evaluate((button) => getComputedStyle(button).backgroundColor)).toBe(
|
||||
'rgb(108, 117, 125)'
|
||||
);
|
||||
await appointButton.hover();
|
||||
expect(await appointButton.evaluate((button) => getComputedStyle(button).cursor)).toBe('pointer');
|
||||
await appointButton.focus();
|
||||
expect(await appointButton.evaluate((button) => getComputedStyle(button).outlineStyle)).not.toBe('none');
|
||||
await screenshot(page, 'core-personnel-desktop-leader.png');
|
||||
});
|
||||
|
||||
test('personnel preserves the legacy fixed 1000px document on a 500px viewport', async ({ page }) => {
|
||||
await installFixture(page, { role: 'head', rate: 20 });
|
||||
await page.setViewportSize({ width: 500, height: 900 });
|
||||
await gotoOffice(page, 'nation/personnel');
|
||||
await expect(page.getByText('작위검증국')).toBeVisible();
|
||||
expect(
|
||||
await page.locator('#personnel-container').evaluate((element) => element.getBoundingClientRect().width)
|
||||
).toBe(1000);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(1000);
|
||||
await expect(page.getByRole('combobox', { name: '외교권자' })).toHaveCount(0);
|
||||
await expect(page.getByRole('combobox', { name: '추방 대상 장수' })).toBeVisible();
|
||||
await screenshot(page, 'core-personnel-mobile-head.png');
|
||||
});
|
||||
|
||||
test('personnel hides every mutation control for an ordinary member and exposes load errors', async ({ page }) => {
|
||||
await installFixture(page, { role: 'member', rate: 20 });
|
||||
await gotoOffice(page, 'nation/personnel');
|
||||
await expect(page.getByText('도 시 관 직 임 명')).toHaveCount(0);
|
||||
await expect(page.getByText('외 교 권 자 임 명')).toHaveCount(0);
|
||||
await expect(page.getByText('추 방', { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText(/곽가\(10년\).*허창/)).toBeVisible();
|
||||
|
||||
const failed = await page.context().newPage();
|
||||
await installFixture(failed, { role: 'member', rate: 20, failPersonnelLoad: true });
|
||||
await gotoOffice(failed, 'nation/personnel');
|
||||
await expect(failed.getByRole('alert')).toContainText('권한이 부족합니다.');
|
||||
});
|
||||
|
||||
test('finance matches the reference 1000px and 500px computed DOM contracts', async ({ page }) => {
|
||||
await installFixture(page, { role: 'head', rate: 20 });
|
||||
await page.setViewportSize({ width: 1000, height: 900 });
|
||||
await gotoOffice(page, 'nation/finance');
|
||||
await expect(page.getByText('외교관계')).toBeVisible();
|
||||
const desktop = await page.locator('#finance-container').evaluate((container) => {
|
||||
const geometry = (selector?: string) => {
|
||||
const element = selector ? container.querySelector<HTMLElement>(selector)! : container;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
gridTemplateColumns: style.gridTemplateColumns,
|
||||
fontFamily: style.fontFamily,
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
backgroundColor: style.backgroundColor,
|
||||
backgroundImage: style.backgroundImage,
|
||||
};
|
||||
};
|
||||
return {
|
||||
container: geometry(),
|
||||
top: geometry('.top-back-bar'),
|
||||
title: geometry('.diplomacy-title'),
|
||||
row: geometry('.diplomacy-row'),
|
||||
notice: geometry('.notice-title'),
|
||||
finance: geometry('.finance-title'),
|
||||
input: geometry('input[type="number"]'),
|
||||
};
|
||||
});
|
||||
expect(desktop.container.width).toBe(1000);
|
||||
expect(desktop.container.fontFamily).toContain('Pretendard');
|
||||
expect(desktop.container.fontSize).toBe('14px');
|
||||
expect(desktop.container.backgroundImage).toContain('back_walnut.jpg');
|
||||
expect(desktop.top.height).toBe(32);
|
||||
expect(desktop.top.gridTemplateColumns).toBe('90px 90px 640px 90px 90px');
|
||||
expect(desktop.title.height).toBeCloseTo(25.47, 1);
|
||||
expect(desktop.title.backgroundColor).toBe('rgb(55, 90, 127)');
|
||||
expect(desktop.row.gridTemplateColumns).toBe(
|
||||
'260.859px 130.438px 86.9531px 86.9531px 173.922px 86.9531px 173.922px'
|
||||
);
|
||||
expect(desktop.notice.backgroundColor).toBe('rgb(255, 255, 255)');
|
||||
expect(desktop.finance.backgroundColor).toBe('rgb(0, 188, 140)');
|
||||
expect(desktop.input.width).toBeCloseTo(58.66, 1);
|
||||
expect(desktop.input.height).toBe(30);
|
||||
expect(
|
||||
await page
|
||||
.locator('.policy-submit')
|
||||
.first()
|
||||
.evaluate((element) => getComputedStyle(element).backgroundColor)
|
||||
).toBe('rgb(55, 90, 127)');
|
||||
expect(
|
||||
await page
|
||||
.locator('.policy-cancel')
|
||||
.first()
|
||||
.evaluate((element) => getComputedStyle(element).backgroundColor)
|
||||
).toBe('rgb(108, 117, 125)');
|
||||
expect(
|
||||
await page
|
||||
.getByRole('checkbox', { name: '전쟁 금지' })
|
||||
.evaluate((element) => getComputedStyle(element).borderRadius)
|
||||
).toBe('16px');
|
||||
await screenshot(page, 'core-finance-desktop-head.png');
|
||||
|
||||
await page.setViewportSize({ width: 500, height: 900 });
|
||||
await page.reload();
|
||||
await expect(page.getByText('외교관계')).toBeVisible();
|
||||
expect(await page.locator('#finance-container').evaluate((element) => element.getBoundingClientRect().width)).toBe(
|
||||
500
|
||||
);
|
||||
expect(
|
||||
await page.locator('.top-back-bar').evaluate((element) => getComputedStyle(element).gridTemplateColumns)
|
||||
).toBe('90px 90px 140px 90px 90px');
|
||||
expect(
|
||||
await page
|
||||
.locator('.diplomacy-row')
|
||||
.first()
|
||||
.evaluate((element) => getComputedStyle(element).gridTemplateColumns)
|
||||
).toBe('130.422px 65.2188px 43.4844px 43.4688px 86.9688px 43.4688px 86.9688px');
|
||||
await screenshot(page, 'core-finance-mobile-head.png');
|
||||
});
|
||||
|
||||
test('finance enforces edit permissions and preserves the old value across an API failure', async ({ page }) => {
|
||||
const state: FixtureState = { role: 'head', rate: 20, failNextRate: true };
|
||||
await installFixture(page, state);
|
||||
await gotoOffice(page, 'nation/finance');
|
||||
const input = page.getByRole('spinbutton', { name: '세율' });
|
||||
await input.fill('25');
|
||||
await page.locator('.policy-cell').filter({ hasText: '세율' }).getByRole('button', { name: '변경' }).click();
|
||||
await expect(page.getByRole('alert')).toContainText('세율을 변경할 수 없습니다.');
|
||||
await expect(input).toHaveValue('20');
|
||||
await input.fill('25');
|
||||
await page.locator('.policy-cell').filter({ hasText: '세율' }).getByRole('button', { name: '변경' }).click();
|
||||
await expect(page.getByRole('status')).toContainText('세율을 변경했습니다.');
|
||||
expect(state.rate).toBe(25);
|
||||
|
||||
const readOnly = await page.context().newPage();
|
||||
await installFixture(readOnly, { role: 'member', rate: 20 });
|
||||
await gotoOffice(readOnly, 'nation/finance');
|
||||
await expect(readOnly.getByRole('button', { name: '국가방침 수정' })).toHaveCount(0);
|
||||
await expect(readOnly.locator('.policy-cell').getByRole('button', { name: '변경' })).toHaveCount(0);
|
||||
await expect(readOnly.getByRole('checkbox', { name: '전쟁 금지' })).toBeDisabled();
|
||||
});
|
||||
@@ -3,10 +3,12 @@ import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
const port = Number(process.env.PLAYWRIGHT_FRONTEND_PORT ?? 15120);
|
||||
const baseURL = `http://127.0.0.1:${port}/che/`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: '.',
|
||||
testMatch: 'troop.spec.ts',
|
||||
testMatch: ['troop.spec.ts', 'nationOffices.spec.ts'],
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
timeout: 30_000,
|
||||
@@ -14,9 +16,9 @@ export default defineConfig({
|
||||
timeout: 5_000,
|
||||
},
|
||||
reporter: [['list'], ['html', { open: 'never', outputFolder: resolve(repositoryRoot, 'playwright-report') }]],
|
||||
outputDir: resolve(repositoryRoot, 'test-results/troop'),
|
||||
outputDir: resolve(repositoryRoot, 'test-results/game-frontend'),
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:15120/che/',
|
||||
baseURL,
|
||||
...devices['Desktop Chrome'],
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: 'dark',
|
||||
@@ -24,10 +26,9 @@ export default defineConfig({
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
webServer: {
|
||||
command:
|
||||
'VITE_APP_BASE_PATH=/che VITE_GAME_API_URL=/che/api/trpc pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port 15120',
|
||||
command: `VITE_APP_BASE_PATH=/che VITE_GAME_API_URL=/che/api/trpc pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port ${port}`,
|
||||
cwd: repositoryRoot,
|
||||
url: 'http://127.0.0.1:15120/che/',
|
||||
url: baseURL,
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user