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,257 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const targetRoot = process.env.REF_AUCTION_URL ?? 'https://dev-sam-ref.hided.net/sam/';
|
||||
const secretRoot = process.env.REF_SECRET_ROOT;
|
||||
const username = process.env.REF_USER_ID ?? 'refuser1';
|
||||
const passwordFile = process.env.REF_PASSWORD_FILE ?? 'user1_password';
|
||||
const allowGeneralCreate = process.env.REF_CREATE_GENERAL === '1';
|
||||
const outputRoot = resolve(
|
||||
process.env.REF_AUCTION_ARTIFACT_DIR ?? resolve(repositoryRoot, 'test-results/auction-reference')
|
||||
);
|
||||
|
||||
if (!secretRoot) {
|
||||
throw new Error('REF_SECRET_ROOT is required.');
|
||||
}
|
||||
|
||||
const password = (await readFile(resolve(secretRoot, passwordFile), 'utf8')).trim();
|
||||
const viewports = [
|
||||
{ name: 'desktop', width: 1000, height: 800 },
|
||||
{ name: 'mobile', width: 500, height: 800 },
|
||||
];
|
||||
|
||||
const login = async (context) => {
|
||||
const page = await context.newPage();
|
||||
await page.goto(targetRoot, { waitUntil: 'networkidle', timeout: 60_000 });
|
||||
const globalSalt = await page.locator('#global_salt').inputValue();
|
||||
const clientPasswordHash = createHash('sha512')
|
||||
.update(globalSalt + password + globalSalt)
|
||||
.digest('hex');
|
||||
const response = await context.request.post(new URL('api.php?path=Login/LoginByID', targetRoot).toString(), {
|
||||
data: { username, password: clientPasswordHash },
|
||||
timeout: 60_000,
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok() || result.result !== true) {
|
||||
throw new Error('Reference login failed.');
|
||||
}
|
||||
if (allowGeneralCreate) {
|
||||
const joinUrl = new URL('hwe/v_join.php', targetRoot).toString();
|
||||
await page.goto(joinUrl, { waitUntil: 'networkidle', timeout: 60_000 });
|
||||
if (page.url().includes('v_join.php')) {
|
||||
const createButton = page.getByRole('button', { name: '장수 생성', exact: true });
|
||||
try {
|
||||
await createButton.waitFor({ state: 'visible', timeout: 30_000 });
|
||||
} catch {
|
||||
const pageText = (await page.locator('body').innerText()).replace(/\s+/g, ' ').slice(0, 300);
|
||||
throw new Error(`Reference general form did not render: ${page.url()} | ${pageText}`);
|
||||
}
|
||||
page.on('dialog', async (dialog) => dialog.accept());
|
||||
await createButton.click();
|
||||
await page.waitForURL((url) => !url.pathname.endsWith('/v_join.php'), { timeout: 60_000 });
|
||||
}
|
||||
}
|
||||
await page.close();
|
||||
};
|
||||
|
||||
const roundedRect = (rect) => ({
|
||||
x: Math.round(rect.x * 100) / 100,
|
||||
y: Math.round(rect.y * 100) / 100,
|
||||
width: Math.round(rect.width * 100) / 100,
|
||||
height: Math.round(rect.height * 100) / 100,
|
||||
});
|
||||
|
||||
const measurePage = async (page, type) => {
|
||||
const diagnostics = [];
|
||||
const onPageError = (error) => diagnostics.push(`pageerror: ${error.message}`);
|
||||
const onConsole = (message) => {
|
||||
if (message.type() === 'error') {
|
||||
diagnostics.push(`console: ${message.text()}`);
|
||||
}
|
||||
};
|
||||
const onResponse = (response) => {
|
||||
if (response.status() >= 400) {
|
||||
diagnostics.push(`http ${response.status()}: ${response.url()}`);
|
||||
}
|
||||
};
|
||||
page.on('pageerror', onPageError);
|
||||
page.on('console', onConsole);
|
||||
page.on('response', onResponse);
|
||||
const relative = type === 'unique' ? 'hwe/v_auction.php?type=unique' : 'hwe/v_auction.php';
|
||||
await page.goto(new URL(relative, targetRoot).toString(), { waitUntil: 'networkidle', timeout: 60_000 });
|
||||
try {
|
||||
await page.locator('#container').waitFor({ state: 'visible', timeout: 30_000 });
|
||||
} catch {
|
||||
const pageText = (await page.locator('body').innerText()).replace(/\s+/g, ' ').slice(0, 300);
|
||||
const scripts = await page.locator('script').evaluateAll((elements) =>
|
||||
elements.map((element) => ({
|
||||
src: element.getAttribute('src'),
|
||||
type: element.getAttribute('type'),
|
||||
length: element.textContent?.length ?? 0,
|
||||
}))
|
||||
);
|
||||
throw new Error(
|
||||
`Reference auction did not render: ${page.url()} | ${await page.title()} | ${pageText} | ${JSON.stringify(scripts)} | ${diagnostics.slice(0, 8).join(' | ')}`
|
||||
);
|
||||
} finally {
|
||||
page.off('pageerror', onPageError);
|
||||
page.off('console', onConsole);
|
||||
page.off('response', onResponse);
|
||||
}
|
||||
await page.locator('button').first().waitFor({ state: 'visible' });
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
|
||||
const measurement = await page.evaluate(() => {
|
||||
const rect = (element) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return null;
|
||||
}
|
||||
const value = element.getBoundingClientRect();
|
||||
return { x: value.x, y: value.y, width: value.width, height: value.height };
|
||||
};
|
||||
const style = (element) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return null;
|
||||
}
|
||||
const value = getComputedStyle(element);
|
||||
return {
|
||||
color: value.color,
|
||||
backgroundColor: value.backgroundColor,
|
||||
backgroundImage: value.backgroundImage,
|
||||
borderColor: value.borderColor,
|
||||
borderWidth: value.borderWidth,
|
||||
borderRadius: value.borderRadius,
|
||||
fontFamily: value.fontFamily,
|
||||
fontSize: value.fontSize,
|
||||
fontWeight: value.fontWeight,
|
||||
lineHeight: value.lineHeight,
|
||||
padding: value.padding,
|
||||
cursor: value.cursor,
|
||||
};
|
||||
};
|
||||
const container = document.querySelector('#container');
|
||||
const topBar = container?.firstElementChild ?? null;
|
||||
const topBarButtons = [...(topBar?.querySelectorAll('button') ?? [])].map((element) => ({
|
||||
text: element.textContent?.trim() ?? '',
|
||||
rect: rect(element),
|
||||
style: style(element),
|
||||
}));
|
||||
const firstButton = document.querySelector('button');
|
||||
const firstInput = [...document.querySelectorAll('input')].find(
|
||||
(element) => element.getBoundingClientRect().width > 0
|
||||
);
|
||||
const firstAuctionRow = document.querySelector('.auctionItem');
|
||||
const firstAuctionRowChildren = [...(firstAuctionRow?.children ?? [])].map((element) => ({
|
||||
className: element.className,
|
||||
rect: rect(element),
|
||||
style: style(element),
|
||||
}));
|
||||
const firstSection = [...document.querySelectorAll('#container > div')].find((element) =>
|
||||
['쌀 구매', '쌀 판매'].includes(element.textContent?.trim() ?? '')
|
||||
);
|
||||
const directChildren = [...(container?.children ?? [])].slice(0, 12).map((element) => ({
|
||||
tag: element.tagName,
|
||||
className: element.className,
|
||||
text: element.textContent?.trim().replace(/\s+/g, ' ').slice(0, 80) ?? '',
|
||||
rect: rect(element),
|
||||
}));
|
||||
return {
|
||||
viewport: { width: window.innerWidth, height: window.innerHeight },
|
||||
document: {
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
},
|
||||
body: { rect: rect(document.body), style: style(document.body) },
|
||||
container: { rect: rect(container), style: style(container) },
|
||||
topBar: { rect: rect(topBar), style: style(topBar) },
|
||||
topBarButtons,
|
||||
firstButton: { rect: rect(firstButton), style: style(firstButton) },
|
||||
firstInput: { rect: rect(firstInput), style: style(firstInput) },
|
||||
firstAuctionRow: { rect: rect(firstAuctionRow), style: style(firstAuctionRow) },
|
||||
firstAuctionRowChildren,
|
||||
firstSection: { rect: rect(firstSection), style: style(firstSection) },
|
||||
directChildren,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...measurement,
|
||||
body: {
|
||||
...measurement.body,
|
||||
rect: measurement.body.rect ? roundedRect(measurement.body.rect) : null,
|
||||
},
|
||||
container: {
|
||||
...measurement.container,
|
||||
rect: measurement.container.rect ? roundedRect(measurement.container.rect) : null,
|
||||
},
|
||||
topBar: {
|
||||
...measurement.topBar,
|
||||
rect: measurement.topBar.rect ? roundedRect(measurement.topBar.rect) : null,
|
||||
},
|
||||
topBarButtons: measurement.topBarButtons.map((button) => ({
|
||||
...button,
|
||||
rect: button.rect ? roundedRect(button.rect) : null,
|
||||
})),
|
||||
firstButton: {
|
||||
...measurement.firstButton,
|
||||
rect: measurement.firstButton.rect ? roundedRect(measurement.firstButton.rect) : null,
|
||||
},
|
||||
firstInput: {
|
||||
...measurement.firstInput,
|
||||
rect: measurement.firstInput.rect ? roundedRect(measurement.firstInput.rect) : null,
|
||||
},
|
||||
firstAuctionRow: {
|
||||
...measurement.firstAuctionRow,
|
||||
rect: measurement.firstAuctionRow.rect ? roundedRect(measurement.firstAuctionRow.rect) : null,
|
||||
},
|
||||
firstAuctionRowChildren: measurement.firstAuctionRowChildren.map((child) => ({
|
||||
...child,
|
||||
rect: child.rect ? roundedRect(child.rect) : null,
|
||||
})),
|
||||
firstSection: {
|
||||
...measurement.firstSection,
|
||||
rect: measurement.firstSection.rect ? roundedRect(measurement.firstSection.rect) : null,
|
||||
},
|
||||
directChildren: measurement.directChildren.map((child) => ({
|
||||
...child,
|
||||
rect: child.rect ? roundedRect(child.rect) : null,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
await mkdir(outputRoot, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
|
||||
const results = {};
|
||||
try {
|
||||
for (const viewport of viewports) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: viewport.width, height: viewport.height },
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: 'dark',
|
||||
});
|
||||
try {
|
||||
await login(context);
|
||||
const page = await context.newPage();
|
||||
for (const type of ['resource', 'unique']) {
|
||||
results[`${viewport.name}-${type}`] = await measurePage(page, type);
|
||||
await page.screenshot({
|
||||
path: resolve(outputRoot, `${viewport.name}-${type}.png`),
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const outputPath = resolve(outputRoot, 'computed-dom.json');
|
||||
await writeFile(outputPath, `${JSON.stringify(results, null, 2)}\n`);
|
||||
console.log(JSON.stringify({ outputPath, views: Object.keys(results) }));
|
||||
@@ -0,0 +1,293 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
|
||||
const gameOrigin = `http://127.0.0.1:${process.env.FRONTEND_PARITY_GAME_PORT ?? '15102'}`;
|
||||
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
const errorResponse = (path: string, code: 'BAD_REQUEST' | 'NOT_FOUND', message: string) => ({
|
||||
error: {
|
||||
message,
|
||||
code: -32000,
|
||||
data: { code, httpStatus: code === 'NOT_FOUND' ? 404 : 400, path },
|
||||
},
|
||||
});
|
||||
|
||||
const operationNames = (route: Route): string[] => {
|
||||
const pathname = new URL(route.request().url()).pathname;
|
||||
return decodeURIComponent(pathname.slice(pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||
};
|
||||
|
||||
const listPayload = {
|
||||
current: { year: 200, month: 1 },
|
||||
entries: [
|
||||
{
|
||||
id: 1,
|
||||
serverId: 'hwe_260725_u3uE',
|
||||
phase: '훼2기',
|
||||
name: '백년01',
|
||||
year: 215,
|
||||
month: 4,
|
||||
color: '#FF0000',
|
||||
type: 'che_병가',
|
||||
power: 34434,
|
||||
gennum: 71,
|
||||
citynum: 78,
|
||||
l12name: 'ⓜ⑮오리온자리',
|
||||
l11name: 'ⓜ②물조명성너프필수',
|
||||
l10name: 'ⓜ㉗좌우쌍욍검유비',
|
||||
l9name: 'ⓜ②다니엘레메로시',
|
||||
l8name: 'ⓜ⑤쿠로',
|
||||
l7name: 'ⓜ⑮료우기시키',
|
||||
l6name: 'ⓜ㉓랜덤박스',
|
||||
l5name: 'ⓜ㉒임사영',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const detailPayload = {
|
||||
emperor: {
|
||||
id: 1,
|
||||
serverId: 'hwe_260725_u3uE',
|
||||
winnerNationId: 1,
|
||||
phase: '훼2기',
|
||||
nationCount: '8 / 17',
|
||||
nationName: '백년01, 청해, 백제',
|
||||
nationHist: '병가(3), 유가(2)',
|
||||
genCount: '71 / 92',
|
||||
personalHist: '의리(14), 대담(11)',
|
||||
specialHist: '상재(8), 견고(7)',
|
||||
name: '백년01',
|
||||
type: 'che_병가',
|
||||
color: '#FF0000',
|
||||
year: 215,
|
||||
month: 4,
|
||||
power: 34434,
|
||||
gennum: 71,
|
||||
citynum: 78,
|
||||
pop: '2,345,000 / 2,500,000',
|
||||
poprate: '93.8 %',
|
||||
gold: 120000,
|
||||
rice: 150000,
|
||||
l12name: 'ⓜ⑮오리온자리',
|
||||
l11name: 'ⓜ②물조명성너프필수',
|
||||
l10name: 'ⓜ㉗좌우쌍욍검유비',
|
||||
l9name: 'ⓜ②다니엘레메로시',
|
||||
l8name: 'ⓜ⑤쿠로',
|
||||
l7name: 'ⓜ⑮료우기시키',
|
||||
l6name: 'ⓜ㉓랜덤박스',
|
||||
l5name: 'ⓜ㉒임사영',
|
||||
tiger: '관우【10】, 조운【9】',
|
||||
eagle: '주유【7】, 육손【6】',
|
||||
gen: '오리온자리, 물조명성너프필수, 좌우쌍욍검유비',
|
||||
history: ['<C>●</><Y><b>【통일】</b></><D><b>백년01</b></>이 전토를 통일하였습니다.'],
|
||||
},
|
||||
nations: [
|
||||
{
|
||||
nation: 1,
|
||||
isWinner: true,
|
||||
name: '백년01',
|
||||
color: '#FF0000',
|
||||
type: 'che_병가',
|
||||
typeName: '병가',
|
||||
level: 7,
|
||||
levelName: '황제',
|
||||
tech: 4000,
|
||||
maxPower: 34434,
|
||||
maxCrew: 120000,
|
||||
maxCities: ['낙양', '장안', '성도'],
|
||||
generals: [11, 12],
|
||||
history: ['<Y>오리온자리</>가 황제로 즉위'],
|
||||
date: '2026-07-25T12:00:00.000Z',
|
||||
generalsFull: [
|
||||
{ generalNo: 11, name: '오리온자리', lastYearMonth: 21504 },
|
||||
{ generalNo: 12, name: '료우기시키', lastYearMonth: 21504 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const installFixture = async (page: Page): Promise<void> => {
|
||||
await page.route('**/image/game/**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'image/png',
|
||||
body: Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64'
|
||||
),
|
||||
});
|
||||
});
|
||||
await page.route('**/che/api/trpc/**', async (route) => {
|
||||
const results = operationNames(route).map((operation) => {
|
||||
if (operation === 'lobby.info') {
|
||||
return response({ myGeneral: null });
|
||||
}
|
||||
if (operation === 'dynasty.getList') {
|
||||
return response(listPayload);
|
||||
}
|
||||
if (operation === 'dynasty.getDetail') {
|
||||
const input = new URL(route.request().url()).searchParams.get('input') ?? '';
|
||||
return input.includes('999')
|
||||
? errorResponse(operation, 'NOT_FOUND', '왕조 정보를 찾을 수 없습니다.')
|
||||
: response(detailPayload);
|
||||
}
|
||||
return errorResponse(operation, 'BAD_REQUEST', `Unhandled fixture operation: ${operation}`);
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(results),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installFixture(page);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
});
|
||||
|
||||
test('dynasty list matches the ref Chromium table geometry and interactions', async ({ page }) => {
|
||||
await page.goto(`${gameOrigin}/che/dynasty`);
|
||||
await expect(page.getByText('백년01 (215年 4月)')).toBeVisible();
|
||||
|
||||
const geometry = await page.locator('#dynasty-list-container').evaluate((container) => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const tables = Array.from(container.querySelectorAll<HTMLTableElement>('table')).map((table) => {
|
||||
const tableRect = table.getBoundingClientRect();
|
||||
const style = getComputedStyle(table);
|
||||
return {
|
||||
x: tableRect.x,
|
||||
y: tableRect.y,
|
||||
width: tableRect.width,
|
||||
height: tableRect.height,
|
||||
borderSpacing: style.borderSpacing,
|
||||
fontFamily: style.fontFamily,
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
};
|
||||
});
|
||||
const button = container.querySelector<HTMLButtonElement>('button')!;
|
||||
const buttonStyle = getComputedStyle(button);
|
||||
const cellStyle = getComputedStyle(container.querySelector<HTMLTableCellElement>('td')!);
|
||||
return {
|
||||
container: { x: rect.x, y: rect.y, width: rect.width },
|
||||
tables,
|
||||
button: {
|
||||
height: button.getBoundingClientRect().height,
|
||||
borderWidth: buttonStyle.borderWidth,
|
||||
borderRadius: buttonStyle.borderRadius,
|
||||
padding: buttonStyle.padding,
|
||||
fontFamily: buttonStyle.fontFamily,
|
||||
fontSize: buttonStyle.fontSize,
|
||||
cursor: buttonStyle.cursor,
|
||||
},
|
||||
firstCell: {
|
||||
padding: cellStyle.padding,
|
||||
borderWidth: cellStyle.borderWidth,
|
||||
textAlign: cellStyle.textAlign,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
expect(geometry.container).toEqual({ x: 140, y: 8, width: 1000 });
|
||||
expect(geometry.tables.slice(0, 3).map(({ x, width, borderSpacing }) => ({ x, width, borderSpacing }))).toEqual([
|
||||
{ x: 140, width: 1000, borderSpacing: '2px' },
|
||||
{ x: 140, width: 1000, borderSpacing: '2px' },
|
||||
{ x: 140, width: 1000, borderSpacing: '2px' },
|
||||
]);
|
||||
expect(geometry.tables.slice(0, 3).map(({ y, height }) => ({ y, height }))).toEqual([
|
||||
{ y: 8, height: 47 },
|
||||
{ y: 65, height: 37 },
|
||||
{ y: 112, height: 139 },
|
||||
]);
|
||||
expect(geometry.tables[0]!.fontFamily).toContain('Times New Roman');
|
||||
expect(geometry.tables[0]!.fontSize).toBe('16px');
|
||||
expect(geometry.tables[0]!.lineHeight).toBe('normal');
|
||||
expect(geometry.button).toEqual({
|
||||
height: 22,
|
||||
borderWidth: '2px',
|
||||
borderRadius: '0px',
|
||||
padding: '1px 6px',
|
||||
fontFamily: 'Arial',
|
||||
fontSize: '13.3333px',
|
||||
cursor: 'default',
|
||||
});
|
||||
expect(geometry.firstCell).toEqual({ padding: '1px', borderWidth: '0px', textAlign: 'start' });
|
||||
|
||||
const historyLink = page.getByRole('link', { name: '역사 보기' }).last();
|
||||
await expect(historyLink).toHaveAttribute('href', '/che/yearbook?serverID=hwe_260725_u3uE');
|
||||
if (artifactRoot) {
|
||||
await page.screenshot({ path: resolve(artifactRoot, 'dynasty-core-list.png'), fullPage: true });
|
||||
}
|
||||
|
||||
const detailLink = page.getByRole('link', { name: '자세히' });
|
||||
await detailLink.focus();
|
||||
await expect(detailLink).toBeFocused();
|
||||
await detailLink.click();
|
||||
await expect(page).toHaveURL(/\/che\/dynasty\/1$/);
|
||||
await expect(page.getByText('국 가 수')).toBeVisible();
|
||||
|
||||
if (artifactRoot) {
|
||||
await page.screenshot({ path: resolve(artifactRoot, 'dynasty-core-detail.png'), fullPage: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('dynasty detail preserves the legacy fields, old-nation table and error flow', async ({ page }) => {
|
||||
await page.goto(`${gameOrigin}/che/dynasty/1`);
|
||||
await expect(page.getByText('장 수 성 격')).toBeVisible();
|
||||
await expect(page.getByText('의리(14), 대담(11)')).toBeVisible();
|
||||
await expect(page.getByText('오 호 장 군')).toBeVisible();
|
||||
await expect(page.getByText('건 안 칠 자')).toBeVisible();
|
||||
await expect(page.getByText('【 백년01 】')).toBeVisible();
|
||||
await expect(page.getByText('황제', { exact: true })).toBeVisible();
|
||||
await expect(page.locator('.old-nation-table')).toHaveCount(1);
|
||||
|
||||
const geometry = await page.locator('#dynasty-detail-container').evaluate((container) => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const first = container.querySelector<HTMLTableElement>('table')!.getBoundingClientRect();
|
||||
const emperor = container.querySelector<HTMLTableElement>('.emperor-table')!.getBoundingClientRect();
|
||||
const oldNation = container.querySelector<HTMLTableElement>('.old-nation-table')!.getBoundingClientRect();
|
||||
return {
|
||||
container: { x: rect.x, y: rect.y, width: rect.width },
|
||||
first: { x: first.x, y: first.y, width: first.width, height: first.height },
|
||||
emperor: { x: emperor.x, y: emperor.y, width: emperor.width },
|
||||
oldNation: { x: oldNation.x, width: oldNation.width },
|
||||
};
|
||||
});
|
||||
expect(geometry.container).toEqual({ x: 140, y: 8, width: 1000 });
|
||||
expect(geometry.first).toEqual({ x: 140, y: 8, width: 1000, height: 47 });
|
||||
expect(geometry.emperor).toEqual({ x: 140, y: 55, width: 1000 });
|
||||
expect(geometry.oldNation).toEqual({ x: 140, width: 1000 });
|
||||
|
||||
await page.goto(`${gameOrigin}/che/dynasty/999`);
|
||||
await expect(page.getByRole('alert')).toHaveText('왕조 정보를 찾을 수 없습니다.');
|
||||
await expect(page.locator('.emperor-table')).toHaveCount(0);
|
||||
await page.getByRole('button', { name: '창 닫기' }).first().click();
|
||||
await expect(page).toHaveURL(/\/che\/public$/);
|
||||
});
|
||||
|
||||
test('dynasty public view is identical for anonymous and distinct user tokens', async ({ browser }) => {
|
||||
const snapshots: string[] = [];
|
||||
for (const token of [null, 'ga_dynasty_owner_a', 'ga_dynasty_owner_b']) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 900 },
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: 'dark',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await installFixture(page);
|
||||
if (token) {
|
||||
await page.addInitScript((value) => {
|
||||
window.localStorage.setItem('sammo-game-token', value);
|
||||
}, token);
|
||||
}
|
||||
await page.goto(`${gameOrigin}/che/dynasty`);
|
||||
await expect(page.getByText('백년01 (215年 4月)')).toBeVisible();
|
||||
snapshots.push(await page.locator('#dynasty-list-container').innerText());
|
||||
await context.close();
|
||||
}
|
||||
|
||||
expect(snapshots[1]).toBe(snapshots[0]);
|
||||
expect(snapshots[2]).toBe(snapshots[0]);
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
const baseUrl = process.env.DYNASTY_REF_URL ?? 'http://127.0.0.1:3410/hwe';
|
||||
const detailId = process.env.DYNASTY_REF_DETAIL_ID ?? '1';
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: 1280, height: 900 },
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: 'dark',
|
||||
locale: 'ko-KR',
|
||||
});
|
||||
|
||||
const measure = async (url) => {
|
||||
await page.goto(url, { waitUntil: 'networkidle' });
|
||||
return page.evaluate(() => {
|
||||
const tables = Array.from(document.querySelectorAll('table')).map((table) => {
|
||||
const rect = table.getBoundingClientRect();
|
||||
const style = getComputedStyle(table);
|
||||
return {
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
backgroundImage: style.backgroundImage,
|
||||
borderSpacing: style.borderSpacing,
|
||||
fontFamily: style.fontFamily,
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
};
|
||||
});
|
||||
const button = document.querySelector('button');
|
||||
const buttonStyle = button ? getComputedStyle(button) : null;
|
||||
const firstCell = document.querySelector('td');
|
||||
const firstCellStyle = firstCell ? getComputedStyle(firstCell) : null;
|
||||
return {
|
||||
chromium: navigator.userAgent,
|
||||
body: {
|
||||
width: document.body.getBoundingClientRect().width,
|
||||
minWidth: getComputedStyle(document.body).minWidth,
|
||||
margin: getComputedStyle(document.body).margin,
|
||||
fontFamily: getComputedStyle(document.body).fontFamily,
|
||||
fontSize: getComputedStyle(document.body).fontSize,
|
||||
lineHeight: getComputedStyle(document.body).lineHeight,
|
||||
},
|
||||
tables,
|
||||
button: buttonStyle
|
||||
? {
|
||||
height: button.getBoundingClientRect().height,
|
||||
borderWidth: buttonStyle.borderWidth,
|
||||
borderRadius: buttonStyle.borderRadius,
|
||||
backgroundColor: buttonStyle.backgroundColor,
|
||||
color: buttonStyle.color,
|
||||
padding: buttonStyle.padding,
|
||||
fontFamily: buttonStyle.fontFamily,
|
||||
fontSize: buttonStyle.fontSize,
|
||||
lineHeight: buttonStyle.lineHeight,
|
||||
cursor: buttonStyle.cursor,
|
||||
}
|
||||
: null,
|
||||
firstCell: firstCellStyle
|
||||
? {
|
||||
padding: firstCellStyle.padding,
|
||||
borderWidth: firstCellStyle.borderWidth,
|
||||
textAlign: firstCellStyle.textAlign,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const output = {
|
||||
list: await measure(`${baseUrl}/a_emperior.php`),
|
||||
detail: await measure(`${baseUrl}/a_emperior_detail.php?select=${detailId}`),
|
||||
};
|
||||
|
||||
if (process.env.FRONTEND_PARITY_ARTIFACT_DIR) {
|
||||
await page.goto(`${baseUrl}/a_emperior.php`, { waitUntil: 'networkidle' });
|
||||
await page.screenshot({
|
||||
path: `${process.env.FRONTEND_PARITY_ARTIFACT_DIR}/dynasty-ref-list.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
if (process.env.DYNASTY_REF_CAPTURE_DETAIL === '1') {
|
||||
await page.goto(`${baseUrl}/a_emperior_detail.php?select=${detailId}`, { waitUntil: 'networkidle' });
|
||||
await page.screenshot({
|
||||
path: `${process.env.FRONTEND_PARITY_ARTIFACT_DIR}/dynasty-ref-detail.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(`${JSON.stringify(output, null, 2)}\n`);
|
||||
await browser.close();
|
||||
@@ -75,6 +75,82 @@ export const canonicalFrontendFixture = {
|
||||
[2, '진', '#1976d2', 2],
|
||||
],
|
||||
},
|
||||
bestGeneral: {
|
||||
isUnited: true,
|
||||
sections: [
|
||||
{
|
||||
title: '명 성',
|
||||
valueType: 'int',
|
||||
entries: [
|
||||
{
|
||||
id: 1,
|
||||
name: '유비',
|
||||
ownerName: '시각검증',
|
||||
nationName: '촉',
|
||||
bgColor: '#006400',
|
||||
fgColor: '#ffffff',
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
value: 12000,
|
||||
printValue: '12,000',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '조조',
|
||||
ownerName: '검증계정',
|
||||
nationName: '위',
|
||||
bgColor: '#8b0000',
|
||||
fgColor: '#ffffff',
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
value: 11000,
|
||||
printValue: '11,000',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '계 급',
|
||||
valueType: 'int',
|
||||
entries: [],
|
||||
},
|
||||
],
|
||||
uniqueItems: [
|
||||
{
|
||||
title: '명 마',
|
||||
slot: 'horse',
|
||||
entries: [
|
||||
{
|
||||
itemKey: 'che_명마_15_적토마',
|
||||
itemName: '적토마',
|
||||
itemInfo: '최고의 명마',
|
||||
owner: {
|
||||
id: 1,
|
||||
name: '유비',
|
||||
nationName: '촉',
|
||||
bgColor: '#006400',
|
||||
fgColor: '#ffffff',
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
itemKey: 'che_명마_15_적토마',
|
||||
itemName: '적토마',
|
||||
itemInfo: '최고의 명마',
|
||||
owner: {
|
||||
id: 0,
|
||||
name: '경매중',
|
||||
nationName: '-',
|
||||
bgColor: '#00582c',
|
||||
fgColor: '#ffffff',
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
hallOptions: [
|
||||
{
|
||||
season: 1,
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
|
||||
import { canonicalFrontendFixture as fixture } from './fixtures/canonical';
|
||||
|
||||
const gamePort = process.env.FRONTEND_PARITY_GAME_PORT ?? '15102';
|
||||
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 pathname = new URL(route.request().url()).pathname;
|
||||
return decodeURIComponent(pathname.slice(pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||
};
|
||||
|
||||
const general = {
|
||||
id: 1,
|
||||
name: '테스트장수',
|
||||
npcState: 0,
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
officerLevel: 1,
|
||||
stats: { leadership: 80, strength: 70, intelligence: 90 },
|
||||
gold: 1000,
|
||||
rice: 1000,
|
||||
crew: 500,
|
||||
train: 100,
|
||||
atmos: 100,
|
||||
injury: 0,
|
||||
experience: 1200,
|
||||
dedication: 900,
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
};
|
||||
|
||||
const generalContext = {
|
||||
general,
|
||||
city: {
|
||||
id: 1,
|
||||
name: '낙양',
|
||||
level: 7,
|
||||
nationId: 1,
|
||||
population: 50000,
|
||||
agriculture: 5000,
|
||||
commerce: 5000,
|
||||
security: 5000,
|
||||
defence: 5000,
|
||||
wall: 5000,
|
||||
supplyState: 1,
|
||||
frontState: 2,
|
||||
},
|
||||
nation: {
|
||||
id: 1,
|
||||
name: '테스트국',
|
||||
color: '#d32f2f',
|
||||
level: 5,
|
||||
gold: 10000,
|
||||
rice: 10000,
|
||||
tech: 1200,
|
||||
typeCode: 'che_군벌',
|
||||
capitalCityId: 1,
|
||||
},
|
||||
settings: {},
|
||||
penalties: {},
|
||||
};
|
||||
|
||||
const target = (generalId: number, generalName: string, nationId: number, nationName: string, color: string) => ({
|
||||
generalId,
|
||||
generalName,
|
||||
nationId,
|
||||
nationName,
|
||||
color,
|
||||
icon: '/image/icons/default.jpg',
|
||||
});
|
||||
|
||||
const ownTarget = target(1, '테스트장수', 1, '테스트국', '#d32f2f');
|
||||
const foreignTarget = target(8, '상대장수', 2, '상대국', '#2457a6');
|
||||
const messageTime = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
||||
|
||||
const buildMessages = (permission: number) => ({
|
||||
result: true,
|
||||
public: [
|
||||
{
|
||||
id: 101,
|
||||
msgType: 'public',
|
||||
src: ownTarget,
|
||||
dest: null,
|
||||
text: '전체 메시지 본문',
|
||||
option: {},
|
||||
time: messageTime,
|
||||
},
|
||||
],
|
||||
national: [
|
||||
{
|
||||
id: 102,
|
||||
msgType: 'national',
|
||||
src: ownTarget,
|
||||
dest: target(0, '', 1, '테스트국', '#d32f2f'),
|
||||
text: '국가 메시지 본문',
|
||||
option: {},
|
||||
time: messageTime,
|
||||
},
|
||||
],
|
||||
private: [
|
||||
{
|
||||
id: 103,
|
||||
msgType: 'private',
|
||||
src: foreignTarget,
|
||||
dest: ownTarget,
|
||||
text: '개인 메시지 본문',
|
||||
option: {},
|
||||
time: messageTime,
|
||||
},
|
||||
],
|
||||
diplomacy: [
|
||||
{
|
||||
id: 104,
|
||||
msgType: 'diplomacy',
|
||||
src: foreignTarget,
|
||||
dest: target(0, '', 1, '테스트국', '#d32f2f'),
|
||||
text: permission >= 3 ? '외교 메시지 본문' : '(외교 메시지입니다)',
|
||||
option:
|
||||
permission >= 3
|
||||
? { action: 'noAggression', deletable: false }
|
||||
: { action: 'noAggression', deletable: false, invalid: true },
|
||||
time: messageTime,
|
||||
},
|
||||
],
|
||||
sequence: 104,
|
||||
nationId: 1,
|
||||
generalName: general.name,
|
||||
permission,
|
||||
canRespondDiplomacy: permission >= 4 && general.officerLevel > 4,
|
||||
latestRead: { private: 0, diplomacy: 0 },
|
||||
});
|
||||
|
||||
const contacts = {
|
||||
nation: [
|
||||
{
|
||||
nationId: 0,
|
||||
mailbox: 9000,
|
||||
name: '재야',
|
||||
color: '#000000',
|
||||
general: [],
|
||||
},
|
||||
{
|
||||
nationId: 1,
|
||||
mailbox: 9001,
|
||||
name: '테스트국',
|
||||
color: '#d32f2f',
|
||||
general: [
|
||||
[1, '테스트장수', 4],
|
||||
[2, '아군군주', 1],
|
||||
],
|
||||
},
|
||||
{
|
||||
nationId: 2,
|
||||
mailbox: 9002,
|
||||
name: '상대국',
|
||||
color: '#2457a6',
|
||||
general: [
|
||||
[8, '상대외교관', 4],
|
||||
[9, '상대일반', 0],
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const installFixture = async (
|
||||
page: Page,
|
||||
options: { permission: number; sendError?: string }
|
||||
): Promise<Array<{ operation: string; body: unknown }>> => {
|
||||
const mutations: Array<{ operation: string; body: unknown }> = [];
|
||||
await page.addInitScript(
|
||||
({ gameToken, profile }) => {
|
||||
window.localStorage.setItem('sammo-game-token', gameToken);
|
||||
window.localStorage.setItem('sammo-game-profile', profile);
|
||||
},
|
||||
{
|
||||
gameToken: fixture.game.session.gameToken,
|
||||
profile: fixture.game.session.profile,
|
||||
}
|
||||
);
|
||||
await page.route('**/image/**', (route) => route.fulfill({ status: 204, body: '' }));
|
||||
await page.route('**/che/api/events**', (route) => route.abort());
|
||||
await page.route('**/che/api/trpc/**', async (route) => {
|
||||
const body = route.request().postDataJSON();
|
||||
const results = operationNames(route).map((operation) => {
|
||||
if (operation === 'lobby.info') {
|
||||
return response({ ...fixture.game.lobby, myGeneral: general });
|
||||
}
|
||||
if (operation === 'general.me') return response(generalContext);
|
||||
if (operation === 'world.getMapLayout') return response(fixture.game.mapLayout);
|
||||
if (operation === 'world.getMap') {
|
||||
return response({ ...fixture.game.map, myCity: 1, myNation: 1 });
|
||||
}
|
||||
if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] });
|
||||
if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') {
|
||||
return response([]);
|
||||
}
|
||||
if (operation === 'messages.getRecent') return response(buildMessages(options.permission));
|
||||
if (operation === 'messages.getContacts') return response(contacts);
|
||||
if (operation === 'board.getAccess') return response({ canMeeting: true, canSecret: true });
|
||||
if (operation === 'tournament.getState') return response({ stage: 0 });
|
||||
if (
|
||||
operation === 'messages.send' ||
|
||||
operation === 'messages.readLatest' ||
|
||||
operation === 'messages.delete' ||
|
||||
operation === 'messages.respond'
|
||||
) {
|
||||
mutations.push({ operation, body });
|
||||
if (operation === 'messages.send' && options.sendError) {
|
||||
return errorResponse(operation, options.sendError);
|
||||
}
|
||||
return response(operation === 'messages.respond' ? { result: true, reason: 'success' } : { ok: true });
|
||||
}
|
||||
return errorResponse(operation, `Unhandled message fixture operation: ${operation}`);
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(results),
|
||||
});
|
||||
});
|
||||
return mutations;
|
||||
};
|
||||
|
||||
const openMessages = async (page: Page, viewport: { width: number; height: number }) => {
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto(`http://127.0.0.1:${gamePort}/che/`);
|
||||
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
||||
if (viewport.width <= 1024) {
|
||||
await page.getByRole('button', { name: '메시지', exact: true }).click();
|
||||
}
|
||||
await expect(page.locator('.MessagePanel')).toBeVisible();
|
||||
};
|
||||
|
||||
for (const viewport of [
|
||||
{ width: 1000, height: 900 },
|
||||
{ width: 500, height: 900 },
|
||||
]) {
|
||||
test(`matches the reference message computed DOM at ${viewport.width}px Chromium viewport`, async ({ page }) => {
|
||||
await installFixture(page, { permission: 4 });
|
||||
await openMessages(page, viewport);
|
||||
const geometry = await page.locator('.MessagePanel').evaluate((panel) => {
|
||||
const required = (selector: string) => panel.querySelector<HTMLElement>(selector)!;
|
||||
const rect = (element: Element) => {
|
||||
const box = element.getBoundingClientRect();
|
||||
return { x: box.x, y: box.y, width: box.width, height: box.height };
|
||||
};
|
||||
const panelStyle = getComputedStyle(panel);
|
||||
const header = required('.BoardHeader');
|
||||
const plate = required('.msg-plate');
|
||||
const icon = required('.general-icon');
|
||||
return {
|
||||
panel: rect(panel),
|
||||
inputForm: rect(required('.MessageInputForm')),
|
||||
select: rect(required('.message-select')),
|
||||
input: rect(required('.message-text')),
|
||||
submit: rect(required('.message-send')),
|
||||
publicSection: rect(required('.PublicTalk')),
|
||||
nationalSection: rect(required('.NationalTalk')),
|
||||
firstHeader: rect(header),
|
||||
firstPlate: rect(plate),
|
||||
firstIcon: rect(icon),
|
||||
computed: {
|
||||
panelDisplay: panelStyle.display,
|
||||
panelColumns: panelStyle.gridTemplateColumns,
|
||||
panelFontSize: panelStyle.fontSize,
|
||||
headerColor: getComputedStyle(header).color,
|
||||
headerOutlineWidth: getComputedStyle(header).outlineWidth,
|
||||
plateBackgroundColor: getComputedStyle(plate).backgroundColor,
|
||||
plateFontSize: getComputedStyle(plate).fontSize,
|
||||
plateMinHeight: getComputedStyle(plate).minHeight,
|
||||
iconObjectFit: getComputedStyle(icon).objectFit,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
expect(geometry.panel.x).toBeCloseTo(0, 0);
|
||||
expect(geometry.panel.width).toBeCloseTo(viewport.width, 0);
|
||||
expect(geometry.inputForm.width).toBeCloseTo(viewport.width, 0);
|
||||
expect(geometry.select.height).toBeCloseTo(35.5, 0);
|
||||
expect(geometry.submit.height).toBeCloseTo(35.5, 0);
|
||||
expect(geometry.firstHeader.height).toBeCloseTo(25, 0);
|
||||
expect(geometry.firstPlate.height).toBeGreaterThanOrEqual(64);
|
||||
expect(geometry.firstIcon).toMatchObject({ width: 64, height: 64 });
|
||||
expect(geometry.computed).toMatchObject({
|
||||
panelFontSize: '14px',
|
||||
headerColor: 'rgb(255, 255, 255)',
|
||||
headerOutlineWidth: '1px',
|
||||
plateBackgroundColor: 'rgb(20, 28, 101)',
|
||||
plateFontSize: '12.5px',
|
||||
plateMinHeight: '64px',
|
||||
iconObjectFit: 'fill',
|
||||
});
|
||||
|
||||
if (viewport.width === 1000) {
|
||||
expect(geometry.computed.panelDisplay).toBe('grid');
|
||||
expect(geometry.computed.panelColumns).toBe('500px 500px');
|
||||
expect(geometry.select.width).toBeCloseTo(166.66, 0);
|
||||
expect(geometry.input.width).toBeCloseTo(666.66, 0);
|
||||
expect(geometry.submit.width).toBeCloseTo(166.66, 0);
|
||||
expect(geometry.publicSection.width).toBeCloseTo(500, 0);
|
||||
expect(geometry.nationalSection.x).toBeCloseTo(500, 0);
|
||||
} else {
|
||||
expect(geometry.computed.panelDisplay).toBe('block');
|
||||
expect(geometry.select.width).toBeCloseTo(250, 0);
|
||||
expect(geometry.input.width).toBeCloseTo(500, 0);
|
||||
expect(geometry.input.height).toBeCloseTo(33.5, 0);
|
||||
expect(geometry.submit.width).toBeCloseTo(250, 0);
|
||||
}
|
||||
|
||||
const submit = page.locator('.message-send');
|
||||
await submit.hover();
|
||||
expect(
|
||||
await submit.evaluate((element) => ({
|
||||
cursor: getComputedStyle(element).cursor,
|
||||
backgroundColor: getComputedStyle(element).backgroundColor,
|
||||
}))
|
||||
).toEqual({ cursor: 'pointer', backgroundColor: 'rgb(55, 90, 127)' });
|
||||
await submit.focus();
|
||||
expect(
|
||||
await submit.evaluate((element) => ({
|
||||
outlineWidth: getComputedStyle(element).outlineWidth,
|
||||
boxShadow: getComputedStyle(element).boxShadow,
|
||||
}))
|
||||
).toEqual({ outlineWidth: '0px', boxShadow: 'none' });
|
||||
});
|
||||
}
|
||||
|
||||
test('exposes ambassador targets, reply, read, delete, and successful send interactions', async ({ page }) => {
|
||||
const mutations = await installFixture(page, { permission: 4 });
|
||||
await openMessages(page, { width: 500, height: 900 });
|
||||
|
||||
const select = page.getByLabel('메시지 수신 대상');
|
||||
await expect(select.locator('option[value="9002"]')).toHaveCount(1);
|
||||
await expect(select.locator('option[value="8"]')).toBeDisabled();
|
||||
await expect(select.locator('option[value="9"]')).toBeEnabled();
|
||||
|
||||
await page.locator('.PrivateTalk .msg-target').filter({ hasText: '상대장수' }).click();
|
||||
await expect(select).toHaveValue('8');
|
||||
|
||||
await page.locator('.PrivateTalk').getByRole('button', { name: '모두 읽음' }).click();
|
||||
await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.readLatest').length).toBe(1);
|
||||
|
||||
const deleteButton = page.locator('.PublicTalk .delete-message');
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await deleteButton.click();
|
||||
await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.delete').length).toBe(1);
|
||||
|
||||
await select.selectOption('9999');
|
||||
await page.getByLabel('메시지 입력').fill('전송 성공');
|
||||
await page.getByRole('button', { name: '서신전달&갱신' }).click();
|
||||
await expect(page.getByLabel('메시지 입력')).toHaveValue('');
|
||||
await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.send').length).toBe(1);
|
||||
});
|
||||
|
||||
test('redacts diplomacy for a low-permission general and preserves the failed-send error flow', async ({ page }) => {
|
||||
const mutations = await installFixture(page, {
|
||||
permission: 2,
|
||||
sendError: '공개 메세지를 보낼 수 없습니다.',
|
||||
});
|
||||
await openMessages(page, { width: 500, height: 900 });
|
||||
|
||||
const select = page.getByLabel('메시지 수신 대상');
|
||||
await expect(select.locator('option[value="9002"]')).toHaveCount(0);
|
||||
await expect(page.locator('.DiplomacyTalk')).toContainText('삭제된 메시지입니다');
|
||||
await expect(page.locator('.DiplomacyTalk')).not.toContainText('외교 메시지 본문');
|
||||
await expect(page.locator('.DiplomacyTalk .message-response button').first()).toBeDisabled();
|
||||
|
||||
await select.selectOption('9999');
|
||||
await page.getByLabel('메시지 입력').fill('차단될 메시지');
|
||||
await page.getByRole('button', { name: '서신전달&갱신' }).click();
|
||||
await expect(page.getByLabel('메시지 입력')).toHaveValue('');
|
||||
await expect(page.locator('.error')).toHaveText('공개 메세지를 보낼 수 없습니다.');
|
||||
await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.send').length).toBe(1);
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { resolve } from 'node:path';
|
||||
import { canonicalFrontendFixture as fixture } from './fixtures/canonical';
|
||||
|
||||
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
|
||||
const gamePort = process.env.FRONTEND_PARITY_GAME_PORT ?? '15102';
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
|
||||
const operationNames = (route: Route): string[] => {
|
||||
@@ -90,6 +91,7 @@ const messageBundle = (visible: boolean, canRespondDiplomacy = true) => ({
|
||||
sequence: visible ? diplomacyMessage.id : -1,
|
||||
nationId: 1,
|
||||
generalName: general.name,
|
||||
permission: canRespondDiplomacy ? 4 : 2,
|
||||
canRespondDiplomacy,
|
||||
latestRead: { diplomacy: 0, private: 0 },
|
||||
});
|
||||
@@ -131,6 +133,9 @@ const installFixture = async (
|
||||
if (operation === 'messages.getRecent') {
|
||||
return response(messageBundle(visible, options.canRespondDiplomacy));
|
||||
}
|
||||
if (operation === 'messages.getContacts') return response({ nation: [] });
|
||||
if (operation === 'board.getAccess') return response({ canMeeting: true, canSecret: true });
|
||||
if (operation === 'tournament.getState') return response({ stage: 0 });
|
||||
if (operation === 'messages.respond') {
|
||||
mutations.push({ operation, body: requestBody });
|
||||
if (options.acceptResponse) {
|
||||
@@ -151,9 +156,9 @@ const installFixture = async (
|
||||
};
|
||||
|
||||
const openDiplomacyTab = async (page: Page) => {
|
||||
await page.goto('http://127.0.0.1:15102/che/');
|
||||
await page.goto(`http://127.0.0.1:${gamePort}/che/`);
|
||||
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
||||
await page.getByRole('button', { name: '외교', exact: true }).last().click();
|
||||
await expect(page.locator('.DiplomacyTalk')).toBeVisible();
|
||||
await expect(page.getByText(diplomacyMessage.text)).toBeVisible();
|
||||
};
|
||||
|
||||
@@ -186,16 +191,16 @@ test.describe('instant diplomacy response UI', () => {
|
||||
});
|
||||
|
||||
expect(geometry.buttons).toHaveLength(2);
|
||||
expect(geometry.buttons[1]!.x - (geometry.buttons[0]!.x + geometry.buttons[0]!.width)).toBeCloseTo(4, 0);
|
||||
expect(geometry.buttons[1]!.x - (geometry.buttons[0]!.x + geometry.buttons[0]!.width)).toBeCloseTo(0, 0);
|
||||
expect(geometry.buttons[0]).toMatchObject({
|
||||
color: 'rgb(143, 209, 143)',
|
||||
fontSize: '11.2px',
|
||||
color: 'rgb(255, 255, 255)',
|
||||
fontSize: '12.5px',
|
||||
borderWidth: '1px',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
expect(geometry.buttons[1]).toMatchObject({
|
||||
color: 'rgb(224, 154, 154)',
|
||||
fontSize: '11.2px',
|
||||
color: 'rgb(255, 255, 255)',
|
||||
fontSize: '12.5px',
|
||||
borderWidth: '1px',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
@@ -234,18 +239,17 @@ test.describe('instant diplomacy response UI', () => {
|
||||
test('keeps the message and exposes a rejected response on mobile Chromium', async ({ page }) => {
|
||||
const mutations = await installFixture(page, { acceptResponse: false });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('http://127.0.0.1:15102/che/');
|
||||
await page.goto(`http://127.0.0.1:${gamePort}/che/`);
|
||||
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
||||
await page.getByRole('button', { name: '메시지', exact: true }).click();
|
||||
await page.getByRole('button', { name: '외교', exact: true }).click();
|
||||
|
||||
const responseRow = page.locator('.message-response');
|
||||
await expect(responseRow).toBeVisible();
|
||||
const itemWidth = await page
|
||||
.locator('.message-item')
|
||||
.locator('.DiplomacyTalk .msg-plate')
|
||||
.evaluate((element) => element.getBoundingClientRect().width);
|
||||
expect(itemWidth).toBeGreaterThan(320);
|
||||
expect(itemWidth).toBeLessThanOrEqual(342);
|
||||
expect(itemWidth).toBeGreaterThanOrEqual(389);
|
||||
expect(itemWidth).toBeLessThanOrEqual(390);
|
||||
|
||||
page.once('dialog', async (dialog) => {
|
||||
expect(dialog.message()).toBe('거절하시겠습니까?');
|
||||
@@ -272,10 +276,9 @@ test.describe('instant diplomacy response UI', () => {
|
||||
canRespondDiplomacy: false,
|
||||
});
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('http://127.0.0.1:15102/che/');
|
||||
await page.goto(`http://127.0.0.1:${gamePort}/che/`);
|
||||
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
||||
await page.getByRole('button', { name: '메시지', exact: true }).click();
|
||||
await page.getByRole('button', { name: '외교', exact: true }).click();
|
||||
|
||||
const accept = page.locator('.message-response').getByRole('button', { name: '수락' });
|
||||
await expect(accept).toBeDisabled();
|
||||
@@ -284,7 +287,7 @@ test.describe('instant diplomacy response UI', () => {
|
||||
const style = getComputedStyle(element);
|
||||
return { cursor: style.cursor, opacity: style.opacity };
|
||||
})
|
||||
).toEqual({ cursor: 'not-allowed', opacity: '0.5' });
|
||||
).toEqual({ cursor: 'not-allowed', opacity: '0.65' });
|
||||
await accept.click({ force: true });
|
||||
expect(mutations).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
const baseUrl = process.env.REF_NATION_BETTING_URL ?? 'https://dev-sam-ref.hided.net/sam/';
|
||||
const staticBaseUrl = process.env.REF_NATION_BETTING_STATIC_BASE_URL;
|
||||
const username = process.env.REF_NATION_BETTING_USER ?? 'refuser1';
|
||||
const passwordFile = process.env.REF_NATION_BETTING_PASSWORD_FILE;
|
||||
const artifactRoot = resolve(process.env.REF_NATION_BETTING_ARTIFACT_DIR ?? 'test-results/reference-nation-betting');
|
||||
|
||||
if (!staticBaseUrl && !passwordFile) {
|
||||
throw new Error('REF_NATION_BETTING_PASSWORD_FILE is required.');
|
||||
}
|
||||
|
||||
const password = passwordFile ? (await readFile(passwordFile, 'utf8')).trim() : '';
|
||||
|
||||
const bettingList = {
|
||||
result: true,
|
||||
bettingList: {
|
||||
7: {
|
||||
id: 7,
|
||||
type: 'bettingNation',
|
||||
name: '천통국 예상',
|
||||
finished: false,
|
||||
selectCnt: 2,
|
||||
isExclusive: false,
|
||||
reqInheritancePoint: true,
|
||||
openYearMonth: 2316,
|
||||
closeYearMonth: 2340,
|
||||
winner: null,
|
||||
totalAmount: 800,
|
||||
},
|
||||
},
|
||||
year: 193,
|
||||
month: 1,
|
||||
};
|
||||
|
||||
const bettingDetail = {
|
||||
result: true,
|
||||
bettingInfo: {
|
||||
id: 7,
|
||||
type: 'bettingNation',
|
||||
name: '천통국 예상',
|
||||
finished: false,
|
||||
selectCnt: 2,
|
||||
isExclusive: false,
|
||||
reqInheritancePoint: true,
|
||||
openYearMonth: 2316,
|
||||
closeYearMonth: 2340,
|
||||
candidates: [
|
||||
{ title: '촉', info: '국력: 1200<br>장수 수: 8<br>도시 수: 5', isHtml: true },
|
||||
{ title: '위', info: '국력: 1100<br>장수 수: 7<br>도시 수: 4', isHtml: true },
|
||||
{ title: '오', info: '국력: 900<br>장수 수: 6<br>도시 수: 3', isHtml: true },
|
||||
{ title: '연', info: '국력: 700<br>장수 수: 5<br>도시 수: 2', isHtml: true },
|
||||
{ title: '양', info: '국력: 650<br>장수 수: 4<br>도시 수: 2', isHtml: true },
|
||||
{ title: '형', info: '국력: 600<br>장수 수: 3<br>도시 수: 1', isHtml: true },
|
||||
],
|
||||
winner: null,
|
||||
},
|
||||
bettingDetail: [
|
||||
['[-1]', 500],
|
||||
['[0,1]', 200],
|
||||
['[1,2]', 100],
|
||||
],
|
||||
myBetting: [['[0,1]', 50]],
|
||||
remainPoint: 1200,
|
||||
year: 193,
|
||||
month: 1,
|
||||
};
|
||||
|
||||
const login = async (context, page) => {
|
||||
await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 60_000 });
|
||||
const globalSalt = await page.locator('#global_salt').inputValue();
|
||||
// The reference entrance polls install status and can keep the PHP session
|
||||
// occupied. Leave it before the login request so the session lock is free.
|
||||
await page.goto('about:blank');
|
||||
await context.clearCookies();
|
||||
const passwordHash = createHash('sha512')
|
||||
.update(globalSalt + password + globalSalt)
|
||||
.digest('hex');
|
||||
const response = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
|
||||
data: { username, password: passwordHash },
|
||||
timeout: 60_000,
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok() || result.result !== true) {
|
||||
throw new Error('Reference login failed.');
|
||||
}
|
||||
};
|
||||
|
||||
const installBettingFixture = async (page) => {
|
||||
await page.route('**/api.php*', async (route) => {
|
||||
const path = new URL(route.request().url()).searchParams.get('path');
|
||||
if (path === 'Betting/GetBettingList') {
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(bettingList) });
|
||||
return;
|
||||
}
|
||||
if (path === 'Betting/GetBettingDetail') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(bettingDetail),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
};
|
||||
|
||||
const mountStaticReference = async (page) => {
|
||||
const hweUrl = new URL('hwe/', staticBaseUrl);
|
||||
const assetUrl = new URL('dist_js/hwe_dynamic/vue/', staticBaseUrl);
|
||||
await page.setContent(
|
||||
`<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=500">
|
||||
<base href="${hweUrl}">
|
||||
<link rel="stylesheet" href="${new URL('d_shared/common.css', hweUrl)}">
|
||||
<link rel="stylesheet" href="${new URL('vendors.css', assetUrl)}">
|
||||
<link rel="stylesheet" href="${new URL('common_ts.css', assetUrl)}">
|
||||
<link rel="stylesheet" href="${new URL('bootstrap.css', assetUrl)}">
|
||||
<link rel="stylesheet" href="${new URL('v_nationBetting.css', assetUrl)}">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="${new URL('d_shared/common_path.js', hweUrl)}"></script>
|
||||
<script src="${new URL('vendors.js', assetUrl)}"></script>
|
||||
<script src="${new URL('common_ts.js', assetUrl)}"></script>
|
||||
<script src="${new URL('bootstrap.js', assetUrl)}"></script>
|
||||
<script src="${new URL('v_nationBetting.js', assetUrl)}"></script>
|
||||
</body>
|
||||
</html>`,
|
||||
{ waitUntil: 'networkidle' }
|
||||
);
|
||||
};
|
||||
|
||||
const measure = async (browser, viewport) => {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: viewport.width, height: viewport.height },
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: 'dark',
|
||||
locale: 'ko-KR',
|
||||
timezoneId: 'UTC',
|
||||
ignoreHTTPSErrors: true,
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
await installBettingFixture(page);
|
||||
if (staticBaseUrl) {
|
||||
await mountStaticReference(page);
|
||||
} else {
|
||||
await login(context, page);
|
||||
await page.goto(new URL('hwe/v_nationBetting.php', baseUrl).toString(), {
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
await page.locator('.bettingItem').click();
|
||||
await page.locator('.bettingCandidate').first().waitFor({ state: 'visible' });
|
||||
|
||||
const geometry = await page.locator('#container').evaluate((container) => {
|
||||
const rect = (element) => {
|
||||
const value = element.getBoundingClientRect();
|
||||
return { x: value.x, y: value.y, width: value.width, height: value.height };
|
||||
};
|
||||
const cards = Array.from(container.querySelectorAll('.bettingCandidate'));
|
||||
const firstCard = cards[0];
|
||||
const cardStyle = getComputedStyle(firstCard);
|
||||
const titleStyle = getComputedStyle(firstCard.querySelector('.title'));
|
||||
const optionalRect = (selector) => {
|
||||
const element = container.querySelector(selector);
|
||||
return element ? rect(element) : null;
|
||||
};
|
||||
return {
|
||||
container: rect(container),
|
||||
topBar: rect(container.querySelector('.back_bar')),
|
||||
candidateCells: Array.from(container.querySelectorAll('.bettingCandidates > div')).map(rect),
|
||||
candidates: cards.map(rect),
|
||||
bettingForm: optionalRect('.bettingCandidates + .row'),
|
||||
payoutTable: optionalRect('.bettingCandidates + .row + div'),
|
||||
bettingList: optionalRect('.bettingList'),
|
||||
bottomBar: optionalRect('.bottom_bar, .bg0[style]'),
|
||||
cardStyle: {
|
||||
borderWidth: cardStyle.borderWidth,
|
||||
borderRadius: cardStyle.borderRadius,
|
||||
cursor: cardStyle.cursor,
|
||||
fontSize: cardStyle.fontSize,
|
||||
lineHeight: cardStyle.lineHeight,
|
||||
},
|
||||
titleStyle: {
|
||||
fontWeight: titleStyle.fontWeight,
|
||||
textAlign: titleStyle.textAlign,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await page.locator('.bettingCandidate').first().click();
|
||||
const pickedStyle = await page
|
||||
.locator('.bettingCandidate')
|
||||
.first()
|
||||
.evaluate((candidate) => {
|
||||
const style = getComputedStyle(candidate);
|
||||
return {
|
||||
borderColor: style.borderColor,
|
||||
outlineWidth: style.outlineWidth,
|
||||
titleWeight: getComputedStyle(candidate.querySelector('.title')).fontWeight,
|
||||
};
|
||||
});
|
||||
|
||||
const screenshotPath = resolve(artifactRoot, `nation-betting-ref-${viewport.name}.png`);
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true, animations: 'disabled' });
|
||||
return { geometry, pickedStyle, screenshotPath };
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
};
|
||||
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const result = {};
|
||||
for (const viewport of [
|
||||
{ name: 'desktop', width: 1280, height: 900 },
|
||||
{ name: 'mobile', width: 500, height: 900 },
|
||||
]) {
|
||||
result[viewport.name] = await measure(browser, viewport);
|
||||
}
|
||||
const outputPath = resolve(artifactRoot, 'computed-dom.json');
|
||||
await writeFile(outputPath, `${JSON.stringify(result, null, 2)}\n`);
|
||||
process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot, outputPath })}\n`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { defineConfig, devices } from '@playwright/test';
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const gatewayPort = process.env.FRONTEND_PARITY_GATEWAY_PORT ?? '15100';
|
||||
const gamePort = process.env.FRONTEND_PARITY_GAME_PORT ?? '15102';
|
||||
const reuseExistingServer = process.env.FRONTEND_PARITY_REUSE_SERVER === '1';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: '.',
|
||||
@@ -12,7 +13,9 @@ export default defineConfig({
|
||||
'visual-parity.spec.ts',
|
||||
'public-gaps.spec.ts',
|
||||
'instant-diplomacy-message.spec.ts',
|
||||
'ingame-message-parity.spec.ts',
|
||||
'tournament-betting.spec.ts',
|
||||
'dynasty-parity.spec.ts',
|
||||
'inheritance-management.spec.ts',
|
||||
],
|
||||
fullyParallel: false,
|
||||
@@ -40,14 +43,14 @@ export default defineConfig({
|
||||
command: `VITE_APP_BASE_PATH=/gateway VITE_GATEWAY_API_URL=/gateway/api/trpc VITE_GAME_API_URL_TEMPLATE=/{profile}/api/trpc VITE_GAME_ASSET_URL=/image pnpm --filter @sammo-ts/gateway-frontend dev --host 127.0.0.1 --port ${gatewayPort}`,
|
||||
cwd: repositoryRoot,
|
||||
url: `http://127.0.0.1:${gatewayPort}/gateway/`,
|
||||
reuseExistingServer: false,
|
||||
reuseExistingServer,
|
||||
timeout: 120_000,
|
||||
},
|
||||
{
|
||||
command: `VITE_APP_BASE_PATH=/che VITE_GAME_API_URL=/che/api/trpc VITE_GAME_ASSET_URL=/image VITE_GAME_PROFILE=che VITE_GATEWAY_WEB_URL=/gateway/ pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port ${gamePort}`,
|
||||
cwd: repositoryRoot,
|
||||
url: `http://127.0.0.1:${gamePort}/che/`,
|
||||
reuseExistingServer: false,
|
||||
reuseExistingServer,
|
||||
timeout: 120_000,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -4,10 +4,7 @@ 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'),
|
||||
];
|
||||
const imageRoots = [resolve(repositoryRoot, '../image/game'), resolve(repositoryRoot, '../../image/game')];
|
||||
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
|
||||
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
@@ -214,12 +211,39 @@ test('nation betting matches the legacy desktop geometry and preserves a failed
|
||||
const geometry = await page.locator('#nation-betting-container').evaluate((container) => {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const bar = container.querySelector<HTMLElement>('.legacy-top-bar')!.getBoundingClientRect();
|
||||
const detail = container.querySelector<HTMLElement>('.betting-detail')!;
|
||||
const detailRect = detail.getBoundingClientRect();
|
||||
const candidateRowElement = container.querySelector<HTMLElement>('.betting-candidates')!;
|
||||
const candidateRow = candidateRowElement.getBoundingClientRect();
|
||||
const candidateCells = Array.from(container.querySelectorAll<HTMLElement>('.betting-candidate-cell'));
|
||||
const cards = Array.from(container.querySelectorAll<HTMLElement>('.betting-candidate'));
|
||||
const cardStyle = getComputedStyle(cards[0]!);
|
||||
const optionalRect = (selector: string) => {
|
||||
const element = container.querySelector<HTMLElement>(selector);
|
||||
if (!element) return null;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||
};
|
||||
return {
|
||||
container: { x: containerRect.x, width: containerRect.width },
|
||||
container: { x: containerRect.x, width: containerRect.width, height: containerRect.height },
|
||||
bar: { width: bar.width, height: bar.height },
|
||||
cardWidths: cards.map((card) => card.getBoundingClientRect().width),
|
||||
detail: { x: detailRect.x, width: detailRect.width },
|
||||
candidateRow: {
|
||||
x: candidateRow.x,
|
||||
width: candidateRow.width,
|
||||
},
|
||||
candidateCells: candidateCells.map((cell) => {
|
||||
const rect = cell.getBoundingClientRect();
|
||||
return { x: rect.x, width: rect.width };
|
||||
}),
|
||||
cards: cards.map((card) => {
|
||||
const rect = card.getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||
}),
|
||||
bettingForm: optionalRect('.betting-form'),
|
||||
payoutTable: optionalRect('.payout-table'),
|
||||
bettingList: optionalRect('.betting-list'),
|
||||
bottomBar: optionalRect('.betting-footer'),
|
||||
cardStyle: {
|
||||
borderWidth: cardStyle.borderWidth,
|
||||
borderRadius: cardStyle.borderRadius,
|
||||
@@ -229,24 +253,43 @@ test('nation betting matches the legacy desktop geometry and preserves a failed
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
expect(geometry.container).toEqual({ x: 140, width: 1000 });
|
||||
expect(geometry.container).toEqual({ x: 140, width: 1000, height: 435 });
|
||||
expect(geometry.bar).toEqual({ width: 1000, height: 32 });
|
||||
expect(geometry.cardWidths.every((width) => Math.abs(width - 162) < 1)).toBe(true);
|
||||
expect(geometry.detail).toEqual({ x: 140, width: 1000 });
|
||||
expect(geometry.candidateRow).toEqual({ x: 138.25, width: 1003.5 });
|
||||
expect(geometry.candidateCells.map(({ width }) => width)).toEqual(Array(6).fill(167.25));
|
||||
expect(geometry.cards.map(({ width }) => width)).toEqual(Array(6).fill(163.75));
|
||||
expect(geometry.cards.map(({ height }) => height)).toEqual(Array(6).fill(143));
|
||||
expect(geometry.cards.map(({ y }) => y)).toEqual(Array(6).fill(53));
|
||||
expect(geometry.bettingForm).toEqual({ x: 140, y: 196, width: 1000, height: 35.5 });
|
||||
expect(geometry.payoutTable).toEqual({ x: 140, y: 231.5, width: 1000, height: 85 });
|
||||
expect(geometry.bettingList).toEqual({ x: 140, y: 330.5, width: 1000, height: 45.5 });
|
||||
expect(geometry.bottomBar).toEqual({ x: 140, y: 379.5, width: 1000, height: 55.5 });
|
||||
expect(geometry.cardStyle).toEqual({
|
||||
borderWidth: '1px',
|
||||
borderRadius: '7px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
lineHeight: '18.2px',
|
||||
lineHeight: '21px',
|
||||
});
|
||||
await expect(page.locator('.legacy-top-bar .legacy-nav-button')).toHaveCount(1);
|
||||
await expect(page.locator('.payout-row:not(.payout-head)').first().locator('div').nth(2)).toHaveText(
|
||||
'(50 -> 100.0)'
|
||||
);
|
||||
|
||||
await page.locator('.betting-candidate').nth(0).click();
|
||||
await page.locator('.betting-candidate').nth(1).click();
|
||||
const pickedStyle = await page.locator('.betting-candidate').first().evaluate((candidate) => {
|
||||
const style = getComputedStyle(candidate);
|
||||
return { borderColor: style.borderColor, outlineWidth: style.outlineWidth, titleWeight: getComputedStyle(candidate.querySelector('.candidate-title')!).fontWeight };
|
||||
});
|
||||
const pickedStyle = await page
|
||||
.locator('.betting-candidate')
|
||||
.first()
|
||||
.evaluate((candidate) => {
|
||||
const style = getComputedStyle(candidate);
|
||||
return {
|
||||
borderColor: style.borderColor,
|
||||
outlineWidth: style.outlineWidth,
|
||||
titleWeight: getComputedStyle(candidate.querySelector('.candidate-title')!).fontWeight,
|
||||
};
|
||||
});
|
||||
expect(pickedStyle.borderColor).toBe('rgb(255, 255, 255)');
|
||||
// Chromium snaps the legacy 1.5px CSS outline to one device pixel at DSF 1.
|
||||
expect(pickedStyle.outlineWidth).toBe('1px');
|
||||
@@ -281,6 +324,7 @@ test('nation betting keeps the legacy 500px three-column mobile contract', async
|
||||
return {
|
||||
x: rect.x,
|
||||
width: rect.width,
|
||||
firstX: cards[0]!.getBoundingClientRect().x,
|
||||
firstWidth: cards[0]!.getBoundingClientRect().width,
|
||||
fourthY: cards[3]!.getBoundingClientRect().y,
|
||||
firstY: cards[0]!.getBoundingClientRect().y,
|
||||
@@ -288,7 +332,8 @@ test('nation betting keeps the legacy 500px three-column mobile contract', async
|
||||
});
|
||||
expect(geometry.x).toBe(0);
|
||||
expect(geometry.width).toBe(500);
|
||||
expect(geometry.firstWidth).toBeCloseTo(161.328125, 3);
|
||||
expect(geometry.firstX).toBe(0);
|
||||
expect(geometry.firstWidth).toBe(164.328125);
|
||||
expect(geometry.fourthY).toBeGreaterThan(geometry.firstY);
|
||||
|
||||
if (artifactRoot) {
|
||||
@@ -325,17 +370,7 @@ test('NPC list matches the legacy table geometry, sorting and error retention',
|
||||
expect(geometry.tableWidth).toBe(1000);
|
||||
// The legacy width attributes total 974px; Chromium proportionally expands them into the 1000px table.
|
||||
expect(geometry.headerWidths).toEqual([
|
||||
104.609375,
|
||||
104.609375,
|
||||
69.734375,
|
||||
121.015625,
|
||||
69.734375,
|
||||
90.25,
|
||||
69.734375,
|
||||
69.734375,
|
||||
69.734375,
|
||||
69.734375,
|
||||
80,
|
||||
104.609375, 104.609375, 69.734375, 121.015625, 69.734375, 90.25, 69.734375, 69.734375, 69.734375, 69.734375, 80,
|
||||
80.109375,
|
||||
]);
|
||||
expect(geometry.headerStyle).toEqual({
|
||||
@@ -346,7 +381,10 @@ test('NPC list matches the legacy table geometry, sorting and error retention',
|
||||
lineHeight: '18.2px',
|
||||
});
|
||||
await expect(page.locator('.npc-table tbody tr').first()).toContainText('관우');
|
||||
await expect(page.locator('.npc-table tbody tr').first().locator('td').first()).toHaveCSS('color', 'rgb(135, 206, 235)');
|
||||
await expect(page.locator('.npc-table tbody tr').first().locator('td').first()).toHaveCSS(
|
||||
'color',
|
||||
'rgb(135, 206, 235)'
|
||||
);
|
||||
const personality = page.locator('.npc-table tbody tr').first().locator('.trait-tooltip').first();
|
||||
await personality.hover();
|
||||
await expect(personality.getByRole('tooltip')).toBeVisible();
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { chromium } from '@playwright/test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const baseUrl = process.env.REF_MENU_URL ?? 'http://127.0.0.1:3400/sam/';
|
||||
const username = process.env.REF_MENU_USER ?? 'refuser1';
|
||||
const passwordFile = process.env.REF_MENU_PASSWORD_FILE;
|
||||
const artifactRoot = resolve(process.env.REF_MENU_ARTIFACT_DIR ?? 'test-results/reference-ingame-menus');
|
||||
|
||||
if (!passwordFile) {
|
||||
throw new Error('REF_MENU_PASSWORD_FILE is required.');
|
||||
}
|
||||
|
||||
const password = (await readFile(passwordFile, 'utf8')).trim();
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
|
||||
const login = async (context, page) => {
|
||||
await page.goto(baseUrl, { waitUntil: 'networkidle' });
|
||||
const globalSalt = await page.locator('#global_salt').inputValue();
|
||||
const passwordHash = createHash('sha512')
|
||||
.update(globalSalt + password + globalSalt)
|
||||
.digest('hex');
|
||||
const response = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
|
||||
data: { username, password: passwordHash },
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok() || result.result !== true) {
|
||||
throw new Error('Reference login failed.');
|
||||
}
|
||||
};
|
||||
|
||||
const rectAndStyle = (element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||
style: {
|
||||
display: style.display,
|
||||
gridTemplateColumns: style.gridTemplateColumns,
|
||||
fontFamily: style.fontFamily,
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
color: style.color,
|
||||
backgroundColor: style.backgroundColor,
|
||||
backgroundImage: style.backgroundImage,
|
||||
borderTopColor: style.borderTopColor,
|
||||
borderTopWidth: style.borderTopWidth,
|
||||
padding: style.padding,
|
||||
margin: style.margin,
|
||||
cursor: style.cursor,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const measure = async (page, selectors) =>
|
||||
page.evaluate(
|
||||
({ selectors, measureSource }) => {
|
||||
const measureElement = new Function(`return (${measureSource})`)();
|
||||
const result = {};
|
||||
for (const [name, selector] of Object.entries(selectors)) {
|
||||
const element = document.querySelector(selector);
|
||||
result[name] = element ? measureElement(element) : null;
|
||||
}
|
||||
return {
|
||||
elements: result,
|
||||
document: {
|
||||
width: document.documentElement.scrollWidth,
|
||||
height: document.documentElement.scrollHeight,
|
||||
},
|
||||
};
|
||||
},
|
||||
{ selectors, measureSource: rectAndStyle.toString() }
|
||||
);
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const output = {};
|
||||
for (const viewport of [
|
||||
{ name: 'desktop', width: 1000, height: 900 },
|
||||
{ name: 'mobile', width: 500, height: 900 },
|
||||
]) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: viewport.width, height: viewport.height },
|
||||
deviceScaleFactor: 1,
|
||||
locale: 'ko-KR',
|
||||
timezoneId: 'Asia/Seoul',
|
||||
colorScheme: 'dark',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const consoleErrors = [];
|
||||
const failedResources = [];
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') consoleErrors.push(message.text());
|
||||
});
|
||||
page.on('response', (response) => {
|
||||
if (response.status() >= 400) failedResources.push(`${response.status()} ${response.url()}`);
|
||||
});
|
||||
await login(context, page);
|
||||
await page.goto(new URL('hwe/', baseUrl).toString(), { waitUntil: 'networkidle' });
|
||||
|
||||
await page.goto(new URL('hwe/b_myPage.php', baseUrl).toString(), { waitUntil: 'networkidle' });
|
||||
await page.locator('#container').waitFor();
|
||||
const myPage = await measure(page, {
|
||||
body: 'body',
|
||||
container: '#container',
|
||||
title: '#container > .row:first-child',
|
||||
infoColumn: '#container > .row:nth-child(2) > .col:first-child',
|
||||
settingsColumn: '#container > .row:nth-child(2) > .col:nth-child(2)',
|
||||
saveButton: '#set_my_setting',
|
||||
firstSelect: 'select',
|
||||
customCss: '#custom_css',
|
||||
firstLogTitle: '#generalActionPlate',
|
||||
});
|
||||
await page.screenshot({ path: resolve(artifactRoot, `ref-my-page-${viewport.name}.png`), fullPage: true });
|
||||
|
||||
await page.goto(new URL('hwe/a_traffic.php', baseUrl).toString(), { waitUntil: 'networkidle' });
|
||||
const traffic = await measure(page, {
|
||||
body: 'body',
|
||||
title: 'body > table:first-of-type',
|
||||
chartLayout: 'body > table:nth-of-type(2)',
|
||||
refreshChart: 'body > table:nth-of-type(2) > tbody > tr > td:first-child > table',
|
||||
onlineChart: 'body > table:nth-of-type(2) > tbody > tr > td:nth-child(2) > table',
|
||||
firstBigBar: '.big_bar',
|
||||
suspectTable: 'body > table:nth-of-type(3)',
|
||||
});
|
||||
await page.screenshot({ path: resolve(artifactRoot, `ref-traffic-${viewport.name}.png`), fullPage: true });
|
||||
|
||||
await page.goto(new URL('hwe/a_npcList.php', baseUrl).toString(), { waitUntil: 'networkidle' });
|
||||
const npcList = await measure(page, {
|
||||
body: 'body',
|
||||
title: 'body > table:first-of-type',
|
||||
sortSelect: 'select[name="type"]',
|
||||
list: 'body > table:nth-of-type(2)',
|
||||
header: 'body > table:nth-of-type(2) tr:first-child',
|
||||
footer: 'body > table:nth-of-type(3)',
|
||||
});
|
||||
await page.screenshot({ path: resolve(artifactRoot, `ref-npc-list-${viewport.name}.png`), fullPage: true });
|
||||
|
||||
await page.goto(new URL('hwe/v_battleCenter.php', baseUrl).toString(), { waitUntil: 'networkidle' });
|
||||
try {
|
||||
await page.locator('#container').waitFor({ timeout: 10_000 });
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Reference battle center failed to mount: ${JSON.stringify({
|
||||
url: page.url(),
|
||||
text: (await page.locator('body').innerText()).slice(0, 500),
|
||||
html: (await page.content()).slice(-1_000),
|
||||
consoleErrors,
|
||||
failedResources,
|
||||
})}`
|
||||
);
|
||||
}
|
||||
const battleCenter = await measure(page, {
|
||||
body: 'body',
|
||||
container: '#container',
|
||||
topBar: '#container > :first-child',
|
||||
selectorRow: '#container > .row:nth-child(2)',
|
||||
previousButton: '#container > .row:nth-child(2) button:first-child',
|
||||
firstSelect: '#container > .row:nth-child(2) select:first-of-type',
|
||||
generalCard: '.header-cell',
|
||||
firstLogHeader: '.header-cell:nth-of-type(1)',
|
||||
});
|
||||
await page.screenshot({
|
||||
path: resolve(artifactRoot, `ref-battle-center-${viewport.name}.png`),
|
||||
fullPage: true,
|
||||
});
|
||||
output[viewport.name] = { myPage, traffic, npcList, battleCenter };
|
||||
await context.close();
|
||||
}
|
||||
await writeFile(resolve(artifactRoot, 'computed-dom.json'), `${JSON.stringify(output, null, 2)}\n`);
|
||||
process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot, viewports: Object.keys(output) })}\n`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, readFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
const baseUrl = process.env.REF_MESSAGE_URL ?? 'http://127.0.0.1:3400/sam/';
|
||||
const username = process.env.REF_MESSAGE_USER ?? 'refuser1';
|
||||
const passwordFile = process.env.REF_MESSAGE_PASSWORD_FILE;
|
||||
const artifactRoot = process.env.REF_MESSAGE_ARTIFACT_DIR;
|
||||
|
||||
if (!passwordFile) {
|
||||
throw new Error('REF_MESSAGE_PASSWORD_FILE is required.');
|
||||
}
|
||||
|
||||
const password = (await readFile(passwordFile, 'utf8')).trim();
|
||||
|
||||
const login = async (context, page) => {
|
||||
await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 60_000 });
|
||||
const globalSalt = await page.locator('#global_salt').inputValue();
|
||||
const passwordHash = createHash('sha512')
|
||||
.update(globalSalt + password + globalSalt)
|
||||
.digest('hex');
|
||||
const response = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
|
||||
data: { username, password: passwordHash },
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok() || result.result !== true) {
|
||||
throw new Error('Reference login failed.');
|
||||
}
|
||||
};
|
||||
|
||||
const ensureGeneral = async (page) => {
|
||||
await page.goto(new URL('hwe/index.php', baseUrl).toString(), {
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 60_000,
|
||||
});
|
||||
if (await page.locator('.MessagePanel').isVisible()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await page.goto(new URL('hwe/v_join.php', baseUrl).toString(), {
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 60_000,
|
||||
});
|
||||
const create = page.getByRole('button', { name: '장수 생성', exact: true });
|
||||
await create.waitFor({ state: 'visible', timeout: 30_000 });
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await create.click();
|
||||
await page.locator('.MessagePanel').waitFor({ state: 'visible', timeout: 60_000 });
|
||||
};
|
||||
|
||||
const measure = async (browser, name, viewport) => {
|
||||
const context = await browser.newContext({
|
||||
viewport,
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: 'dark',
|
||||
locale: 'ko-KR',
|
||||
timezoneId: 'UTC',
|
||||
ignoreHTTPSErrors: true,
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
await login(context, page);
|
||||
await ensureGeneral(page);
|
||||
await page.locator('.MessagePanel').waitFor({ state: 'visible', timeout: 30_000 });
|
||||
await page.locator('.BoardHeader').first().waitFor({ state: 'visible' });
|
||||
const marker = `computed-dom-${name}-${Date.now()}`;
|
||||
await page.locator('.MessageInputForm select').selectOption('9999');
|
||||
await page.locator('.MessageInputForm input').fill(marker);
|
||||
await page.getByRole('button', { name: '서신전달&갱신' }).click();
|
||||
await page.getByText(marker, { exact: true }).waitFor({ state: 'visible', timeout: 30_000 });
|
||||
|
||||
if (artifactRoot) {
|
||||
const path = resolve(artifactRoot, `message-ref-${name}.png`);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await page.locator('.MessagePanel').screenshot({
|
||||
path,
|
||||
animations: 'disabled',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
const rect = (element) => {
|
||||
const box = element.getBoundingClientRect();
|
||||
return {
|
||||
x: box.x,
|
||||
y: box.y,
|
||||
width: box.width,
|
||||
height: box.height,
|
||||
};
|
||||
};
|
||||
const required = (selector) => {
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) throw new Error(`Missing reference selector: ${selector}`);
|
||||
return element;
|
||||
};
|
||||
const optionalRect = (selector) => {
|
||||
const element = document.querySelector(selector);
|
||||
return element ? rect(element) : null;
|
||||
};
|
||||
const style = (selector) => getComputedStyle(required(selector));
|
||||
const input = required('.MessageInputForm input');
|
||||
const select = required('.MessageInputForm select');
|
||||
const submit = required('#msg_submit-col button');
|
||||
const firstPlate = document.querySelector('.msg_plate');
|
||||
const firstIcon = document.querySelector('.msg_plate .generalIcon');
|
||||
const panelStyle = style('.MessagePanel');
|
||||
const headerStyle = style('.BoardHeader');
|
||||
const plateStyle = firstPlate ? getComputedStyle(firstPlate) : null;
|
||||
const iconStyle = firstIcon ? getComputedStyle(firstIcon) : null;
|
||||
return {
|
||||
panel: rect(required('.MessagePanel')),
|
||||
inputForm: rect(required('.MessageInputForm')),
|
||||
select: rect(select),
|
||||
input: rect(input),
|
||||
submit: rect(submit),
|
||||
publicSection: rect(required('.PublicTalk')),
|
||||
nationalSection: rect(required('.NationalTalk')),
|
||||
privateSection: rect(required('.PrivateTalk')),
|
||||
diplomacySection: rect(required('.DiplomacyTalk')),
|
||||
firstHeader: rect(required('.BoardHeader')),
|
||||
firstPlate: optionalRect('.msg_plate'),
|
||||
firstIcon: optionalRect('.msg_plate .generalIcon'),
|
||||
computed: {
|
||||
panelDisplay: panelStyle.display,
|
||||
panelColumns: panelStyle.gridTemplateColumns,
|
||||
panelFontSize: panelStyle.fontSize,
|
||||
headerColor: headerStyle.color,
|
||||
headerOutlineWidth: headerStyle.outlineWidth,
|
||||
headerBackgroundImage: headerStyle.backgroundImage,
|
||||
plateBackgroundColor: plateStyle?.backgroundColor ?? null,
|
||||
plateFontSize: plateStyle?.fontSize ?? null,
|
||||
plateMinHeight: plateStyle?.minHeight ?? null,
|
||||
iconObjectFit: iconStyle?.objectFit ?? null,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const submit = page.locator('#msg_submit-col button');
|
||||
await submit.hover();
|
||||
const hover = await submit.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
cursor: style.cursor,
|
||||
backgroundColor: style.backgroundColor,
|
||||
};
|
||||
});
|
||||
await submit.focus();
|
||||
const focus = await submit.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
outline: style.outline,
|
||||
boxShadow: style.boxShadow,
|
||||
};
|
||||
});
|
||||
const markerPlate = page.locator('.msg_plate').filter({ hasText: marker });
|
||||
const deleteButton = markerPlate.locator('.btn-delete-msg');
|
||||
if (await deleteButton.isVisible()) {
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await deleteButton.click();
|
||||
}
|
||||
return { ...result, interaction: { hover, focus } };
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
};
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const measurements = {
|
||||
desktop: await measure(browser, 'desktop', { width: 1000, height: 900 }),
|
||||
mobile: await measure(browser, 'mobile', { width: 500, height: 900 }),
|
||||
};
|
||||
process.stdout.write(`${JSON.stringify(measurements, null, 2)}\n`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { chromium } from '@playwright/test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const baseUrl = process.env.REF_PARITY_URL ?? 'http://127.0.0.1:3400/sam/';
|
||||
const username = process.env.REF_PARITY_USER ?? 'refadmin';
|
||||
const passwordFile = process.env.REF_PARITY_PASSWORD_FILE;
|
||||
const artifactRoot = resolve(process.env.REF_PARITY_ARTIFACT_DIR ?? 'test-results/reference-npc-policy');
|
||||
|
||||
if (!passwordFile) {
|
||||
throw new Error('REF_PARITY_PASSWORD_FILE is required.');
|
||||
}
|
||||
|
||||
const password = (await readFile(passwordFile, 'utf8')).trim();
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
|
||||
const login = async (context, page) => {
|
||||
await page.goto(baseUrl, { waitUntil: 'networkidle' });
|
||||
const globalSalt = await page.locator('#global_salt').inputValue();
|
||||
const passwordHash = createHash('sha512')
|
||||
.update(globalSalt + password + globalSalt)
|
||||
.digest('hex');
|
||||
const response = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
|
||||
data: { username, password: passwordHash },
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok() || result.result !== true) {
|
||||
throw new Error('Reference login failed.');
|
||||
}
|
||||
};
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const result = {};
|
||||
for (const viewport of [
|
||||
{ name: 'desktop', width: 1000, height: 900 },
|
||||
{ name: 'mobile', width: 500, height: 900 },
|
||||
]) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: viewport.width, height: viewport.height },
|
||||
deviceScaleFactor: 1,
|
||||
locale: 'ko-KR',
|
||||
timezoneId: 'Asia/Seoul',
|
||||
colorScheme: 'dark',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await login(context, page);
|
||||
await page.goto(new URL('hwe/', baseUrl).toString(), { waitUntil: 'networkidle' });
|
||||
await page.goto(new URL('hwe/v_NPCControl.php', baseUrl).toString(), { waitUntil: 'networkidle' });
|
||||
try {
|
||||
await page.locator('#container').waitFor({ timeout: 10_000 });
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Reference NPC policy failed to mount: ${JSON.stringify({
|
||||
url: page.url(),
|
||||
text: (await page.locator('body').innerText()).slice(0, 500),
|
||||
})}`
|
||||
);
|
||||
}
|
||||
result[viewport.name] = await page.evaluate(() => {
|
||||
const measure = (selector) => {
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) return null;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||
style: {
|
||||
display: style.display,
|
||||
gridTemplateColumns: style.gridTemplateColumns,
|
||||
fontFamily: style.fontFamily,
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
color: style.color,
|
||||
backgroundColor: style.backgroundColor,
|
||||
backgroundImage: style.backgroundImage,
|
||||
borderColor: style.borderColor,
|
||||
padding: style.padding,
|
||||
margin: style.margin,
|
||||
cursor: style.cursor,
|
||||
},
|
||||
};
|
||||
};
|
||||
return {
|
||||
body: measure('body'),
|
||||
container: measure('#container'),
|
||||
topBackBar: measure('body > :first-child'),
|
||||
sectionBar: measure('.section_bar'),
|
||||
formList: measure('.form_list'),
|
||||
firstField: measure('.form_list > .col'),
|
||||
firstInput: measure('input[type="number"]'),
|
||||
firstInfoButton: measure('.form_list button'),
|
||||
controlBar: measure('.control_bar'),
|
||||
resetButton: measure('.reset_btn'),
|
||||
submitButton: measure('.submit_btn'),
|
||||
priorityGrid: measure('.half_section_left'),
|
||||
priorityColumn: measure('.priority-list'),
|
||||
priorityItem: measure('.priority-list .list-group-item'),
|
||||
helpButton: measure('.priority_info button'),
|
||||
document: {
|
||||
width: document.documentElement.scrollWidth,
|
||||
height: document.documentElement.scrollHeight,
|
||||
},
|
||||
};
|
||||
});
|
||||
await page.screenshot({
|
||||
path: resolve(artifactRoot, `ref-npc-policy-${viewport.name}.png`),
|
||||
fullPage: true,
|
||||
});
|
||||
await context.close();
|
||||
}
|
||||
await writeFile(resolve(artifactRoot, 'computed-dom.json'), `${JSON.stringify(result, null, 2)}\n`);
|
||||
process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot, viewports: Object.keys(result) })}\n`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { chromium } from '@playwright/test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const baseUrl = process.env.REF_RANKING_URL ?? 'https://dev-sam-ref.hided.net/sam/';
|
||||
const username = process.env.REF_RANKING_USER ?? 'refuser1';
|
||||
const passwordFile = process.env.REF_RANKING_PASSWORD_FILE;
|
||||
const artifactRoot = resolve(process.env.REF_RANKING_ARTIFACT_DIR ?? 'test-results/reference-rankings');
|
||||
|
||||
if (!passwordFile) {
|
||||
throw new Error('REF_RANKING_PASSWORD_FILE is required.');
|
||||
}
|
||||
|
||||
const password = (await readFile(passwordFile, 'utf8')).trim();
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
|
||||
const login = async (context, page) => {
|
||||
await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 60_000 });
|
||||
const globalSalt = await page.locator('#global_salt').inputValue();
|
||||
const passwordHash = createHash('sha512')
|
||||
.update(globalSalt + password + globalSalt)
|
||||
.digest('hex');
|
||||
const response = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
|
||||
data: { username, password: passwordHash },
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok() || result.result !== true) {
|
||||
throw new Error('Reference login failed.');
|
||||
}
|
||||
};
|
||||
|
||||
const measureRanking = async (page) =>
|
||||
page.evaluate(() => {
|
||||
const pick = (selector) => {
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||
style: {
|
||||
fontFamily: style.fontFamily,
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
backgroundImage: style.backgroundImage,
|
||||
backgroundColor: style.backgroundColor,
|
||||
color: style.color,
|
||||
borderTopColor: style.borderTopColor,
|
||||
borderTopWidth: style.borderTopWidth,
|
||||
borderRadius: style.borderRadius,
|
||||
padding: style.padding,
|
||||
fontWeight: style.fontWeight,
|
||||
cursor: style.cursor,
|
||||
minHeight: style.minHeight,
|
||||
objectFit: style.objectFit,
|
||||
},
|
||||
};
|
||||
};
|
||||
const image = document.querySelector('.generalIcon');
|
||||
return {
|
||||
title: document.title,
|
||||
container: pick('#container'),
|
||||
rankType: pick('.rankType'),
|
||||
rankCell: pick('.rankView li'),
|
||||
uniqueCell: pick('.rankView li.no_value'),
|
||||
image: image
|
||||
? {
|
||||
...pick('.generalIcon'),
|
||||
naturalWidth: image.naturalWidth,
|
||||
naturalHeight: image.naturalHeight,
|
||||
}
|
||||
: null,
|
||||
firstButton: pick('button, input[type="submit"], input[type="button"]'),
|
||||
rankSectionCount: document.querySelectorAll('.rankView').length,
|
||||
document: {
|
||||
width: document.documentElement.scrollWidth,
|
||||
height: document.documentElement.scrollHeight,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const result = {};
|
||||
for (const viewport of [
|
||||
{ name: 'desktop', width: 1365, height: 768 },
|
||||
{ name: 'mobile', width: 390, height: 844 },
|
||||
]) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: viewport.width, height: viewport.height },
|
||||
deviceScaleFactor: 1,
|
||||
locale: 'ko-KR',
|
||||
timezoneId: 'UTC',
|
||||
colorScheme: 'dark',
|
||||
ignoreHTTPSErrors: true,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await login(context, page);
|
||||
await page.goto(new URL('hwe/', baseUrl).toString(), { waitUntil: 'networkidle', timeout: 60_000 });
|
||||
|
||||
await page.goto(new URL('hwe/a_bestGeneral.php', baseUrl).toString(), {
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 60_000,
|
||||
});
|
||||
await page.locator('#container').waitFor();
|
||||
const bestGeneral = await measureRanking(page);
|
||||
const userButton = page.getByRole('button', { name: '유저 보기' });
|
||||
await userButton.hover();
|
||||
bestGeneral.userButtonHover = await userButton.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return { backgroundColor: style.backgroundColor, cursor: style.cursor };
|
||||
});
|
||||
await userButton.focus();
|
||||
bestGeneral.userButtonFocus = await userButton.evaluate((element) => getComputedStyle(element).outline);
|
||||
await page.screenshot({
|
||||
path: resolve(artifactRoot, `ref-best-general-${viewport.name}.png`),
|
||||
fullPage: true,
|
||||
animations: 'disabled',
|
||||
});
|
||||
|
||||
await page.goto(new URL('hwe/a_hallOfFame.php', baseUrl).toString(), {
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 60_000,
|
||||
});
|
||||
await page.locator('#container').waitFor();
|
||||
const hallOfFame = await measureRanking(page);
|
||||
const scenario = page.locator('#by_scenario');
|
||||
hallOfFame.scenario = await scenario.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
rect: { width: rect.width, height: rect.height },
|
||||
fontFamily: style.fontFamily,
|
||||
fontSize: style.fontSize,
|
||||
};
|
||||
});
|
||||
await scenario.focus();
|
||||
hallOfFame.scenarioFocus = await scenario.evaluate((element) => getComputedStyle(element).outline);
|
||||
await page.screenshot({
|
||||
path: resolve(artifactRoot, `ref-hall-of-fame-${viewport.name}.png`),
|
||||
fullPage: true,
|
||||
animations: 'disabled',
|
||||
});
|
||||
|
||||
result[viewport.name] = { bestGeneral, hallOfFame };
|
||||
await context.close();
|
||||
}
|
||||
await writeFile(resolve(artifactRoot, 'computed-dom.json'), `${JSON.stringify(result, null, 2)}\n`);
|
||||
process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot, viewports: Object.keys(result) })}\n`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import { canonicalFrontendFixture as fixture } from './fixtures/canonical';
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const imageRoot = resolve(repositoryRoot, '../../image');
|
||||
const imageRoots = [resolve(repositoryRoot, '../image'), resolve(repositoryRoot, '../../image')];
|
||||
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
|
||||
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
@@ -28,11 +28,11 @@ const installImages = async (page: Page): Promise<void> => {
|
||||
await page.route('**/image/**', async (route) => {
|
||||
const pathname = decodeURIComponent(new URL(route.request().url()).pathname);
|
||||
const relative = pathname.replace(/^\/image\//, '');
|
||||
const candidates = [
|
||||
const candidates = imageRoots.flatMap((imageRoot) => [
|
||||
resolve(imageRoot, relative),
|
||||
resolve(imageRoot, 'game', relative),
|
||||
resolve(imageRoot, 'icons', '22.jpg'),
|
||||
];
|
||||
]);
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const body = await readFile(candidate);
|
||||
@@ -139,6 +139,7 @@ const installAuthenticatedGameFixture = async (page: Page): Promise<void> => {
|
||||
};
|
||||
}
|
||||
if (operation === 'public.getMapLayout') return fixture.game.mapLayout;
|
||||
if (operation === 'ranking.getBestGeneral') return fixture.game.bestGeneral;
|
||||
if (operation === 'yearbook.getRange') return fixture.game.yearbookRange;
|
||||
if (operation === 'yearbook.getHistory') return fixture.game.yearbook;
|
||||
if (operation === 'vote.getVoteList') return fixture.game.surveyList;
|
||||
@@ -387,6 +388,117 @@ test.describe('gateway legacy parity', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('best general legacy parity', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installAuthenticatedGameFixture(page);
|
||||
});
|
||||
|
||||
for (const viewport of [
|
||||
{ name: 'desktop', width: 1365, height: 768, expectedWidth: 1000 },
|
||||
{ name: 'mobile', width: 390, height: 844, expectedWidth: 500 },
|
||||
]) {
|
||||
test(`matches the ref fixed ranking grid on ${viewport.name}`, async ({ page }) => {
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto('http://127.0.0.1:15102/che/best-general');
|
||||
await expect(page.getByText('유비').first()).toBeVisible();
|
||||
await expect(page.locator('.rankView')).toHaveCount(3);
|
||||
if (artifactRoot) {
|
||||
await page.screenshot({
|
||||
path: resolve(artifactRoot, `best-general-core-${viewport.name}.png`),
|
||||
fullPage: true,
|
||||
animations: 'disabled',
|
||||
});
|
||||
}
|
||||
|
||||
const geometry = await page.evaluate(() => {
|
||||
const container = document.querySelector<HTMLElement>('#best-general-container')!;
|
||||
const item = document.querySelector<HTMLElement>('.rankView li')!;
|
||||
const uniqueItem = document.querySelector<HTMLElement>('.rankView li.no-value')!;
|
||||
const title = document.querySelector<HTMLElement>('.rankType')!;
|
||||
const image = document.querySelector<HTMLImageElement>('.generalIcon')!;
|
||||
return {
|
||||
container: {
|
||||
x: container.getBoundingClientRect().x,
|
||||
width: container.getBoundingClientRect().width,
|
||||
fontFamily: getComputedStyle(container).fontFamily,
|
||||
fontSize: getComputedStyle(container).fontSize,
|
||||
backgroundImage: getComputedStyle(container).backgroundImage,
|
||||
},
|
||||
item: {
|
||||
width: item.getBoundingClientRect().width,
|
||||
minHeight: getComputedStyle(item).minHeight,
|
||||
},
|
||||
uniqueItem: {
|
||||
minHeight: getComputedStyle(uniqueItem).minHeight,
|
||||
},
|
||||
title: {
|
||||
fontSize: getComputedStyle(title).fontSize,
|
||||
lineHeight: getComputedStyle(title).lineHeight,
|
||||
backgroundImage: getComputedStyle(title).backgroundImage,
|
||||
},
|
||||
image: {
|
||||
width: image.getBoundingClientRect().width,
|
||||
height: image.getBoundingClientRect().height,
|
||||
naturalWidth: image.naturalWidth,
|
||||
objectFit: getComputedStyle(image).objectFit,
|
||||
},
|
||||
closeX: document
|
||||
.querySelector<HTMLElement>('.legacy-ranking-title .legacy-button')!
|
||||
.getBoundingClientRect().x,
|
||||
};
|
||||
});
|
||||
|
||||
expect(geometry.container.width).toBe(viewport.expectedWidth);
|
||||
expect(geometry.container.fontFamily).toContain('Pretendard');
|
||||
expect(geometry.container.fontSize).toBe('14px');
|
||||
expect(geometry.container.backgroundImage).toContain('back_walnut.jpg');
|
||||
expect(geometry.closeX).toBe(geometry.container.x);
|
||||
expect(geometry.item).toEqual({ width: 100, minHeight: '149px' });
|
||||
expect(geometry.uniqueItem.minHeight).toBe('128px');
|
||||
expect(geometry.title).toMatchObject({
|
||||
fontSize: viewport.name === 'desktop' ? '28px' : '22.06px',
|
||||
lineHeight: viewport.name === 'desktop' ? '33.6px' : '26.472px',
|
||||
});
|
||||
expect(geometry.title.backgroundImage).toContain('back_green.jpg');
|
||||
expect(geometry.image).toMatchObject({ width: 64, height: 64, objectFit: 'fill' });
|
||||
expect(geometry.image.naturalWidth).toBeGreaterThan(0);
|
||||
|
||||
const npcButton = page.getByRole('button', { name: 'NPC 보기' });
|
||||
await npcButton.hover();
|
||||
await expect(npcButton).toHaveCSS('background-color', 'rgb(107, 107, 107)');
|
||||
await npcButton.focus();
|
||||
await expect(npcButton).toBeFocused();
|
||||
await npcButton.click();
|
||||
await expect(npcButton).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
const itemName = page.locator('.item-name').first();
|
||||
await itemName.hover();
|
||||
await expect(itemName).toHaveAttribute('title', '최고의 명마');
|
||||
});
|
||||
}
|
||||
|
||||
test('keeps the current ranking and selected user type after an API error', async ({ page }) => {
|
||||
await page.goto('http://127.0.0.1:15102/che/best-general');
|
||||
await expect(page.getByText('유비').first()).toBeVisible();
|
||||
await page.route('**/che/api/trpc/**', async (route) => {
|
||||
if (operationNames(route).includes('ranking.getBestGeneral')) {
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: { message: '명장일람 조회에 실패했습니다.' } }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fallback();
|
||||
});
|
||||
const npcButton = page.getByRole('button', { name: 'NPC 보기' });
|
||||
await npcButton.click();
|
||||
await expect(page.getByRole('alert')).toBeVisible();
|
||||
await expect(page.getByText('유비').first()).toBeVisible();
|
||||
await expect(npcButton).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('hall of fame legacy parity', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installHallFixture(page);
|
||||
@@ -401,6 +513,13 @@ test.describe('hall of fame legacy parity', () => {
|
||||
await page.goto('http://127.0.0.1:15102/che/hall-of-fame');
|
||||
await expect(page.getByText('유비')).toBeVisible();
|
||||
await expect(page.locator('.rankView')).toHaveCount(2);
|
||||
if (artifactRoot) {
|
||||
await page.screenshot({
|
||||
path: resolve(artifactRoot, `hall-of-fame-core-${viewport.name}.png`),
|
||||
fullPage: true,
|
||||
animations: 'disabled',
|
||||
});
|
||||
}
|
||||
|
||||
const geometry = await page.evaluate(() => {
|
||||
const container = document.querySelector<HTMLElement>('#container')!;
|
||||
@@ -409,7 +528,10 @@ test.describe('hall of fame legacy parity', () => {
|
||||
const titleStyle = getComputedStyle(document.querySelector<HTMLElement>('.rankType')!);
|
||||
const image = document.querySelector<HTMLImageElement>('.generalIcon')!;
|
||||
return {
|
||||
container: container.getBoundingClientRect().width,
|
||||
container: {
|
||||
x: container.getBoundingClientRect().x,
|
||||
width: container.getBoundingClientRect().width,
|
||||
},
|
||||
containerBackgroundImage: getComputedStyle(container).backgroundImage,
|
||||
item: {
|
||||
width: item.getBoundingClientRect().width,
|
||||
@@ -427,23 +549,57 @@ test.describe('hall of fame legacy parity', () => {
|
||||
naturalHeight: image.naturalHeight,
|
||||
objectFit: getComputedStyle(image).objectFit,
|
||||
},
|
||||
closeX: document
|
||||
.querySelector<HTMLElement>('.legacy-hall-title .legacy-button')!
|
||||
.getBoundingClientRect().x,
|
||||
};
|
||||
});
|
||||
|
||||
expect(geometry.container).toBe(viewport.expectedWidth);
|
||||
expect(geometry.container.width).toBe(viewport.expectedWidth);
|
||||
expect(geometry.closeX).toBe(geometry.container.x);
|
||||
expect(geometry.containerBackgroundImage).toContain('back_walnut.jpg');
|
||||
expect(geometry.item.width).toBe(100);
|
||||
expect(geometry.title.fontFamily).toContain('Pretendard');
|
||||
expect(geometry.title.fontSize).toBe(viewport.name === 'desktop' ? '28px' : '22.06px');
|
||||
expect(geometry.title.backgroundImage).toContain('back_green.jpg');
|
||||
expect(geometry.image).toMatchObject({ width: 64, height: 64, objectFit: 'cover' });
|
||||
expect(geometry.image).toMatchObject({ width: 64, height: 64, objectFit: 'fill' });
|
||||
expect(geometry.image.naturalWidth).toBeGreaterThan(0);
|
||||
|
||||
const close = page.getByRole('button', { name: '창 닫기' }).first();
|
||||
await close.hover();
|
||||
await expect(close).toHaveCSS('background-color', 'rgb(107, 107, 107)');
|
||||
await close.focus();
|
||||
await expect(close).toBeFocused();
|
||||
|
||||
const scenario = page.getByLabel('시나리오 검색');
|
||||
await expect(scenario).toHaveCSS('width', '189px');
|
||||
await scenario.focus();
|
||||
await expect(scenario).toBeFocused();
|
||||
await scenario.selectOption('scenario:1:22');
|
||||
await expect(scenario).toHaveValue('scenario:1:22');
|
||||
});
|
||||
}
|
||||
|
||||
test('keeps the selected scenario after a hall API error', async ({ page }) => {
|
||||
await page.goto('http://127.0.0.1:15102/che/hall-of-fame');
|
||||
await expect(page.getByText('유비')).toBeVisible();
|
||||
await page.route('**/che/api/trpc/**', async (route) => {
|
||||
if (operationNames(route).includes('ranking.getHallOfFame')) {
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: { message: '명예의 전당 조회에 실패했습니다.' } }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fallback();
|
||||
});
|
||||
const scenario = page.getByLabel('시나리오 검색');
|
||||
await scenario.selectOption('scenario:1:22');
|
||||
await expect(page.getByRole('alert')).toBeVisible();
|
||||
await expect(scenario).toHaveValue('scenario:1:22');
|
||||
await expect(page.getByText('유비')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test('game login delegates to the gateway like the ref entry point', async ({ page }) => {
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface CanonicalTurnSnapshot {
|
||||
engine: CanonicalEngine;
|
||||
world: Record<string, unknown>;
|
||||
generals: Array<Record<string, unknown>>;
|
||||
rankData: Array<Record<string, unknown>>;
|
||||
cities: Array<Record<string, unknown>>;
|
||||
nations: Array<Record<string, unknown>>;
|
||||
diplomacy: Array<Record<string, unknown>>;
|
||||
@@ -91,6 +92,7 @@ export const projectCoreDatabaseSnapshot = (rows: {
|
||||
meta: unknown;
|
||||
};
|
||||
generals: Array<Record<string, unknown>>;
|
||||
rankData: Array<Record<string, unknown>>;
|
||||
cities: Array<Record<string, unknown>>;
|
||||
nations: Array<Record<string, unknown>>;
|
||||
diplomacy: Array<Record<string, unknown>>;
|
||||
@@ -99,6 +101,7 @@ export const projectCoreDatabaseSnapshot = (rows: {
|
||||
logs: Array<Record<string, unknown>>;
|
||||
}): CanonicalTurnSnapshot => {
|
||||
const worldMeta = asRecord(rows.world.meta);
|
||||
const legacyRankTypes = new Set<string>(LEGACY_RANK_DATA_TYPES);
|
||||
const generals = rows.generals.map((row) => {
|
||||
const meta = asRecord(row.meta);
|
||||
return {
|
||||
@@ -235,6 +238,14 @@ export const projectCoreDatabaseSnapshot = (rows: {
|
||||
isUnited: readNumber(worldMeta, 'isUnited', readNumber(worldMeta, 'isunited')),
|
||||
},
|
||||
generals,
|
||||
rankData: rows.rankData
|
||||
.filter((row) => typeof row.type === 'string' && legacyRankTypes.has(row.type))
|
||||
.map((row) => ({
|
||||
generalId: row.generalId,
|
||||
nationId: row.nationId,
|
||||
type: row.type,
|
||||
value: row.value,
|
||||
})),
|
||||
cities,
|
||||
nations,
|
||||
diplomacy,
|
||||
@@ -248,3 +259,4 @@ export const projectCoreDatabaseSnapshot = (rows: {
|
||||
},
|
||||
};
|
||||
};
|
||||
import { LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common';
|
||||
|
||||
@@ -14,6 +14,12 @@ export interface SnapshotComparisonOptions {
|
||||
type FlatSnapshot = Map<string, unknown>;
|
||||
|
||||
const entityKey = (value: Record<string, unknown>, index: number): string => {
|
||||
if (
|
||||
(typeof value.generalId === 'number' || typeof value.generalId === 'string') &&
|
||||
typeof value.type === 'string'
|
||||
) {
|
||||
return `${String(value.generalId)}:${value.type}`;
|
||||
}
|
||||
for (const key of ['id', 'generalId', 'nationId', 'fromNationId']) {
|
||||
const candidate = value[key];
|
||||
if (typeof candidate === 'number' || typeof candidate === 'string') {
|
||||
|
||||
@@ -18,6 +18,10 @@ import type {
|
||||
TurnWorldSnapshot,
|
||||
TurnWorldState,
|
||||
} from '@sammo-ts/game-engine/turn/types.js';
|
||||
import {
|
||||
applyPersistedRankRowsToMeta,
|
||||
buildLegacyComparableRankRows,
|
||||
} from '@sammo-ts/game-engine/turn/rankData.js';
|
||||
|
||||
import {
|
||||
canonicalizeTurnCommandArgs,
|
||||
@@ -47,6 +51,7 @@ export interface TurnCommandFixtureRequest {
|
||||
};
|
||||
isolateWorld?: boolean;
|
||||
generals?: Array<Record<string, unknown>>;
|
||||
rankData?: Array<{ generalId: number; type: string; value: number }>;
|
||||
nations?: Array<Record<string, unknown>>;
|
||||
cities?: Array<Record<string, unknown>>;
|
||||
troops?: Array<Record<string, unknown>>;
|
||||
@@ -295,6 +300,17 @@ const buildWorldInput = (
|
||||
const month = readNumber(referenceBefore.world, 'month', request.setup?.world?.month ?? 1);
|
||||
const turnTime = new Date(`${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-01T00:00:00.000Z`);
|
||||
const generals = referenceBefore.generals.map((row) => buildGeneral(row, turnTime));
|
||||
for (const general of generals) {
|
||||
applyPersistedRankRowsToMeta(
|
||||
general.meta,
|
||||
referenceBefore.rankData
|
||||
.filter((row) => readNumber(row, 'generalId') === general.id)
|
||||
.map((row) => ({
|
||||
type: readString(row, 'type', ''),
|
||||
value: readNumber(row, 'value'),
|
||||
}))
|
||||
);
|
||||
}
|
||||
const referenceGeneralCooldowns = Array.isArray(referenceBefore.world.generalCooldowns)
|
||||
? referenceBefore.world.generalCooldowns
|
||||
: [];
|
||||
@@ -348,6 +364,9 @@ const buildWorldInput = (
|
||||
maxGeneral: 500,
|
||||
baseGold: 0,
|
||||
baseRice: 2_000,
|
||||
generalMinimumGold: 0,
|
||||
generalMinimumRice: 500,
|
||||
npcSeizureMessageProb: 0.01,
|
||||
maxResourceActionAmount: 10_000,
|
||||
maxTechLevel: 12,
|
||||
maxLevel: 255,
|
||||
@@ -521,6 +540,11 @@ const projectWorld = (
|
||||
}),
|
||||
},
|
||||
generals,
|
||||
rankData: world
|
||||
.listGenerals()
|
||||
.filter((general) => selector.generalIds.has(general.id))
|
||||
.flatMap(buildLegacyComparableRankRows)
|
||||
.map((row) => ({ ...row })),
|
||||
cities: world
|
||||
.listCities()
|
||||
.filter((city) => selector.cityIds.has(city.id))
|
||||
|
||||
@@ -11,11 +11,15 @@ export const readCoreDatabaseSnapshot = async (
|
||||
try {
|
||||
const db = connector.prisma;
|
||||
const world = await db.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } });
|
||||
const [generals, cities, nations, diplomacy, generalTurns, nationTurns, logs] = await Promise.all([
|
||||
const [generals, rankData, cities, nations, diplomacy, generalTurns, nationTurns, logs] = await Promise.all([
|
||||
db.general.findMany({
|
||||
where: { id: { in: selector.generalIds } },
|
||||
orderBy: { id: 'asc' },
|
||||
}),
|
||||
db.rankData.findMany({
|
||||
where: { generalId: { in: selector.generalIds } },
|
||||
orderBy: [{ generalId: 'asc' }, { type: 'asc' }],
|
||||
}),
|
||||
db.city.findMany({
|
||||
where: { id: { in: selector.cityIds } },
|
||||
orderBy: { id: 'asc' },
|
||||
@@ -54,6 +58,7 @@ export const readCoreDatabaseSnapshot = async (
|
||||
return projectCoreDatabaseSnapshot({
|
||||
world,
|
||||
generals,
|
||||
rankData,
|
||||
cities,
|
||||
nations,
|
||||
diplomacy,
|
||||
|
||||
@@ -451,6 +451,10 @@ describe('auction integration flow', () => {
|
||||
const initialScore = await redis.zScore(keys.timerKey, String(auction.id));
|
||||
expect(Number(initialScore)).toBe(initialCloseAt.getTime());
|
||||
|
||||
await expect(hostClient.auction.bidBuyRice.mutate({ auctionId: auction.id, amount: 300 })).rejects.toThrow(
|
||||
'자신이 연 경매에 입찰할 수 없습니다.'
|
||||
);
|
||||
|
||||
const bidder1Client = createGameClient(gameUrl, gameServer.config.trpcPath, {
|
||||
value: bidder1.accessToken,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common';
|
||||
|
||||
import { compareTurnSnapshotDeltas } from '../src/turn-differential/compare.js';
|
||||
import { runCoreTurnCommandTrace, type TurnCommandFixtureRequest } from '../src/turn-differential/coreCommandTrace.js';
|
||||
@@ -72,6 +73,7 @@ interface FixturePatches {
|
||||
troops?: Array<Record<string, unknown>>;
|
||||
diplomacy?: Record<string, Record<string, unknown>>;
|
||||
randomFoundingCandidateCityIds?: number[];
|
||||
rankData?: Array<{ generalId: number; type: string; value: number }>;
|
||||
}
|
||||
|
||||
const buildRequest = (
|
||||
@@ -166,6 +168,7 @@ const buildRequest = (
|
||||
{ ...general(2, 2, 70, 12), ...fixturePatches.generals?.[2] },
|
||||
{ ...general(3, 1, 3, 1), ...fixturePatches.generals?.[3] },
|
||||
],
|
||||
...(fixturePatches.rankData ? { rankData: fixturePatches.rankData } : {}),
|
||||
...(fixturePatches.troops ? { troops: fixturePatches.troops } : {}),
|
||||
...(fixturePatches.randomFoundingCandidateCityIds
|
||||
? { randomFoundingCandidateCityIds: fixturePatches.randomFoundingCandidateCityIds }
|
||||
@@ -382,6 +385,67 @@ integration('general command success matrix', () => {
|
||||
);
|
||||
});
|
||||
|
||||
integration('명장일람 rank_data command parity', () => {
|
||||
it('화계 increments firenum from the same seeded value as legacy', async () => {
|
||||
const request = buildRequest(
|
||||
'che_화계',
|
||||
{ destCityID: 70 },
|
||||
{ intelligence: 100 },
|
||||
{
|
||||
generals: { 2: { intelligence: 10 } },
|
||||
rankData: [{ generalId: 1, type: 'firenum', value: 17 }],
|
||||
}
|
||||
);
|
||||
request.setup!.world!.hiddenSeed = 'general-injury-4';
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||
|
||||
expect(reference.after.rankData).toContainEqual(
|
||||
expect.objectContaining({ generalId: 1, type: 'firenum', value: 18 })
|
||||
);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
}, 120_000);
|
||||
|
||||
it('은퇴 resets every legacy RankColumn row exactly like legacy', async () => {
|
||||
const request = buildRequest(
|
||||
'che_은퇴',
|
||||
undefined,
|
||||
{ age: 65, lastTurn: { command: '은퇴', term: 1 } },
|
||||
{
|
||||
rankData: LEGACY_RANK_DATA_TYPES.map((type, index) => ({
|
||||
generalId: 1,
|
||||
type,
|
||||
value: index + 1,
|
||||
})),
|
||||
}
|
||||
);
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||
|
||||
expect(reference.after.rankData.filter((row) => row.generalId === 1)).toHaveLength(
|
||||
LEGACY_RANK_DATA_TYPES.length
|
||||
);
|
||||
expect(reference.after.rankData.filter((row) => row.generalId === 1).every((row) => row.value === 0)).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
type GeneralFailureCase = {
|
||||
action: string;
|
||||
args?: Record<string, unknown>;
|
||||
@@ -1109,6 +1173,194 @@ integration('general command missing-target fallback matrix', () => {
|
||||
);
|
||||
});
|
||||
|
||||
const resourceAmountCases: Array<{
|
||||
name: string;
|
||||
action: string;
|
||||
args: Record<string, unknown>;
|
||||
expectedAmount: number;
|
||||
}> = [
|
||||
{
|
||||
name: 'gift rounds a half unit up',
|
||||
action: 'che_증여',
|
||||
args: { isGold: true, amount: 150, destGeneralID: 3 },
|
||||
expectedAmount: 200,
|
||||
},
|
||||
{
|
||||
name: 'gift clamps below the minimum',
|
||||
action: 'che_증여',
|
||||
args: { isGold: true, amount: 1, destGeneralID: 3 },
|
||||
expectedAmount: 100,
|
||||
},
|
||||
{
|
||||
name: 'gift clamps above the maximum',
|
||||
action: 'che_증여',
|
||||
args: { isGold: true, amount: 10_050, destGeneralID: 3 },
|
||||
expectedAmount: 10_000,
|
||||
},
|
||||
{
|
||||
name: 'donation rounds a half unit up',
|
||||
action: 'che_헌납',
|
||||
args: { isGold: true, amount: 150 },
|
||||
expectedAmount: 200,
|
||||
},
|
||||
{
|
||||
name: 'donation clamps below the minimum',
|
||||
action: 'che_헌납',
|
||||
args: { isGold: true, amount: 1 },
|
||||
expectedAmount: 100,
|
||||
},
|
||||
{
|
||||
name: 'donation clamps above the maximum',
|
||||
action: 'che_헌납',
|
||||
args: { isGold: true, amount: 10_050 },
|
||||
expectedAmount: 10_000,
|
||||
},
|
||||
{
|
||||
name: 'trade rounds a half unit up',
|
||||
action: 'che_군량매매',
|
||||
args: { buyRice: true, amount: 150 },
|
||||
expectedAmount: 200,
|
||||
},
|
||||
{
|
||||
name: 'trade clamps below the minimum',
|
||||
action: 'che_군량매매',
|
||||
args: { buyRice: true, amount: 1 },
|
||||
expectedAmount: 100,
|
||||
},
|
||||
{
|
||||
name: 'trade clamps above the maximum',
|
||||
action: 'che_군량매매',
|
||||
args: { buyRice: true, amount: 10_050 },
|
||||
expectedAmount: 10_000,
|
||||
},
|
||||
];
|
||||
|
||||
integration('general command resource amount normalization matrix', () => {
|
||||
it.each(resourceAmountCases)(
|
||||
'$name matches legacy rounding and clamp semantics',
|
||||
async ({ action, args, expectedAmount }) => {
|
||||
const request = buildRequest(action, args);
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||
const referenceActor = reference.after.generals.find((entry) => entry.id === 1);
|
||||
const coreActor = core.after.generals.find((entry) => entry.id === 1);
|
||||
const referenceLastTurn = referenceActor?.lastTurn as { arg?: Record<string, unknown> } | null | undefined;
|
||||
const coreLastTurn = coreActor?.lastTurn as { arg?: Record<string, unknown> } | null | undefined;
|
||||
|
||||
expect(reference.execution.outcome).toMatchObject({ completed: true });
|
||||
expect(core.execution.outcome).toMatchObject({
|
||||
requestedAction: action,
|
||||
actionKey: action,
|
||||
usedFallback: false,
|
||||
});
|
||||
expect(referenceLastTurn?.arg).toMatchObject({ amount: expectedAmount });
|
||||
expect(coreLastTurn?.arg).toMatchObject({ amount: expectedAmount });
|
||||
expect(core.rng).toEqual(reference.rng);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
},
|
||||
120_000
|
||||
);
|
||||
});
|
||||
|
||||
integration('general command donation resource boundaries', () => {
|
||||
it('donates the available resource when the normalized request exceeds the current amount', async () => {
|
||||
const request = buildRequest('che_헌납', { isGold: true, amount: 10_000 }, { gold: 5_000 });
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||
|
||||
expect(reference.execution.outcome).toMatchObject({ completed: true });
|
||||
expect(core.execution.outcome).toMatchObject({
|
||||
requestedAction: 'che_헌납',
|
||||
actionKey: 'che_헌납',
|
||||
usedFallback: false,
|
||||
});
|
||||
expect(core.rng).toEqual(reference.rng);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
}, 120_000);
|
||||
|
||||
it('falls back when current rice is below the legacy minimum even for a small request', async () => {
|
||||
const request = buildRequest('che_헌납', { isGold: false, amount: 100 }, { rice: 499 });
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||
|
||||
expect(reference.execution.outcome).toMatchObject({ completed: false });
|
||||
expect(core.execution.outcome).toMatchObject({
|
||||
requestedAction: 'che_헌납',
|
||||
actionKey: '휴식',
|
||||
usedFallback: true,
|
||||
});
|
||||
expect(core.rng).toEqual(reference.rng);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
integration('general command gift resource and target boundaries', () => {
|
||||
it('keeps the legacy minimum rice reserve while gifting the available amount', async () => {
|
||||
const request = buildRequest('che_증여', { isGold: false, amount: 10_000, destGeneralID: 3 }, { rice: 600 });
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||
|
||||
expect(reference.execution.outcome).toMatchObject({ completed: true });
|
||||
expect(core.execution.outcome).toMatchObject({
|
||||
requestedAction: 'che_증여',
|
||||
actionKey: 'che_증여',
|
||||
usedFallback: false,
|
||||
});
|
||||
expect(core.rng).toEqual(reference.rng);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
}, 120_000);
|
||||
|
||||
it('rejects gifting to the actor and falls back without command RNG', async () => {
|
||||
const request = buildRequest('che_증여', { isGold: true, amount: 100, destGeneralID: 1 });
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||
|
||||
expect(reference.execution.outcome).toMatchObject({ completed: false });
|
||||
expect(core.execution.outcome).toMatchObject({
|
||||
requestedAction: 'che_증여',
|
||||
actionKey: '휴식',
|
||||
usedFallback: true,
|
||||
});
|
||||
expect(core.rng).toEqual(reference.rng);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
type GeneralConstraintCase = {
|
||||
name: string;
|
||||
action: string;
|
||||
|
||||
@@ -11,6 +11,9 @@ const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT;
|
||||
const workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRoot(process.cwd());
|
||||
const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1');
|
||||
|
||||
const readGold = (row: { gold?: unknown } | undefined): number => (typeof row?.gold === 'number' ? row.gold : 0);
|
||||
const NPC_SEIZURE_MESSAGE_TEXT = '몰수를 하다니... 이것이 윗사람이 할 짓이란 말입니까...';
|
||||
|
||||
const ignoredLifecyclePaths = [
|
||||
/^generalTurns/,
|
||||
/^nationTurns/,
|
||||
@@ -469,3 +472,338 @@ integration('nation command success matrix', () => {
|
||||
120_000
|
||||
);
|
||||
});
|
||||
|
||||
const nationResourceAmountCases: Array<{
|
||||
name: string;
|
||||
action: 'che_포상' | 'che_몰수';
|
||||
args: Record<string, unknown>;
|
||||
expectedAmount: number;
|
||||
}> = [
|
||||
{
|
||||
name: 'award rounds a half unit up',
|
||||
action: 'che_포상',
|
||||
args: { isGold: true, amount: 150, destGeneralID: 3 },
|
||||
expectedAmount: 200,
|
||||
},
|
||||
{
|
||||
name: 'award clamps below the minimum',
|
||||
action: 'che_포상',
|
||||
args: { isGold: true, amount: 1, destGeneralID: 3 },
|
||||
expectedAmount: 100,
|
||||
},
|
||||
{
|
||||
name: 'award clamps above the maximum',
|
||||
action: 'che_포상',
|
||||
args: { isGold: true, amount: 10_050, destGeneralID: 3 },
|
||||
expectedAmount: 10_000,
|
||||
},
|
||||
{
|
||||
name: 'seizure rounds a half unit up',
|
||||
action: 'che_몰수',
|
||||
args: { isGold: true, amount: 150, destGeneralID: 3 },
|
||||
expectedAmount: 200,
|
||||
},
|
||||
{
|
||||
name: 'seizure clamps below the minimum',
|
||||
action: 'che_몰수',
|
||||
args: { isGold: true, amount: 1, destGeneralID: 3 },
|
||||
expectedAmount: 100,
|
||||
},
|
||||
{
|
||||
name: 'seizure clamps above the maximum',
|
||||
action: 'che_몰수',
|
||||
args: { isGold: true, amount: 10_050, destGeneralID: 3 },
|
||||
expectedAmount: 10_000,
|
||||
},
|
||||
];
|
||||
|
||||
integration('nation command resource amount normalization matrix', () => {
|
||||
it.each(nationResourceAmountCases)(
|
||||
'$name matches legacy rounding and clamp semantics',
|
||||
async ({ action, args, expectedAmount }) => {
|
||||
const request = buildRequest(action, args);
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||
const referenceTargetBefore = reference.before.generals.find((entry) => entry.id === 3);
|
||||
const referenceTargetAfter = reference.after.generals.find((entry) => entry.id === 3);
|
||||
const coreTargetBefore = core.before.generals.find((entry) => entry.id === 3);
|
||||
const coreTargetAfter = core.after.generals.find((entry) => entry.id === 3);
|
||||
const referenceAmount =
|
||||
action === 'che_포상'
|
||||
? readGold(referenceTargetAfter) - readGold(referenceTargetBefore)
|
||||
: readGold(referenceTargetBefore) - readGold(referenceTargetAfter);
|
||||
const coreAmount =
|
||||
action === 'che_포상'
|
||||
? readGold(coreTargetAfter) - readGold(coreTargetBefore)
|
||||
: readGold(coreTargetBefore) - readGold(coreTargetAfter);
|
||||
|
||||
expect(reference.execution.outcome).toMatchObject({ completed: true });
|
||||
expect(core.execution.outcome).toMatchObject({
|
||||
requestedAction: action,
|
||||
actionKey: action,
|
||||
usedFallback: false,
|
||||
});
|
||||
expect(referenceAmount).toBe(expectedAmount);
|
||||
expect(coreAmount).toBe(expectedAmount);
|
||||
expect(core.rng).toEqual(reference.rng);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
},
|
||||
120_000
|
||||
);
|
||||
});
|
||||
|
||||
const nationResourceBoundaryCases: Array<{
|
||||
name: string;
|
||||
action: 'che_포상' | 'che_몰수';
|
||||
args: Record<string, unknown>;
|
||||
fixturePatches?: FixturePatches;
|
||||
completed: boolean;
|
||||
}> = [
|
||||
{
|
||||
name: 'award is limited to the available nation gold',
|
||||
action: 'che_포상',
|
||||
args: { isGold: true, amount: 10_000, destGeneralID: 3 },
|
||||
fixturePatches: { nations: { 1: { gold: 5_000 } } },
|
||||
completed: true,
|
||||
},
|
||||
{
|
||||
name: 'award keeps the legacy base rice reserve',
|
||||
action: 'che_포상',
|
||||
args: { isGold: false, amount: 10_000, destGeneralID: 3 },
|
||||
fixturePatches: { nations: { 1: { rice: 2_100 } } },
|
||||
completed: true,
|
||||
},
|
||||
{
|
||||
name: 'award rejects the actor as its target',
|
||||
action: 'che_포상',
|
||||
args: { isGold: true, amount: 100, destGeneralID: 1 },
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
name: 'seizure is limited to the target general gold',
|
||||
action: 'che_몰수',
|
||||
args: { isGold: true, amount: 1_000, destGeneralID: 3 },
|
||||
fixturePatches: { generals: { 3: { gold: 50 } } },
|
||||
completed: true,
|
||||
},
|
||||
{
|
||||
name: 'seizure rejects the actor as its target',
|
||||
action: 'che_몰수',
|
||||
args: { isGold: true, amount: 100, destGeneralID: 1 },
|
||||
completed: false,
|
||||
},
|
||||
];
|
||||
|
||||
integration('nation command resource balance and target boundaries', () => {
|
||||
it.each(nationResourceBoundaryCases)(
|
||||
'$name matches legacy completion, RNG, and state delta',
|
||||
async ({ action, args, fixturePatches, completed }) => {
|
||||
const request = buildRequest(action, args, fixturePatches);
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||
|
||||
expect(reference.execution.outcome).toMatchObject({ completed });
|
||||
expect(core.execution.outcome).toMatchObject({
|
||||
requestedAction: action,
|
||||
actionKey: completed ? action : '휴식',
|
||||
usedFallback: !completed,
|
||||
});
|
||||
expect(core.rng).toEqual(reference.rng);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
},
|
||||
120_000
|
||||
);
|
||||
});
|
||||
|
||||
integration('nation seizure NPC public message parity', () => {
|
||||
it('matches the legacy fixed-seed RNG and public message side effect', async () => {
|
||||
const request = buildRequest(
|
||||
'che_몰수',
|
||||
{ isGold: true, amount: 100, destGeneralID: 3 },
|
||||
{
|
||||
world: { hiddenSeed: 'seizure-message-37' },
|
||||
generals: { 3: { name: '몰수NPC', npcState: 2 } },
|
||||
}
|
||||
);
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||
|
||||
expect(reference.execution.outcome).toMatchObject({ completed: true });
|
||||
expect(reference.rng).toHaveLength(2);
|
||||
expect(reference.rng.map((call) => call.operation)).toEqual(['nextFloat1', 'nextInt']);
|
||||
expect(core.rng).toEqual(reference.rng);
|
||||
const referenceMessages = reference.after.messages.slice(reference.before.messages.length);
|
||||
expect(referenceMessages).toHaveLength(1);
|
||||
expect(core.after.messages).toHaveLength(1);
|
||||
expect(referenceMessages[0]).toMatchObject({
|
||||
mailbox: 9999,
|
||||
type: 'public',
|
||||
sourceId: 3,
|
||||
destinationId: 9999,
|
||||
payload: {
|
||||
src: { id: 3, name: '몰수NPC', nation_id: 1, nation: '아국' },
|
||||
dest: { id: 3, name: '몰수NPC', nation_id: 1, nation: '아국' },
|
||||
text: NPC_SEIZURE_MESSAGE_TEXT,
|
||||
},
|
||||
});
|
||||
expect(core.after.messages[0]).toMatchObject({
|
||||
payload: {
|
||||
msgType: 'public',
|
||||
src: { generalId: 3, generalName: '몰수NPC', nationId: 1, nationName: '아국' },
|
||||
dest: { generalId: 3, generalName: '몰수NPC', nationId: 1, nationName: '아국' },
|
||||
text: NPC_SEIZURE_MESSAGE_TEXT,
|
||||
},
|
||||
});
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
integration('nation seizure zero target balance parity', () => {
|
||||
it('matches the legacy zero-amount logs for a user target', async () => {
|
||||
const request = buildRequest(
|
||||
'che_몰수',
|
||||
{ isGold: true, amount: 100, destGeneralID: 3 },
|
||||
{ generals: { 3: { name: '무자원장수', gold: 0, npcState: 0 } } }
|
||||
);
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||
const referenceLogs = reference.after.logs.slice(reference.before.logs.length);
|
||||
|
||||
expect(reference.execution.outcome).toMatchObject({ completed: true });
|
||||
expect(core.execution.outcome).toMatchObject({
|
||||
requestedAction: 'che_몰수',
|
||||
actionKey: 'che_몰수',
|
||||
usedFallback: false,
|
||||
});
|
||||
expect(reference.rng).toEqual([]);
|
||||
expect(core.rng).toEqual(reference.rng);
|
||||
expect(referenceLogs.map((entry) => entry.text)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('금 0를 몰수 당했습니다.'),
|
||||
expect.stringContaining('금 <C>0</>를 몰수했습니다.'),
|
||||
])
|
||||
);
|
||||
expect(core.after.logs.map((entry) => entry.text)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('금 0를 몰수 당했습니다.'),
|
||||
expect.stringContaining('금 <C>0</>를 몰수했습니다.'),
|
||||
])
|
||||
);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
}, 120_000);
|
||||
|
||||
it('preserves NPC message RNG and side effects before applying a zero delta', async () => {
|
||||
const request = buildRequest(
|
||||
'che_몰수',
|
||||
{ isGold: true, amount: 100, destGeneralID: 3 },
|
||||
{
|
||||
world: { hiddenSeed: 'seizure-message-37' },
|
||||
generals: { 3: { name: '무자원NPC', gold: 0, npcState: 2 } },
|
||||
}
|
||||
);
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||
const referenceMessages = reference.after.messages.slice(reference.before.messages.length);
|
||||
|
||||
expect(reference.execution.outcome).toMatchObject({ completed: true });
|
||||
expect(reference.rng.map((call) => call.operation)).toEqual(['nextFloat1', 'nextInt']);
|
||||
expect(core.rng).toEqual(reference.rng);
|
||||
expect(referenceMessages).toHaveLength(1);
|
||||
expect(core.after.messages).toHaveLength(1);
|
||||
expect(referenceMessages[0]).toMatchObject({
|
||||
type: 'public',
|
||||
sourceId: 3,
|
||||
payload: { text: NPC_SEIZURE_MESSAGE_TEXT },
|
||||
});
|
||||
expect(core.after.messages[0]).toMatchObject({
|
||||
payload: { msgType: 'public', text: NPC_SEIZURE_MESSAGE_TEXT },
|
||||
});
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
const nationPersonnelTargetCases: Array<{
|
||||
name: string;
|
||||
action: 'che_포상' | 'che_몰수';
|
||||
destGeneralID: number;
|
||||
}> = [
|
||||
{
|
||||
name: 'award rejects a missing target general',
|
||||
action: 'che_포상',
|
||||
destGeneralID: 9999,
|
||||
},
|
||||
{
|
||||
name: 'award rejects a foreign target general',
|
||||
action: 'che_포상',
|
||||
destGeneralID: 2,
|
||||
},
|
||||
{
|
||||
name: 'seizure rejects a missing target general',
|
||||
action: 'che_몰수',
|
||||
destGeneralID: 9999,
|
||||
},
|
||||
{
|
||||
name: 'seizure rejects a foreign target general',
|
||||
action: 'che_몰수',
|
||||
destGeneralID: 2,
|
||||
},
|
||||
];
|
||||
|
||||
integration('nation award and seizure target constraints', () => {
|
||||
it.each(nationPersonnelTargetCases)(
|
||||
'$name matches legacy fallback, RNG, and semantic delta',
|
||||
async ({ action, destGeneralID }) => {
|
||||
const request = buildRequest(action, { isGold: true, amount: 100, destGeneralID });
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||
|
||||
expect(reference.execution.outcome).toMatchObject({ completed: false });
|
||||
expect(core.execution.outcome).toMatchObject({
|
||||
requestedAction: action,
|
||||
actionKey: '휴식',
|
||||
usedFallback: true,
|
||||
});
|
||||
expect(reference.rng).toEqual([]);
|
||||
expect(core.rng).toEqual(reference.rng);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
},
|
||||
120_000
|
||||
);
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ const snapshot = (
|
||||
engine,
|
||||
world: { year: 183, month: 1, tickMinutes: 10, turnTime: '0183-01-01T00:00:00.000Z', isUnited: 0 },
|
||||
generals: [{ id: 1, gold: 1000, rice: 1000, crew: 1000, nationId: 1, cityId: 1 }],
|
||||
rankData: [],
|
||||
cities: [{ id: 1, nationId: 1, agriculture: 1000, defence: 500 }],
|
||||
nations: [{ id: 1, gold: 0, rice: 0 }],
|
||||
diplomacy: [],
|
||||
@@ -44,6 +45,23 @@ describe('turn snapshot differential comparator', () => {
|
||||
expect(compareTurnSnapshots(reference, core)).toEqual([]);
|
||||
});
|
||||
|
||||
it('compares rank rows by general and type instead of array position', () => {
|
||||
const reference = snapshot('ref', {
|
||||
rankData: [
|
||||
{ generalId: 2, nationId: 1, type: 'firenum', value: 3 },
|
||||
{ generalId: 1, nationId: 1, type: 'warnum', value: 5 },
|
||||
],
|
||||
});
|
||||
const core = snapshot('core2026', {
|
||||
rankData: [
|
||||
{ generalId: 1, nationId: 1, type: 'warnum', value: 5 },
|
||||
{ generalId: 2, nationId: 1, type: 'firenum', value: 3 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(compareTurnSnapshots(reference, core)).toEqual([]);
|
||||
});
|
||||
|
||||
it('normalizes legacy ID argument spelling at the trace boundary', () => {
|
||||
expect(
|
||||
canonicalizeTurnCommandArgs({
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const scenarioRoot = path.join(repositoryRoot, 'resources', 'scenario');
|
||||
const catalogPath = path.join(repositoryRoot, 'resources', 'general-icons.json');
|
||||
const canonicalDirectory = '장수';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const write = args.includes('--write');
|
||||
const imageRootIndex = args.indexOf('--image-root');
|
||||
if (imageRootIndex < 0 || !args[imageRootIndex + 1]) {
|
||||
throw new Error('Usage: node tools/manage-general-icons.mjs --image-root <image-repository> [--write]');
|
||||
}
|
||||
const imageRoot = path.resolve(args[imageRootIndex + 1]);
|
||||
const iconsRoot = path.join(imageRoot, 'icons');
|
||||
|
||||
const compareText = (left, right) => left.localeCompare(right, 'ko');
|
||||
const toPosix = (value) => value.split(path.sep).join('/');
|
||||
|
||||
const readJson = async (filePath) => JSON.parse(await fs.readFile(filePath, 'utf8'));
|
||||
|
||||
const exists = async (filePath) => {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const listFiles = async (root) => {
|
||||
const result = [];
|
||||
const visit = async (directory) => {
|
||||
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
|
||||
const entryPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await visit(entryPath);
|
||||
} else if (entry.isFile()) {
|
||||
result.push(toPosix(path.relative(iconsRoot, entryPath)));
|
||||
}
|
||||
}
|
||||
};
|
||||
await visit(root);
|
||||
return result.sort(compareText);
|
||||
};
|
||||
|
||||
const scenarioFiles = (await fs.readdir(scenarioRoot))
|
||||
.filter((fileName) => /^scenario_\d+\.json$/.test(fileName))
|
||||
.sort(compareText);
|
||||
const scenarios = await Promise.all(
|
||||
scenarioFiles.map(async (fileName) => {
|
||||
const filePath = path.join(scenarioRoot, fileName);
|
||||
const source = await fs.readFile(filePath, 'utf8');
|
||||
return {
|
||||
fileName,
|
||||
filePath,
|
||||
source,
|
||||
data: JSON.parse(source),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const inventory = await listFiles(iconsRoot);
|
||||
const inventorySet = new Set(inventory);
|
||||
const filesByDirectoryAndStem = new Map();
|
||||
const filesByStem = new Map();
|
||||
for (const relativePath of inventory) {
|
||||
const parsed = path.posix.parse(relativePath);
|
||||
const directory = parsed.dir || '.';
|
||||
const directoryKey = `${directory}\0${parsed.name.normalize('NFC')}`;
|
||||
filesByDirectoryAndStem.set(directoryKey, relativePath);
|
||||
const stem = parsed.name.normalize('NFC');
|
||||
const values = filesByStem.get(stem) ?? [];
|
||||
values.push(relativePath);
|
||||
filesByStem.set(stem, values);
|
||||
}
|
||||
|
||||
const existingCatalog = (await exists(catalogPath))
|
||||
? await readJson(catalogPath)
|
||||
: { schemaVersion: 1, canonicalDirectory, icons: [], unresolved: [] };
|
||||
const entriesByName = new Map(existingCatalog.icons.map((entry) => [entry.name, entry]));
|
||||
|
||||
const numericSourcesByName = new Map();
|
||||
const namesByNumericId = new Map();
|
||||
for (const scenario of scenarios) {
|
||||
for (const collectionName of ['general', 'general_ex', 'general_neutral']) {
|
||||
for (const row of scenario.data[collectionName] ?? []) {
|
||||
const [name, picture] = [row[1], row[2]];
|
||||
if (typeof name !== 'string' || typeof picture !== 'number' || picture < 0) {
|
||||
continue;
|
||||
}
|
||||
const normalizedName = name.normalize('NFC');
|
||||
const ids = numericSourcesByName.get(normalizedName) ?? new Set();
|
||||
ids.add(picture);
|
||||
numericSourcesByName.set(normalizedName, ids);
|
||||
const names = namesByNumericId.get(picture) ?? new Set();
|
||||
names.add(normalizedName);
|
||||
namesByNumericId.set(picture, names);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ambiguousNames = [...numericSourcesByName]
|
||||
.filter(([, ids]) => ids.size > 1)
|
||||
.map(([name, ids]) => ({ name, legacyPictureIds: [...ids].sort((left, right) => left - right) }));
|
||||
if (ambiguousNames.length > 0) {
|
||||
throw new Error(`One general name maps to multiple numeric icons: ${JSON.stringify(ambiguousNames)}`);
|
||||
}
|
||||
|
||||
const unresolvedByName = new Map(
|
||||
(existingCatalog.unresolved ?? []).map((entry) => [entry.name, entry])
|
||||
);
|
||||
for (const [name, ids] of [...numericSourcesByName].sort(([left], [right]) => compareText(left, right))) {
|
||||
if (entriesByName.has(name)) {
|
||||
continue;
|
||||
}
|
||||
const legacyPictureIds = [...ids].sort((left, right) => left - right);
|
||||
const numericSource = `icons/${legacyPictureIds[0]}.jpg`;
|
||||
let source = numericSource;
|
||||
if (!inventorySet.has(numericSource.slice('icons/'.length))) {
|
||||
const namedCandidates = (filesByStem.get(name) ?? []).filter(
|
||||
(candidate) => !candidate.startsWith(`${canonicalDirectory}/`)
|
||||
);
|
||||
if (namedCandidates.length !== 1) {
|
||||
unresolvedByName.set(name, {
|
||||
name,
|
||||
legacyPictureIds,
|
||||
reason:
|
||||
namedCandidates.length === 0
|
||||
? 'missing numeric and unique named source'
|
||||
: 'multiple named source candidates',
|
||||
candidates: namedCandidates,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
source = `icons/${namedCandidates[0]}`;
|
||||
}
|
||||
const extension = path.posix.extname(source).toLowerCase();
|
||||
const fileName = `${name}${extension}`;
|
||||
entriesByName.set(name, {
|
||||
name,
|
||||
path: `${canonicalDirectory}/${fileName}`,
|
||||
legacyPictureIds,
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
const catalog = {
|
||||
schemaVersion: 1,
|
||||
canonicalDirectory,
|
||||
icons: [...entriesByName.values()].sort((left, right) => compareText(left.name, right.name)),
|
||||
unresolved: [...unresolvedByName.values()].sort((left, right) => compareText(left.name, right.name)),
|
||||
};
|
||||
const catalogByName = new Map(catalog.icons.map((entry) => [entry.name, entry]));
|
||||
const catalogByNumericId = new Map();
|
||||
for (const entry of catalog.icons) {
|
||||
for (const numericId of entry.legacyPictureIds) {
|
||||
const values = catalogByNumericId.get(numericId) ?? [];
|
||||
values.push(entry);
|
||||
catalogByNumericId.set(numericId, values);
|
||||
}
|
||||
}
|
||||
|
||||
const stats = {
|
||||
scenarios: scenarios.length,
|
||||
rows: 0,
|
||||
changedRows: 0,
|
||||
themedMatches: 0,
|
||||
canonicalMatches: 0,
|
||||
unresolvedRows: 0,
|
||||
};
|
||||
|
||||
const resolvePicture = (name, picture, iconDirectory) => {
|
||||
const normalizedName = name.normalize('NFC');
|
||||
if (typeof picture === 'number') {
|
||||
if (picture < 0) {
|
||||
return 'default.jpg';
|
||||
}
|
||||
const candidates = catalogByNumericId.get(picture) ?? [];
|
||||
const exact = candidates.find((entry) => entry.name === normalizedName);
|
||||
const selected = exact ?? (candidates.length === 1 ? candidates[0] : undefined);
|
||||
if (selected) {
|
||||
stats.canonicalMatches += 1;
|
||||
return selected.path;
|
||||
}
|
||||
stats.unresolvedRows += 1;
|
||||
return 'default.jpg';
|
||||
}
|
||||
if (typeof picture === 'string' && picture.length > 0) {
|
||||
if (picture.includes('/')) {
|
||||
return picture;
|
||||
}
|
||||
if (iconDirectory !== '.' && inventorySet.has(`${iconDirectory}/${picture}`)) {
|
||||
return `${iconDirectory}/${picture}`;
|
||||
}
|
||||
return picture;
|
||||
}
|
||||
|
||||
if (iconDirectory !== '.') {
|
||||
const themed = filesByDirectoryAndStem.get(`${iconDirectory}\0${normalizedName}`);
|
||||
if (themed) {
|
||||
stats.themedMatches += 1;
|
||||
return themed;
|
||||
}
|
||||
}
|
||||
const canonical = catalogByName.get(normalizedName);
|
||||
if (canonical) {
|
||||
stats.canonicalMatches += 1;
|
||||
return canonical.path;
|
||||
}
|
||||
const rootNamed = filesByDirectoryAndStem.get(`.\0${normalizedName}`);
|
||||
if (rootNamed) {
|
||||
stats.themedMatches += 1;
|
||||
return rootNamed;
|
||||
}
|
||||
stats.unresolvedRows += 1;
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderRow = (row) => `[${row.map((value) => JSON.stringify(value)).join(', ')}]`;
|
||||
|
||||
const rewriteScenario = (scenario) => {
|
||||
const iconDirectory = typeof scenario.data.iconPath === 'string' ? scenario.data.iconPath : '.';
|
||||
const expectedCounts = new Map(
|
||||
['general', 'general_ex', 'general_neutral'].map((collectionName) => [
|
||||
collectionName,
|
||||
(scenario.data[collectionName] ?? []).length,
|
||||
])
|
||||
);
|
||||
const visitedCounts = new Map([...expectedCounts.keys()].map((collectionName) => [collectionName, 0]));
|
||||
let activeCollection = null;
|
||||
const output = scenario.source.split('\n').map((line) => {
|
||||
const collectionMatch = line.match(/^ {4}"(general|general_ex|general_neutral)": \[$/);
|
||||
if (collectionMatch) {
|
||||
activeCollection = collectionMatch[1];
|
||||
return line;
|
||||
}
|
||||
if (activeCollection === null) {
|
||||
return line;
|
||||
}
|
||||
if (/^ {4}\](?:,)?$/.test(line)) {
|
||||
activeCollection = null;
|
||||
return line;
|
||||
}
|
||||
if (line.trim() === '') {
|
||||
return line;
|
||||
}
|
||||
const rowMatch = line.match(/^( {8})(\[.*\])(,?)$/);
|
||||
if (!rowMatch) {
|
||||
throw new Error(`${scenario.fileName}: unsupported multiline ${activeCollection} row: ${line}`);
|
||||
}
|
||||
const row = JSON.parse(rowMatch[2]);
|
||||
if (!Array.isArray(row) || typeof row[1] !== 'string') {
|
||||
throw new Error(`${scenario.fileName}: invalid ${activeCollection} row: ${line}`);
|
||||
}
|
||||
stats.rows += 1;
|
||||
visitedCounts.set(activeCollection, (visitedCounts.get(activeCollection) ?? 0) + 1);
|
||||
const resolved = resolvePicture(row[1], row[2], iconDirectory);
|
||||
if (row[2] !== resolved) {
|
||||
row[2] = resolved;
|
||||
stats.changedRows += 1;
|
||||
return `${rowMatch[1]}${renderRow(row)}${rowMatch[3]}`;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
for (const [collectionName, expected] of expectedCounts) {
|
||||
const visited = visitedCounts.get(collectionName);
|
||||
if (visited !== expected) {
|
||||
throw new Error(
|
||||
`${scenario.fileName}: visited ${String(visited)} ${collectionName} rows, expected ${String(expected)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return output.join('\n');
|
||||
};
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
scenario.output = rewriteScenario(scenario);
|
||||
}
|
||||
|
||||
const catalogText = `${JSON.stringify(catalog, null, 4)}\n`;
|
||||
let drift = !(await exists(catalogPath)) || (await fs.readFile(catalogPath, 'utf8')) !== catalogText;
|
||||
for (const scenario of scenarios) {
|
||||
if (scenario.source !== scenario.output) {
|
||||
drift = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of catalog.icons) {
|
||||
const sourcePath = path.join(imageRoot, entry.source);
|
||||
const destinationPath = path.join(iconsRoot, entry.path);
|
||||
if (!(await exists(sourcePath))) {
|
||||
throw new Error(`Catalog source does not exist: ${entry.source}`);
|
||||
}
|
||||
if (!(await exists(destinationPath))) {
|
||||
drift = true;
|
||||
if (write) {
|
||||
await fs.mkdir(path.dirname(destinationPath), { recursive: true });
|
||||
await fs.copyFile(sourcePath, destinationPath);
|
||||
}
|
||||
} else {
|
||||
const [sourceBytes, destinationBytes] = await Promise.all([
|
||||
fs.readFile(sourcePath),
|
||||
fs.readFile(destinationPath),
|
||||
]);
|
||||
if (!sourceBytes.equals(destinationBytes)) {
|
||||
throw new Error(`Canonical icon differs from source: icons/${entry.path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (write) {
|
||||
await fs.writeFile(catalogPath, catalogText);
|
||||
await Promise.all(scenarios.map((scenario) => fs.writeFile(scenario.filePath, scenario.output)));
|
||||
} else if (drift) {
|
||||
throw new Error('General icon catalog, aliases, or scenario paths are out of date. Re-run with --write.');
|
||||
}
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
mode: write ? 'write' : 'check',
|
||||
canonicalIcons: catalog.icons.length,
|
||||
unresolvedCatalogEntries: catalog.unresolved.length,
|
||||
sharedNumericIds: [...catalogByNumericId.values()].filter((entries) => entries.length > 1).length,
|
||||
...stats,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user