Files
core2026/tools/frontend-legacy-parity/tournament-betting.spec.ts
T
Hide_D aa912a2eed fix(game-ui): 공통 Lumen 버튼 모양을 통일한다
상하단 이동·갱신 제어와 장수 목록, 토너먼트, 명령 선택을 공통 semantic button family로 연결한다.

고정 행은 공통 fixed-height 변형이 hover와 active 높이를 보정하게 하고 실제 Chromium geometry 회귀를 추가한다.
2026-08-21 16:31:42 +00:00

437 lines
17 KiB
TypeScript

import { expect, test, type Page, type Route } from '@playwright/test';
const response = (data: unknown) => ({ result: { data } });
const gameUrl = process.env.FRONTEND_PARITY_GAME_URL ?? 'http://127.0.0.1:15102';
const artifactDir = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
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 participants = Array.from({ length: 64 }, (_, index) => ({
id: index + 1,
name: `장수${String(index + 1).padStart(2, '0')}`,
leadership: 80 - (index % 20),
strength: 70 + (index % 20),
intel: 65 + (index % 15),
level: 5,
groupId: 10 + Math.floor(index / 8),
groupNo: index % 8,
win: index % 4,
draw: index % 2,
lose: (index + 1) % 3,
gl: 20 - index,
finalRank: (index % 8) + 1,
}));
const matches = Array.from({ length: 8 }, (_, index) => ({
id: index + 1,
stage: 7,
roundIndex: index,
attackerId: index * 2 + 1,
defenderId: index * 2 + 2,
}));
const snapshot = {
state: {
stage: 6,
phase: 0,
type: 0,
auto: true,
openYear: 193,
openMonth: 1,
termSeconds: 60,
nextAt: '2099-01-01T00:00:00.000Z',
bettingId: 7,
bettingCloseAt: '2099-01-01T00:00:00.000Z',
},
participants,
matches,
betCount: 3,
};
const bettingSummary = {
state: snapshot.state,
totals: { 1: 300, 2: 200, 3: 100 },
myTotals: { 1: 50 },
totalAmount: 600,
myAmount: 50,
};
type FixtureState = {
betCalls: number;
joinCalls: number;
snapshotQueries: number;
summaryQueries: number;
rankingQueries: number;
accessCalls: number;
stage: number;
myGeneralId: number;
};
const fixtureByPage = new WeakMap<Page, FixtureState>();
const installFixture = async (page: Page) => {
const state: FixtureState = {
betCalls: 0,
joinCalls: 0,
snapshotQueries: 0,
summaryQueries: 0,
rankingQueries: 0,
accessCalls: 0,
stage: snapshot.state.stage,
myGeneralId: 64,
};
fixtureByPage.set(page, state);
await page.addInitScript(() => {
window.localStorage.setItem('sammo-game-token', 'ga_tournament_visual');
window.localStorage.setItem('sammo-game-profile', 'che:default');
});
await page.route('**/image/game/**', (route) =>
route.fulfill({
status: 200,
contentType: 'image/jpeg',
body: Buffer.from('/9j/4AAQSkZJRgABAQAAAQABAAD/2Q==', 'base64'),
})
);
await page.route('**/che/api/trpc/**', async (route) => {
const results = operationNames(route).map((operation) => {
if (operation === 'auth.status') {
return response({ userId: 'tournament-user' });
}
if (operation === 'lobby.info') {
return response({ myGeneral: { id: state.myGeneralId, name: '내장수' } });
}
if (operation === 'join.getConfig') return response({});
if (operation === 'general.me') {
return response({ general: { id: state.myGeneralId, name: '내장수' }, nation: null, city: null });
}
if (operation === 'tournament.getSnapshot') {
state.snapshotQueries += 1;
return response({
...snapshot,
state: {
...snapshot.state,
stage: state.stage,
},
});
}
if (operation === 'tournament.getBettingSummary') {
state.summaryQueries += 1;
return response(bettingSummary);
}
if (operation === 'tournament.getRankings') {
state.rankingQueries += 1;
return response(
[
['tt', '전 력 전', '종합'],
['tl', '통 솔 전', '통솔'],
['ts', '일 기 토', '무력'],
['ti', '설 전', '지력'],
].map(([prefix, title, statLabel]) => ({
prefix,
title,
statLabel,
entries: [
{
rank: 1,
generalId: 1,
name: '장수01',
npcState: 0,
stat: 210,
games: 12,
win: 8,
draw: 2,
lose: 2,
score: 42,
prizes: 1,
},
],
}))
);
}
if (operation === 'tournament.getAdminStatus')
return errorResponse(operation, 'Admin permission is required.');
if (operation === 'tournament.join') {
state.joinCalls += 1;
return state.joinCalls === 1
? errorResponse(operation, '금이 부족합니다.')
: response({ ok: true, count: 64 });
}
if (operation === 'tournament.placeBet') {
state.betCalls += 1;
return state.betCalls === 1
? errorResponse(operation, '500금까지만 베팅 가능합니다.')
: response({ ok: true });
}
if (operation === 'public.recordAccess') {
state.accessCalls += 1;
return response({ recorded: true });
}
return errorResponse(operation, `Unhandled fixture operation: ${operation}`);
});
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(results),
});
});
};
test.beforeEach(async ({ page }) => {
await installFixture(page);
});
test('tournament controls share fixed Lumen state geometry on desktop and mobile', async ({ page }, testInfo) => {
const evidence: Record<string, unknown> = {};
for (const viewport of [
{ width: 1024, height: 768 },
{ width: 500, height: 900 },
]) {
await page.setViewportSize(viewport);
await page.goto(`${gameUrl}/che/tournament`);
await expect(page.getByText('삼모전 토너먼트')).toBeVisible();
const controls = [
page.getByRole('tab', { name: '토너먼트' }),
page.getByRole('tab', { name: '베팅장' }),
page.getByRole('button', { name: '창 닫기' }).first(),
page.getByRole('button', { name: '갱신' }),
];
const viewportEvidence: Record<string, unknown> = {};
for (const control of controls) {
const label = (await control.textContent())?.trim() ?? 'unknown';
await control.scrollIntoViewIfNeeded();
await expect(control).toHaveClass(/legacy-button--fixed-height/u);
const measure = () =>
control.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
top: rect.top,
bottom: rect.bottom,
height: rect.height,
marginTop: style.marginTop,
borderBottomWidth: style.borderBottomWidth,
borderRadius: style.borderRadius,
backgroundColor: style.backgroundColor,
fontFamily: style.fontFamily,
fontSize: style.fontSize,
};
});
await page.mouse.move(viewport.width - 1, viewport.height - 1);
const base = await measure();
expect(base).toMatchObject({
height: 44,
marginTop: '0px',
borderBottomWidth: '4px',
borderRadius: '5.25px',
fontSize: '14px',
});
expect(base.fontFamily).toContain('Pretendard');
await control.hover();
const hover = await measure();
expect(hover).toMatchObject({ height: 43, marginTop: '1px', borderBottomWidth: '3px' });
expect(hover.bottom).toBeCloseTo(base.bottom, 2);
const box = await control.boundingBox();
if (!box) throw new Error(`${label} tournament control is not measurable`);
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
const active = await measure();
expect(active).toMatchObject({ height: 42, marginTop: '2px', borderBottomWidth: '2px' });
expect(active.bottom).toBeCloseTo(base.bottom, 2);
await page.mouse.move(viewport.width - 1, viewport.height - 1);
await page.mouse.up();
viewportEvidence[label] = { default: base, hover, active };
}
evidence[`${viewport.width}x${viewport.height}`] = viewportEvidence;
if (artifactDir) {
await page.screenshot({
path: `${artifactDir}/core-tournament-buttons-${viewport.width}.png`,
fullPage: true,
});
}
}
await testInfo.attach('tournament-button-geometry', {
body: JSON.stringify(evidence, null, 2),
contentType: 'application/json',
});
});
test('tournament keeps the legacy 2000px bracket and 250px group geometry', async ({ page }) => {
await page.setViewportSize({ width: 2200, height: 1000 });
await page.goto(`${gameUrl}/che/tournament`);
await expect(page.getByText('삼모전 토너먼트')).toBeVisible();
await expect(page.locator('.top16 span')).toHaveCount(16);
const geometry = await page.locator('#tournament-container').evaluate((container) => {
const rect = container.getBoundingClientRect();
const candidate = container.querySelector<HTMLElement>('.top16 span')!;
const groupTable = container.querySelector<HTMLElement>('.group-grid table')!;
const title = container.querySelector<HTMLElement>('.legacy-title')!;
const refresh = container.querySelector<HTMLElement>('.toolbar button')!;
const style = getComputedStyle(container);
return {
x: rect.x,
width: rect.width,
candidateWidth: candidate.getBoundingClientRect().width,
groupWidth: groupTable.getBoundingClientRect().width,
titleHeight: title.getBoundingClientRect().height,
refreshHeight: refresh.getBoundingClientRect().height,
refreshBottom: refresh.getBoundingClientRect().bottom,
refreshMarginTop: getComputedStyle(refresh).marginTop,
refreshEdge: getComputedStyle(refresh).borderBottomWidth,
refreshRadius: getComputedStyle(refresh).borderRadius,
fontFamily: style.fontFamily,
fontSize: style.fontSize,
backgroundImage: getComputedStyle(container.querySelector<HTMLElement>('.bracket')!).backgroundImage,
};
});
expect(geometry).toMatchObject({
x: 100,
width: 2000,
candidateWidth: 125,
groupWidth: 250,
titleHeight: 55.6875,
refreshHeight: 44,
refreshMarginTop: '0px',
refreshEdge: '4px',
refreshRadius: '5.25px',
fontSize: '14px',
});
expect(geometry.fontFamily).toContain('Pretendard');
expect(geometry.backgroundImage).toContain('back_walnut.jpg');
if (artifactDir) await page.screenshot({ path: `${artifactDir}/core-tournament.png`, fullPage: true });
const refresh = page.getByRole('button', { name: '갱신' });
await refresh.hover();
const hover = await refresh.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
bottom: rect.bottom,
height: rect.height,
marginTop: style.marginTop,
borderBottomWidth: style.borderBottomWidth,
filter: style.filter,
};
});
expect(hover).toMatchObject({ height: 43, marginTop: '1px', borderBottomWidth: '3px', filter: 'none' });
expect(hover.bottom).toBeCloseTo(geometry.refreshBottom, 2);
const box = await refresh.boundingBox();
if (!box) throw new Error('tournament refresh is not measurable');
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
const active = await refresh.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
bottom: rect.bottom,
height: rect.height,
marginTop: style.marginTop,
borderBottomWidth: style.borderBottomWidth,
};
});
expect(active).toMatchObject({ height: 42, marginTop: '2px', borderBottomWidth: '2px' });
expect(active.bottom).toBeCloseTo(geometry.refreshBottom, 2);
await page.mouse.up();
await refresh.focus();
await expect(refresh).toBeFocused();
});
test('tournament keeps the fixed legacy canvas at a 1024px viewport', async ({ page }) => {
await page.setViewportSize({ width: 1024, height: 768 });
await page.goto(`${gameUrl}/che/tournament`);
await expect(page.locator('#tournament-container')).toHaveCSS('width', '2000px');
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeGreaterThanOrEqual(2000);
});
test('tournament reloads its snapshot after failed and successful joins without route access', async ({ page }) => {
const state = fixtureByPage.get(page)!;
state.stage = 1;
state.myGeneralId = 999;
await page.goto(`${gameUrl}/che/tournament`);
const join = page.getByRole('button', { name: '참가', exact: true });
await expect(join).toBeVisible();
await expect.poll(() => state.snapshotQueries).toBe(1);
await join.click();
await expect(page.getByRole('status')).toHaveText('금이 부족합니다.');
await expect.poll(() => state.snapshotQueries).toBe(2);
await join.click();
await expect(page.getByRole('status')).toHaveText('참가 신청이 반영되었습니다.');
await expect.poll(() => state.snapshotQueries).toBe(3);
expect(state.summaryQueries).toBe(3);
expect(state.accessCalls).toBe(0);
});
test('betting keeps the 1120px and 16 by 70px layout and retains a failed selection', async ({ page }) => {
const state = fixtureByPage.get(page)!;
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(`${gameUrl}/che/betting`);
await expect(page.getByText('베 팅 장')).toBeVisible();
await expect(page.locator('.names span')).toHaveCount(16);
await expect.poll(() => state.snapshotQueries).toBe(1);
const geometry = await page.locator('#tournament-betting-container').evaluate((container) => {
const rect = container.getBoundingClientRect();
const first = container.querySelector<HTMLElement>('.names span')!;
const title = container.querySelector<HTMLElement>('.title')!;
const refresh = container.querySelector<HTMLElement>('.toolbar button')!;
const stateStyle = getComputedStyle(container.querySelector<HTMLElement>('.state')!);
const tableStyle = getComputedStyle(container.querySelector<HTMLElement>('.candidate-table')!);
return {
x: rect.x,
width: rect.width,
candidateWidth: first.getBoundingClientRect().width,
titleHeight: title.getBoundingClientRect().height,
refreshHeight: refresh.getBoundingClientRect().height,
refreshRadius: getComputedStyle(refresh).borderRadius,
stateFontSize: stateStyle.fontSize,
tableFontSize: tableStyle.fontSize,
tableBorder: tableStyle.borderTopWidth,
};
});
expect(geometry).toEqual({
x: 80,
width: 1120,
candidateWidth: 70,
titleHeight: 55.6875,
refreshHeight: 35.5,
refreshRadius: '5.25px',
stateFontSize: '24px',
tableFontSize: '10px',
tableBorder: '1px',
});
if (artifactDir) await page.screenshot({ path: `${artifactDir}/core-betting.png`, fullPage: true });
const select = page.getByLabel('장수01 베팅 금액');
await select.selectOption('500');
const bet = page.getByRole('button', { name: '베팅!' }).first();
await bet.hover();
await bet.focus();
await expect(bet).toBeFocused();
await bet.click();
await expect(page.getByRole('status')).toHaveText('500금까지만 베팅 가능합니다.');
await expect(select).toHaveValue('500');
await expect.poll(() => state.snapshotQueries).toBe(2);
await bet.click();
await expect(page.getByRole('status')).toHaveText('베팅이 등록되었습니다.');
await expect.poll(() => state.snapshotQueries).toBe(3);
expect(state.summaryQueries).toBe(3);
expect(state.rankingQueries).toBe(3);
expect(state.accessCalls).toBe(0);
});