feat(game-ui): redesign settings and tournament layouts

This commit is contained in:
2026-08-13 15:58:37 +00:00
parent 0e0c82afd6
commit ab2360d4d9
12 changed files with 823 additions and 160 deletions
+17 -7
View File
@@ -646,7 +646,7 @@ test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ p
expect(mobileWidth).toBe(1016);
});
test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in place', async ({ page }) => {
test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-identity layout', async ({ page }) => {
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
await install(page, state);
await page.setViewportSize({ width: 1000, height: 900 });
@@ -696,7 +696,7 @@ test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in plac
};
});
expect(desktop.width).toBe(1000);
expect(desktop.minWidth).toBe('500px');
expect(desktop.minWidth).toBe('0px');
expect(desktop.fontSize).toBe('14px');
expect(desktop.columns.split(' ')).toHaveLength(2);
expect(desktop.titleHeight).toBeCloseTo(54, 0);
@@ -766,26 +766,36 @@ test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in plac
expect(state.settingMutations.at(-1)).not.toHaveProperty('generalId');
}
await page.setViewportSize({ width: 500, height: 900 });
await page.setViewportSize({ width: 390, height: 900 });
await page.reload();
const mobile = await page.locator('#container').evaluate((element) => {
const rect = element.getBoundingClientRect();
const settings = element.querySelector<HTMLElement>('.settings-column')!.getBoundingClientRect();
const icon = element.querySelector<HTMLElement>('.portrait-image')!.getBoundingClientRect();
const name = element.querySelector<HTMLElement>('.portrait-cell strong')!.getBoundingClientRect();
return {
width: rect.width,
scrollWidth: document.documentElement.scrollWidth,
columns: getComputedStyle(element.querySelector('.top-grid')!).gridTemplateColumns,
settingsOffset: settings.x - rect.x,
settingsWidth: settings.width,
identity: {
iconRight: icon.right,
nameLeft: name.left,
iconCenterY: icon.y + icon.height / 2,
nameCenterY: name.y + name.height / 2,
},
};
});
expect(mobile).toMatchObject({
width: 500,
scrollWidth: 500,
columns: '500px',
width: 390,
scrollWidth: 390,
columns: '390px',
settingsOffset: 0,
settingsWidth: 500,
settingsWidth: 390,
});
expect(mobile.identity.nameLeft).toBeGreaterThanOrEqual(mobile.identity.iconRight);
expect(Math.abs(mobile.identity.iconCenterY - mobile.identity.nameCenterY)).toBeLessThan(1);
await persistParityArtifact(page, 'core-my-page-mobile', mobile);
});
+130 -6
View File
@@ -1,15 +1,22 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import { readFile } from 'node:fs/promises';
import { mkdir, readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const responsiveArtifactDir = process.env.TOURNAMENT_RESPONSIVE_ARTIFACT_DIR;
const imageRoots = [
...(process.env.FRONTEND_PARITY_IMAGE_ROOT ? [resolve(process.env.FRONTEND_PARITY_IMAGE_ROOT, 'game')] : []),
resolve(repositoryRoot, '../image/game'),
resolve(repositoryRoot, '../../image/game'),
];
const iconRoots = [
...(process.env.FRONTEND_PARITY_IMAGE_ROOT ? [resolve(process.env.FRONTEND_PARITY_IMAGE_ROOT, 'icons')] : []),
resolve(repositoryRoot, '../image/icons'),
resolve(repositoryRoot, '../../image/icons'),
resolve(repositoryRoot, '../../sam_rebuild/image/icons'),
];
const names = [
'관우',
'장료',
@@ -35,6 +42,8 @@ const participants = names.map((name, index) => ({
strength: 80,
intel: 80,
level: 10,
picture: 'default.jpg',
imageServer: 0,
groupId: 10 + (index % 8),
groupNo: Math.floor(index / 8),
win: 3 - (index % 2),
@@ -88,6 +97,26 @@ const readReferenceImage = async (filename: string): Promise<Buffer> => {
throw new Error(`Reference image not found: ${filename}`);
};
const readReferenceIcon = async (filename: string): Promise<Buffer> => {
for (const iconRoot of iconRoots) {
try {
return await readFile(resolve(iconRoot, filename));
} catch {
// Worktrees can be nested at different depths.
}
}
throw new Error(`Reference icon not found: ${filename}`);
};
const persistScreenshot = async (page: Page, name: string, fallbackPath: string) => {
if (!responsiveArtifactDir) {
await page.screenshot({ path: fallbackPath, fullPage: true });
return;
}
await mkdir(responsiveArtifactDir, { recursive: true });
await page.screenshot({ path: resolve(responsiveArtifactDir, `${name}.webp`), fullPage: true });
};
const installFixture = async (page: Page) => {
await page.addInitScript((profile) => {
window.localStorage.setItem('sammo-game-token', 'ga_tournament_bracket_playwright');
@@ -98,6 +127,9 @@ const installFixture = async (page: Page) => {
await route.fulfill({ status: 200, contentType: 'image/jpeg', body: await readReferenceImage(filename) });
});
}
await page.route('**/icons/default.jpg', async (route) => {
await route.fulfill({ status: 200, contentType: 'image/jpeg', body: await readReferenceIcon('default.jpg') });
});
await page.route(gameTrpcRoute, async (route) => {
const results = operationNames(route).map((operation) => {
if (operation === 'auth.status') return response({ ok: true });
@@ -125,12 +157,43 @@ const installFixture = async (page: Page) => {
}
if (operation === 'tournament.getBettingSummary') {
return response({
totals: Object.fromEntries(participants.map((participant, index) => [participant.id, 100 + index * 10])),
totals: Object.fromEntries(
participants.map((participant, index) => [participant.id, 100 + index * 10])
),
myTotals: {},
totalAmount: 2800,
myAmount: 0,
});
}
if (operation === 'tournament.getRankings') {
return response(
[
['tt', '전 력 전', '종합'],
['tl', '통 솔 전', '통솔'],
['ts', '일 기 토', '무력'],
['ti', '설 전', '지력'],
].map(([prefix, title, statLabel]) => ({
prefix,
title,
statLabel,
entries: participants.slice(0, 6).map((participant, index) => ({
rank: index + 1,
generalId: participant.id,
name: participant.name,
picture: participant.picture,
imageServer: participant.imageServer,
npcState: 0,
stat: 240 - index,
games: 10,
win: 7,
draw: 1,
lose: 2,
score: 22 - index,
prizes: 3,
})),
}))
);
}
return response(null);
});
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
@@ -162,16 +225,20 @@ test('desktop bracket connects every real general slot to the next round', async
connectorCenter: firstConnector.x + firstConnector.width / 2,
championCenter: champion.x + champion.width / 2,
finalistCenters: finalists.map((rect) => rect.x + rect.width / 2),
connectorQuarters: [firstConnector.x + firstConnector.width / 4, firstConnector.x + (firstConnector.width * 3) / 4],
connectorQuarters: [
firstConnector.x + firstConnector.width / 4,
firstConnector.x + (firstConnector.width * 3) / 4,
],
};
});
expect(geometry.canvasWidth).toBe(2000);
expect(geometry.canvasWidth).toBeGreaterThanOrEqual(1000);
expect(geometry.canvasWidth).toBeLessThanOrEqual(1200);
expect(Math.abs(geometry.connectorCenter - geometry.championCenter)).toBeLessThan(1);
expect(geometry.finalistCenters).toHaveLength(2);
expect(Math.abs(geometry.finalistCenters[0]! - geometry.connectorQuarters[0]!)).toBeLessThan(1);
expect(Math.abs(geometry.finalistCenters[1]! - geometry.connectorQuarters[1]!)).toBeLessThan(1);
await page.screenshot({ path: testInfo.outputPath('tournament-bracket-desktop.webp'), fullPage: true });
await persistScreenshot(page, 'tournament-desktop', testInfo.outputPath('tournament-bracket-desktop.webp'));
});
test('mobile bracket shows every round and general within the handheld width', async ({ page }, testInfo) => {
@@ -197,5 +264,62 @@ test('mobile bracket shows every round and general within the handheld width', a
expect(bounds.width).toBe(390);
expect(bounds.minX).toBeGreaterThanOrEqual(0);
expect(bounds.maxX).toBeLessThanOrEqual(390);
await page.screenshot({ path: testInfo.outputPath('tournament-bracket-mobile.webp'), fullPage: true });
const identity = await bracket
.locator('.mobile-bracket-name')
.first()
.evaluate((element) => {
const icon = element.querySelector('img')!.getBoundingClientRect();
const name = element.querySelector<HTMLElement>('.general-identity-name')!.getBoundingClientRect();
return { iconRight: icon.right, nameLeft: name.left, iconY: icon.y, nameY: name.y };
});
expect(identity.nameLeft).toBeGreaterThanOrEqual(identity.iconRight);
expect(Math.abs(identity.iconY - identity.nameY)).toBeLessThan(8);
await expect(page.getByRole('tablist', { name: '본선 조 선택' })).toBeVisible();
await page.getByRole('tab', { name: '二조' }).first().click();
await expect(page.getByRole('tab', { name: '二조' }).first()).toHaveAttribute('aria-selected', 'true');
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
await persistScreenshot(page, 'tournament-mobile', testInfo.outputPath('tournament-bracket-mobile.webp'));
});
test('mobile betting rankings use tabs and keep dedicated icons beside general names', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
await installFixture(page);
await page.goto('betting');
await expect(page.locator('.candidate-card')).toHaveCount(16);
await expect(page.getByRole('tablist', { name: '토너먼트 랭킹 종목 선택' })).toBeVisible();
await expect(page.locator('.ranking-table:visible')).toHaveCount(1);
await page.getByRole('tab', { name: '통솔전' }).click();
await expect(page.getByRole('tab', { name: '통솔전' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.ranking-table:visible thead')).toContainText('통 솔 전');
const identity = await page
.locator('.ranking-table:visible .general-identity')
.first()
.evaluate((element) => {
const icon = element.querySelector('img')!.getBoundingClientRect();
const name = element.querySelector<HTMLElement>('.general-identity-name')!.getBoundingClientRect();
return { iconRight: icon.right, nameLeft: name.left, iconY: icon.y, nameY: name.y };
});
expect(identity.nameLeft).toBeGreaterThanOrEqual(identity.iconRight);
expect(Math.abs(identity.iconY - identity.nameY)).toBeLessThan(8);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
await persistScreenshot(page, 'tournament-ranking-mobile', testInfo.outputPath('tournament-ranking-mobile.webp'));
});
test('desktop betting presents icon-and-name cards and all four rankings without document overflow', async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 1365, height: 900 });
await installFixture(page);
await page.goto('betting');
await expect(page.locator('.candidate-card')).toHaveCount(16);
await expect(page.locator('.ranking-table:visible')).toHaveCount(4);
const columns = await page
.locator('.candidate-grid')
.evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(' ').length);
expect(columns).toBe(4);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(1365);
await persistScreenshot(page, 'tournament-ranking-desktop', testInfo.outputPath('tournament-ranking-desktop.webp'));
});