merge: complete ranking menu parity

This commit is contained in:
2026-07-26 05:27:24 +00:00
8 changed files with 1096 additions and 124 deletions
@@ -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,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();
}
@@ -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 }) => {