1173 lines
55 KiB
TypeScript
1173 lines
55 KiB
TypeScript
import { expect, test, type Page, type Route } from '@playwright/test';
|
|
import { mkdir, readFile, writeFile } 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 longGeneralName = 'ⓜ가나다라마바사아자차카타파하일이삼';
|
|
const names = [
|
|
'관우',
|
|
'장료',
|
|
'조운',
|
|
'하후돈',
|
|
'손책',
|
|
'태사자',
|
|
'마초',
|
|
'황충',
|
|
'여포',
|
|
'전위',
|
|
'감녕',
|
|
'문추',
|
|
'안량',
|
|
'허저',
|
|
'주태',
|
|
longGeneralName,
|
|
...Array.from({ length: 48 }, (_, index) => `예선장수${index + 17}`),
|
|
];
|
|
const tournamentNpcStates = [0, 1, 2, 4, 5, 6] as const;
|
|
const participants = names.map((name, index) => ({
|
|
id: index + 1,
|
|
name,
|
|
leadership: 80,
|
|
strength: 80,
|
|
intel: 80,
|
|
level: 10,
|
|
picture: 'default.jpg',
|
|
imageServer: 0,
|
|
npcState: tournamentNpcStates[index % tournamentNpcStates.length],
|
|
groupId: Math.floor(index / 8) < 4 ? 10 + (index % 8) : index % 8,
|
|
groupNo: Math.floor(index / 8),
|
|
win: Math.floor(index / 8) < 4 ? 3 - (index % 2) : 7 - Math.floor(index / 8),
|
|
draw: index % 2,
|
|
lose: Math.floor(index / 8) < 4 ? 0 : Math.floor(index / 8),
|
|
gl: 64 - index,
|
|
finalRank: Math.floor(index / 8) + 1,
|
|
preliminaryGroupId: index % 8,
|
|
preliminaryGroupNo: Math.floor(index / 8),
|
|
preliminaryRank: Math.floor(index / 8) + 1,
|
|
preliminaryWin: 7 - Math.floor(index / 8),
|
|
preliminaryDraw: index % 2,
|
|
preliminaryLose: Math.floor(index / 8),
|
|
preliminaryGl: 64 - index,
|
|
}));
|
|
const matches = [
|
|
...Array.from({ length: 8 }, (_, index) => ({
|
|
id: index + 1,
|
|
stage: 7,
|
|
roundIndex: index,
|
|
attackerId: index * 2 + 1,
|
|
defenderId: index * 2 + 2,
|
|
winnerId: index * 2 + 1,
|
|
})),
|
|
...Array.from({ length: 4 }, (_, index) => ({
|
|
id: index + 9,
|
|
stage: 8,
|
|
roundIndex: index,
|
|
attackerId: index * 4 + 1,
|
|
defenderId: index * 4 + 3,
|
|
winnerId: index * 4 + 1,
|
|
})),
|
|
...Array.from({ length: 2 }, (_, index) => ({
|
|
id: index + 13,
|
|
stage: 9,
|
|
roundIndex: index,
|
|
attackerId: index * 8 + 1,
|
|
defenderId: index * 8 + 5,
|
|
winnerId: index * 8 + 1,
|
|
})),
|
|
{
|
|
id: 15,
|
|
stage: 10,
|
|
roundIndex: 0,
|
|
attackerId: 1,
|
|
defenderId: 9,
|
|
winnerId: 1,
|
|
log: ['<S>●</> <Y>관우</> <C>(800)</> vs <C>(790)</> <Y>여포</>', '<S>●</> <Y>관우</> <S>우승</>!'],
|
|
},
|
|
];
|
|
const buildGroupFightMatches = (stage: 2 | 4) => {
|
|
const groupStart = stage === 2 ? 0 : 10;
|
|
return Array.from({ length: 8 }, (_, index) => ({
|
|
id: stage * 100 + groupStart + index + 1,
|
|
stage,
|
|
roundIndex: groupStart + index,
|
|
groupId: groupStart + index,
|
|
attackerId: index * 2 + 1,
|
|
defenderId: index * 2 + 2,
|
|
winnerId: index * 2 + 1,
|
|
log: [
|
|
`<S>●</> <Y>${names[index * 2]}</> <C>(800)</> vs <C>(790)</> <Y>${names[index * 2 + 1]}</>`,
|
|
'<S>●</> 01合 : <C>720</><span class="ev_highlight">(-080)</span> vs <span class="ev_highlight">(-090)</span><C>700</>',
|
|
`<S>●</> <Y>${names[index * 2]}</> <S>승리</>!`,
|
|
],
|
|
}));
|
|
};
|
|
const matchesForStage = (stage: number) => {
|
|
if (stage === 2 || stage === 3) {
|
|
return [...buildGroupFightMatches(2), ...matches];
|
|
}
|
|
if (stage === 4 || stage === 5) {
|
|
return [...buildGroupFightMatches(2), ...buildGroupFightMatches(4), ...matches];
|
|
}
|
|
if (stage === 7) {
|
|
return matches.map((match, index) =>
|
|
match.stage === 7 && index === 0
|
|
? {
|
|
...match,
|
|
log: [
|
|
'<S>●</> <Y>관우</> <C>(800)</> vs <C>(790)</> <Y>장료</>',
|
|
'<S>●</> <Y>관우</> <S>승리</>!',
|
|
],
|
|
}
|
|
: match
|
|
);
|
|
}
|
|
return matches;
|
|
};
|
|
|
|
const response = (data: unknown) => ({ result: { data } });
|
|
const asRecord = (value: unknown): Record<string, unknown> | null =>
|
|
typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
|
const findBetInput = (value: unknown): { targetId: number; amount: number } | null => {
|
|
const record = asRecord(value);
|
|
if (!record) return null;
|
|
if (typeof record.targetId === 'number' && typeof record.amount === 'number') {
|
|
return { targetId: record.targetId, amount: record.amount };
|
|
}
|
|
for (const child of Object.values(record)) {
|
|
const result = findBetInput(child);
|
|
if (result) return result;
|
|
}
|
|
return null;
|
|
};
|
|
const operationNames = (route: Route): string[] => {
|
|
const url = new URL(route.request().url());
|
|
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
|
};
|
|
|
|
const readReferenceImage = async (filename: string): Promise<Buffer> => {
|
|
for (const imageRoot of imageRoots) {
|
|
try {
|
|
return await readFile(resolve(imageRoot, filename));
|
|
} catch {
|
|
// Worktrees can be nested at different depths.
|
|
}
|
|
}
|
|
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,
|
|
options: {
|
|
applicationOpen?: boolean;
|
|
tournamentType?: number;
|
|
tournamentStage?: number;
|
|
joinedGroupId?: number;
|
|
emptyFinalGroups?: boolean;
|
|
realtimeState?: { tournamentStage: number; totalAmount: number };
|
|
betFailure?: { message: string | null };
|
|
betDelayMs?: number;
|
|
onOperation?: (operation: string, headers: Record<string, string>) => void;
|
|
} = {}
|
|
) => {
|
|
let joined = false;
|
|
const placedBets: Array<{ targetId: number; amount: number }> = [];
|
|
await page.addInitScript((profile) => {
|
|
window.localStorage.setItem('sammo-game-token', 'ga_tournament_bracket_playwright');
|
|
window.localStorage.setItem('sammo-game-profile', profile);
|
|
}, gameProfile);
|
|
for (const filename of ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg']) {
|
|
await page.route(`**/image/game/${filename}`, async (route) => {
|
|
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) => {
|
|
if (operationNames(route).includes('tournament.placeBet') && options.betDelayMs) {
|
|
await new Promise((resolve) => setTimeout(resolve, options.betDelayMs));
|
|
}
|
|
const results = operationNames(route).map((operation) => {
|
|
options.onOperation?.(operation, route.request().headers());
|
|
if (operation === 'auth.status') return response({ ok: true });
|
|
if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: names[0] } });
|
|
if (operation === 'join.getConfig') return response({});
|
|
if (operation === 'general.me') return response({ general: { id: 1, name: names[0] } });
|
|
if (operation === 'tournament.getAdminStatus') return response({ ok: false });
|
|
if (operation === 'tournament.getSnapshot') {
|
|
const tournamentStage =
|
|
options.realtimeState?.tournamentStage ??
|
|
options.tournamentStage ??
|
|
(options.applicationOpen ? 1 : 0);
|
|
const joinedGroupId = options.joinedGroupId ?? 0;
|
|
return response({
|
|
state: {
|
|
stage: tournamentStage,
|
|
phase: 0,
|
|
type: options.tournamentType ?? 0,
|
|
auto: false,
|
|
openYear: 184,
|
|
openMonth: 1,
|
|
termSeconds: 60,
|
|
nextAt: '2026-08-02T00:00:00.000Z',
|
|
winnerId: tournamentStage === 0 ? 1 : undefined,
|
|
},
|
|
participants:
|
|
options.applicationOpen && !joined
|
|
? []
|
|
: options.applicationOpen
|
|
? [
|
|
{
|
|
...participants[0],
|
|
groupId: joinedGroupId,
|
|
groupNo: 0,
|
|
preliminaryGroupId: joinedGroupId,
|
|
preliminaryGroupNo: 0,
|
|
win: 0,
|
|
draw: 0,
|
|
lose: 0,
|
|
gl: 0,
|
|
seedRank: 0,
|
|
finalRank: 0,
|
|
},
|
|
]
|
|
: options.emptyFinalGroups
|
|
? participants.map((participant) => ({
|
|
...participant,
|
|
groupId: participant.preliminaryGroupId,
|
|
}))
|
|
: participants,
|
|
matches: matchesForStage(tournamentStage),
|
|
betCount: 16,
|
|
});
|
|
}
|
|
if (operation === 'tournament.join') {
|
|
joined = true;
|
|
return response({ ok: true, count: 1 });
|
|
}
|
|
if (operation === 'tournament.getBettingSummary') {
|
|
return response({
|
|
totals: Object.fromEntries(
|
|
participants
|
|
.slice(0, 16)
|
|
.map((participant, index) => [
|
|
participant.id,
|
|
100 +
|
|
index * 10 +
|
|
placedBets
|
|
.filter((bet) => bet.targetId === participant.id)
|
|
.reduce((sum, bet) => sum + bet.amount, 0),
|
|
])
|
|
),
|
|
myTotals: Object.fromEntries(
|
|
participants
|
|
.slice(0, 16)
|
|
.map((participant) => [
|
|
participant.id,
|
|
(participant.id === 1 ? 120 : participant.id === 2 ? 40 : 0) +
|
|
placedBets
|
|
.filter((bet) => bet.targetId === participant.id)
|
|
.reduce((sum, bet) => sum + bet.amount, 0),
|
|
])
|
|
),
|
|
totalAmount:
|
|
(options.realtimeState?.totalAmount ?? 2800) +
|
|
placedBets.reduce((sum, bet) => sum + bet.amount, 0),
|
|
myAmount: 160 + placedBets.reduce((sum, bet) => sum + bet.amount, 0),
|
|
});
|
|
}
|
|
if (operation === 'tournament.placeBet') {
|
|
const input = findBetInput(route.request().postDataJSON());
|
|
if (!input) throw new Error('베팅 요청에서 targetId와 amount를 찾을 수 없습니다.');
|
|
if (options.betFailure?.message)
|
|
return {
|
|
error: {
|
|
message: options.betFailure.message,
|
|
code: -32600,
|
|
data: { code: 'BAD_REQUEST', httpStatus: 400, path: operation },
|
|
},
|
|
};
|
|
placedBets.push(input);
|
|
return response({ ok: true });
|
|
}
|
|
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: participant.npcState,
|
|
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) });
|
|
});
|
|
return { placedBets };
|
|
};
|
|
|
|
const installFakeEventSource = async (page: Page) => {
|
|
await page.addInitScript(() => {
|
|
class FakeEventSource extends EventTarget {
|
|
static instances: FakeEventSource[] = [];
|
|
readonly url: string;
|
|
closed = false;
|
|
|
|
constructor(url: string | URL) {
|
|
super();
|
|
this.url = String(url);
|
|
FakeEventSource.instances.push(this);
|
|
queueMicrotask(() => {
|
|
if (!this.closed) this.dispatchEvent(new Event('open'));
|
|
});
|
|
}
|
|
|
|
close() {
|
|
this.closed = true;
|
|
}
|
|
|
|
emit(type: string, payload: unknown) {
|
|
if (this.closed) return;
|
|
this.dispatchEvent(new MessageEvent(type, { data: JSON.stringify(payload) }));
|
|
}
|
|
}
|
|
|
|
Object.defineProperty(window, 'EventSource', { configurable: true, value: FakeEventSource });
|
|
Object.assign(window, {
|
|
__tournamentEventSourceCount: () => FakeEventSource.instances.filter((source) => !source.closed).length,
|
|
__tournamentEventSourceUrls: () =>
|
|
FakeEventSource.instances.filter((source) => !source.closed).map((source) => source.url),
|
|
__emitTournamentEvent: (type: string, payload: unknown) => {
|
|
for (const source of FakeEventSource.instances) source.emit(type, payload);
|
|
},
|
|
});
|
|
});
|
|
};
|
|
|
|
const openTournament = async (page: Page) => {
|
|
await installFixture(page);
|
|
await page.goto('tournament');
|
|
await expect(page.getByLabel('토너먼트 대진표')).toBeVisible();
|
|
};
|
|
|
|
test('tournament and betting identities preserve Ref NPC name colors on desktop and mobile', async ({
|
|
page,
|
|
}, testInfo) => {
|
|
await installFixture(page, { tournamentStage: 7 });
|
|
const expectedColors = [
|
|
'',
|
|
'rgb(135, 206, 235)',
|
|
'rgb(0, 255, 255)',
|
|
'rgb(0, 191, 255)',
|
|
'rgb(0, 139, 139)',
|
|
'rgb(102, 205, 170)',
|
|
];
|
|
|
|
for (const viewport of [
|
|
{ width: 1365, height: 900 },
|
|
{ width: 500, height: 900 },
|
|
]) {
|
|
await page.setViewportSize(viewport);
|
|
await page.goto('tournament');
|
|
const scope = viewport.width > 800 ? '.desktop-bracket' : '.mobile-bracket';
|
|
for (let index = 0; index < expectedColors.length; index += 1) {
|
|
const name = page.locator(`${scope} [data-general-id="${index + 1}"] .general-identity-name`).first();
|
|
await expect(name).toBeVisible();
|
|
if (expectedColors[index]) await expect(name).toHaveCSS('color', expectedColors[index]!);
|
|
else expect(await name.evaluate((element) => (element as HTMLElement).style.color)).toBe('');
|
|
}
|
|
await page.screenshot({
|
|
path: testInfo.outputPath(`tournament-npc-colors-${viewport.width}.png`),
|
|
fullPage: true,
|
|
});
|
|
}
|
|
|
|
await page.setViewportSize({ width: 1365, height: 900 });
|
|
await page.goto('betting');
|
|
const rankingNames = page.locator('.ranking-general .general-identity-name');
|
|
for (let index = 0; index < expectedColors.length; index += 1) {
|
|
if (expectedColors[index]) await expect(rankingNames.nth(index)).toHaveCSS('color', expectedColors[index]!);
|
|
else expect(await rankingNames.nth(index).evaluate((element) => (element as HTMLElement).style.color)).toBe('');
|
|
}
|
|
await page.screenshot({ path: testInfo.outputPath('betting-npc-colors-1365.png'), fullPage: true });
|
|
});
|
|
|
|
test('desktop bracket connects every real general slot to the next round', async ({ page }, testInfo) => {
|
|
await page.setViewportSize({ width: 1365, height: 900 });
|
|
await openTournament(page);
|
|
|
|
await expect(page.locator('.desktop-bracket-canvas .desktop-bracket-name[data-general-id]')).toHaveCount(31);
|
|
await expect(page.locator('.desktop-bracket-canvas svg > g')).toHaveCount(15);
|
|
await expect(
|
|
page.locator('.desktop-bracket-canvas .desktop-bracket-name.advanced', { hasText: '관우' })
|
|
).toHaveCount(5);
|
|
|
|
const geometry = await page.locator('.desktop-bracket-canvas').evaluate((canvas) => {
|
|
const cards = [...canvas.querySelectorAll<HTMLElement>('.desktop-bracket-name')];
|
|
const firstRound = cards.slice(0, 16).map((element) => element.getBoundingClientRect());
|
|
const quarterFinal = cards[16]!.getBoundingClientRect();
|
|
const icons = cards.map((element) => element.querySelector('img')!.getBoundingClientRect());
|
|
const names = cards.map((element) =>
|
|
element.querySelector<HTMLElement>('.general-identity-name')!.getBoundingClientRect()
|
|
);
|
|
const own = canvas.getBoundingClientRect();
|
|
return {
|
|
canvasWidth: own.width,
|
|
minX: Math.min(...cards.map((card) => card.getBoundingClientRect().left - own.left)),
|
|
maxX: Math.max(...cards.map((card) => card.getBoundingClientRect().right - own.left)),
|
|
iconSizes: icons.map((icon) => [icon.width, icon.height]),
|
|
horizontalIdentities: icons.every((icon, index) => names[index]!.left >= icon.right - 1),
|
|
firstParentY: quarterFinal.y + quarterFinal.height / 2,
|
|
firstPairAverageY:
|
|
(firstRound[0]!.y + firstRound[0]!.height / 2 + firstRound[1]!.y + firstRound[1]!.height / 2) / 2,
|
|
};
|
|
});
|
|
expect(geometry.canvasWidth).toBeGreaterThanOrEqual(800);
|
|
expect(geometry.canvasWidth).toBeLessThanOrEqual(1200);
|
|
expect(geometry.minX).toBeGreaterThanOrEqual(0);
|
|
expect(geometry.maxX).toBeLessThanOrEqual(geometry.canvasWidth);
|
|
expect(geometry.iconSizes.every(([width, height]) => width === 64 && height === 64)).toBe(true);
|
|
expect(geometry.horizontalIdentities).toBe(true);
|
|
expect(Math.abs(geometry.firstParentY - geometry.firstPairAverageY)).toBeLessThan(1);
|
|
|
|
const controls = await page.locator('#tournament-container').evaluate((container) => {
|
|
const bounds = (selector: string) => container.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
|
|
const refresh = bounds('.toolbar button:first-child');
|
|
const join = bounds('.join-button');
|
|
const close = bounds('.close-button');
|
|
return {
|
|
refresh: { width: refresh.width, height: refresh.height },
|
|
join: { width: join.width, height: join.height },
|
|
close: { width: close.width, height: close.height },
|
|
};
|
|
});
|
|
expect(controls.refresh).toEqual({ width: 72, height: 44 });
|
|
expect(controls.join).toEqual({ width: 72, height: 44 });
|
|
expect(controls.close).toEqual({ width: 88, height: 44 });
|
|
|
|
await expect(page.locator('.desktop-bracket .bracket-bet-summary')).toHaveCount(0);
|
|
|
|
const preliminaryGroups = page.locator('.preliminary-grid .tournament-group-card');
|
|
await expect(preliminaryGroups).toHaveCount(8);
|
|
for (let groupIndex = 0; groupIndex < 8; groupIndex += 1) {
|
|
await expect(preliminaryGroups.nth(groupIndex).locator('.standing-row')).toHaveCount(8);
|
|
await expect(preliminaryGroups.nth(groupIndex).locator('.general-identity')).toHaveCount(8);
|
|
}
|
|
await expect(page.locator('.group-grid th, .group-grid td')).toHaveCount(0);
|
|
|
|
const longName = page
|
|
.locator('.tournament-group-card .general-identity-name', { hasText: longGeneralName })
|
|
.first();
|
|
await expect(longName).toHaveAttribute('title', longGeneralName);
|
|
const longNameGeometry = await longName.evaluate((element) => ({
|
|
clientWidth: element.clientWidth,
|
|
scrollWidth: element.scrollWidth,
|
|
overflow: getComputedStyle(element).overflow,
|
|
textOverflow: getComputedStyle(element).textOverflow,
|
|
whiteSpace: getComputedStyle(element).whiteSpace,
|
|
}));
|
|
expect(longNameGeometry.clientWidth).toBeGreaterThan(100);
|
|
expect(longNameGeometry.scrollWidth).toBeGreaterThan(longNameGeometry.clientWidth);
|
|
expect(longNameGeometry).toMatchObject({ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' });
|
|
|
|
await persistScreenshot(page, 'tournament-desktop', testInfo.outputPath('tournament-bracket-desktop.webp'));
|
|
});
|
|
|
|
test('join refresh shows the assigned preliminary group immediately with accessible controls', async ({
|
|
page,
|
|
}, testInfo) => {
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await installFixture(page, { applicationOpen: true, joinedGroupId: 5 });
|
|
await page.goto('tournament');
|
|
|
|
const refresh = page.getByRole('button', { name: '갱신' });
|
|
const join = page.getByRole('button', { name: '참가' });
|
|
const close = page.getByRole('button', { name: '창 닫기' }).first();
|
|
await expect(join).toBeEnabled();
|
|
await expect(page.getByText('조별 예선 순위')).toBeVisible();
|
|
await expect(page.getByText('조별 본선 순위')).toHaveCount(0);
|
|
await expect(page.getByLabel('토너먼트 대진표')).toHaveCount(0);
|
|
await join.click();
|
|
|
|
await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="success"]')).toContainText(
|
|
'참가 신청이 반영되었습니다. 六조에 배정되었습니다.'
|
|
);
|
|
await expect(join).toBeDisabled();
|
|
const preliminaryTabs = page.getByRole('tablist', { name: '예선 조 선택' });
|
|
await expect(preliminaryTabs.getByRole('tab').nth(5)).toHaveAttribute('aria-selected', 'true');
|
|
const assignedGroup = page.locator('[data-preliminary-group="5"]');
|
|
await expect(assignedGroup.locator('.general-identity', { hasText: names[0] })).toBeVisible();
|
|
await expect(assignedGroup.locator('.standing-row')).toHaveCount(8);
|
|
await expect(assignedGroup.locator('.standing-row[data-empty="true"]')).toHaveCount(7);
|
|
const emptySlotGeometry = await assignedGroup.locator('.standing-row').evaluateAll((rows) =>
|
|
rows.map((row) => {
|
|
const icon = row.querySelector<HTMLElement>('.general-identity-icon')!.getBoundingClientRect();
|
|
return { height: row.getBoundingClientRect().height, iconWidth: icon.width, iconHeight: icon.height };
|
|
})
|
|
);
|
|
expect(new Set(emptySlotGeometry.map((row) => row.height)).size).toBe(1);
|
|
expect(emptySlotGeometry.every((row) => row.iconWidth === 64 && row.iconHeight === 64)).toBe(true);
|
|
const assignedGroupBounds = await assignedGroup.boundingBox();
|
|
expect(assignedGroupBounds?.y).toBeLessThan(844);
|
|
expect((assignedGroupBounds?.y ?? 0) + (assignedGroupBounds?.height ?? 0)).toBeGreaterThan(0);
|
|
await persistScreenshot(
|
|
page,
|
|
'tournament-joined-group-mobile',
|
|
testInfo.outputPath('tournament-joined-group.webp')
|
|
);
|
|
|
|
for (const control of [refresh, join, close]) {
|
|
const box = await control.boundingBox();
|
|
expect(box?.height).toBe(44);
|
|
expect(box?.width).toBeGreaterThanOrEqual(72);
|
|
}
|
|
await refresh.focus();
|
|
await expect(refresh).toBeFocused();
|
|
await refresh.hover();
|
|
await expect(refresh).toHaveCSS('filter', 'none');
|
|
await expect(refresh).toHaveCSS('height', '43px');
|
|
await expect(refresh).toHaveCSS('margin-top', '1px');
|
|
await expect(refresh).toHaveCSS('border-bottom-width', '3px');
|
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
|
|
});
|
|
|
|
test('desktop join scrolls the assigned preliminary group into view without future sections', async ({
|
|
page,
|
|
}, testInfo) => {
|
|
await page.setViewportSize({ width: 1365, height: 900 });
|
|
await installFixture(page, { applicationOpen: true, joinedGroupId: 7 });
|
|
await page.goto('tournament');
|
|
|
|
await expect(page.getByText('조별 본선 순위')).toHaveCount(0);
|
|
await expect(page.getByLabel('토너먼트 대진표')).toHaveCount(0);
|
|
await page.getByRole('button', { name: '참가' }).click();
|
|
|
|
await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="success"]')).toContainText(
|
|
'참가 신청이 반영되었습니다. 八조에 배정되었습니다.'
|
|
);
|
|
const assignedGroup = page.locator('[data-preliminary-group="7"]');
|
|
await expect(assignedGroup.locator('.general-identity', { hasText: names[0] })).toBeVisible();
|
|
const bounds = await assignedGroup.boundingBox();
|
|
expect(bounds?.y).toBeLessThan(900);
|
|
expect((bounds?.y ?? 0) + (bounds?.height ?? 0)).toBeGreaterThan(0);
|
|
await persistScreenshot(
|
|
page,
|
|
'tournament-joined-group-desktop',
|
|
testInfo.outputPath('tournament-joined-group.webp')
|
|
);
|
|
});
|
|
|
|
test('final group section appears before the later knockout section', async ({ page }, testInfo) => {
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await installFixture(page, { tournamentStage: 3, emptyFinalGroups: true });
|
|
await page.goto('tournament');
|
|
|
|
await expect(page.getByText('조별 예선 순위')).toBeVisible();
|
|
await expect(page.getByText('조별 본선 순위')).toBeVisible();
|
|
await expect(page.getByLabel('토너먼트 대진표')).toHaveCount(0);
|
|
const activeFinalGroup = page.locator('.final-grid .tournament-group-card.mobile-active');
|
|
await expect(activeFinalGroup.locator('.standing-row')).toHaveCount(4);
|
|
await expect(activeFinalGroup.locator('.standing-row[data-empty="true"]')).toHaveCount(4);
|
|
const emptyFinalHeights = await activeFinalGroup
|
|
.locator('.standing-row')
|
|
.evaluateAll((rows) => rows.map((row) => row.getBoundingClientRect().height));
|
|
expect(new Set(emptyFinalHeights).size).toBe(1);
|
|
await persistScreenshot(page, 'tournament-final-stage-mobile', testInfo.outputPath('tournament-final-stage.webp'));
|
|
});
|
|
|
|
test('preliminary stage renders the latest fight log for all eight groups', async ({ page }, testInfo) => {
|
|
await page.setViewportSize({ width: 1365, height: 900 });
|
|
await installFixture(page, { tournamentStage: 2 });
|
|
await page.goto('tournament');
|
|
|
|
const region = page.getByRole('region', { name: '예선 조별 전투 로그' });
|
|
const logs = region.locator('.fight-log');
|
|
await expect(logs).toHaveCount(8);
|
|
await expect(logs).toHaveText([
|
|
/一조 전투 로그.*관우.*장료.*승리/s,
|
|
/二조 전투 로그.*조운.*하후돈.*승리/s,
|
|
/三조 전투 로그/s,
|
|
/四조 전투 로그/s,
|
|
/五조 전투 로그/s,
|
|
/六조 전투 로그/s,
|
|
/七조 전투 로그/s,
|
|
/八조 전투 로그/s,
|
|
]);
|
|
const geometry = await logs.evaluateAll((elements) =>
|
|
elements.map((element) => {
|
|
const bounds = element.getBoundingClientRect();
|
|
return { top: bounds.top, left: bounds.left, right: bounds.right, width: bounds.width };
|
|
})
|
|
);
|
|
expect(new Set(geometry.slice(0, 4).map((item) => item.top)).size).toBe(1);
|
|
expect(geometry[4]!.top).toBeGreaterThan(geometry[0]!.top);
|
|
expect(geometry.every((item) => item.left >= 0 && item.right <= 1365 && item.width > 0)).toBe(true);
|
|
await expect(logs.first().locator('p').first().locator('span').first()).toHaveCSS('color', 'rgb(135, 206, 235)');
|
|
await persistScreenshot(page, 'tournament-preliminary-fight-logs', testInfo.outputPath('preliminary-logs.webp'));
|
|
});
|
|
|
|
test('final group stage keeps all eight fight logs visible on mobile without overflow', async ({ page }, testInfo) => {
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await installFixture(page, { tournamentStage: 4 });
|
|
await page.goto('tournament');
|
|
|
|
const region = page.getByRole('region', { name: '본선 조별 전투 로그' });
|
|
const logs = region.locator('.fight-log');
|
|
await expect(logs).toHaveCount(8);
|
|
for (let index = 0; index < 8; index += 1) {
|
|
await expect(logs.nth(index)).toBeVisible();
|
|
}
|
|
const geometry = await logs.evaluateAll((elements) =>
|
|
elements.map((element) => {
|
|
const bounds = element.getBoundingClientRect();
|
|
return { top: bounds.top, left: bounds.left, right: bounds.right };
|
|
})
|
|
);
|
|
expect(geometry.every((item, index) => index === 0 || item.top > geometry[index - 1]!.top)).toBe(true);
|
|
expect(geometry.every((item) => item.left >= 0 && item.right <= 390)).toBe(true);
|
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
|
|
await persistScreenshot(page, 'tournament-final-fight-logs-mobile', testInfo.outputPath('final-logs-mobile.webp'));
|
|
});
|
|
|
|
test('knockout stage shows the latest completed match instead of the next empty match', async ({ page }) => {
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await installFixture(page, { tournamentStage: 7 });
|
|
await page.goto('tournament');
|
|
|
|
const region = page.getByRole('region', { name: '현재 토너먼트 전투 로그' });
|
|
await expect(region).toContainText('관우 vs 장료');
|
|
await expect(region).toContainText('관우 승리!');
|
|
await expect(region).not.toContainText('<S>');
|
|
await expect(region.locator('p').first().locator('span').first()).toHaveCSS('color', 'rgb(135, 206, 235)');
|
|
});
|
|
|
|
test('completed tournament retains the final fight log like Ref', async ({ page }) => {
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await installFixture(page);
|
|
await page.goto('tournament');
|
|
|
|
const region = page.getByRole('region', { name: '현재 토너먼트 전투 로그' });
|
|
await expect(region).toContainText('관우 vs 여포');
|
|
await expect(region).toContainText('관우 우승!');
|
|
});
|
|
|
|
test('mobile bracket exposes every round through tabs with standard horizontal identities', async ({
|
|
page,
|
|
}, testInfo) => {
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await openTournament(page);
|
|
|
|
const bracket = page.locator('.mobile-bracket');
|
|
await expect(bracket).toBeVisible();
|
|
await expect(page.getByRole('tablist', { name: '토너먼트 라운드 선택' })).toBeVisible();
|
|
await expect(bracket.locator('.mobile-bracket-name')).toHaveCount(16);
|
|
const longIdentity = bracket.locator('.mobile-bracket-name', { hasText: longGeneralName });
|
|
await expect(longIdentity).toBeVisible();
|
|
const longNameMetrics = await longIdentity.locator('.general-identity-name').evaluate((element) => {
|
|
const style = getComputedStyle(element);
|
|
return {
|
|
clientWidth: element.clientWidth,
|
|
scrollWidth: element.scrollWidth,
|
|
overflow: style.overflow,
|
|
textOverflow: style.textOverflow,
|
|
whiteSpace: style.whiteSpace,
|
|
title: element.getAttribute('title'),
|
|
};
|
|
});
|
|
expect(longNameMetrics.scrollWidth).toBeGreaterThan(longNameMetrics.clientWidth);
|
|
expect(longNameMetrics.overflow).toBe('hidden');
|
|
expect(longNameMetrics.textOverflow).toBe('ellipsis');
|
|
expect(longNameMetrics.whiteSpace).toBe('nowrap');
|
|
expect(longNameMetrics.title).toBe(longGeneralName);
|
|
await expect(bracket.locator('.mobile-bracket-name', { hasText: '관우' })).toHaveCount(1);
|
|
for (const [label, count] of [
|
|
['8강', 8],
|
|
['4강', 4],
|
|
['결승', 2],
|
|
['우승', 1],
|
|
] as const) {
|
|
await page.getByRole('tab', { name: label }).click();
|
|
await expect(page.getByRole('tab', { name: label })).toHaveAttribute('aria-selected', 'true');
|
|
await expect(bracket.locator('.mobile-bracket-name')).toHaveCount(count);
|
|
await expect(bracket.locator('.mobile-bracket-name', { hasText: '관우' })).toHaveCount(1);
|
|
}
|
|
await page.getByRole('tab', { name: '16강' }).click();
|
|
const bounds = await bracket.evaluate((element) => {
|
|
const names = [...element.querySelectorAll<HTMLElement>('.mobile-bracket-name')].map((name) =>
|
|
name.getBoundingClientRect()
|
|
);
|
|
const own = element.getBoundingClientRect();
|
|
return {
|
|
width: own.width,
|
|
minX: Math.min(...names.map((rect) => rect.left - own.left)),
|
|
maxX: Math.max(...names.map((rect) => rect.right - own.left)),
|
|
};
|
|
});
|
|
expect(bounds.width).toBe(390);
|
|
expect(bounds.minX).toBeGreaterThanOrEqual(0);
|
|
expect(bounds.maxX).toBeLessThanOrEqual(390);
|
|
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 {
|
|
iconWidth: icon.width,
|
|
iconHeight: icon.height,
|
|
iconRight: icon.right,
|
|
nameLeft: name.left,
|
|
iconTop: icon.top,
|
|
iconBottom: icon.bottom,
|
|
nameTop: name.top,
|
|
nameBottom: name.bottom,
|
|
};
|
|
});
|
|
expect(identity.iconWidth).toBe(64);
|
|
expect(identity.iconHeight).toBe(64);
|
|
expect(identity.nameLeft).toBeGreaterThanOrEqual(identity.iconRight - 1);
|
|
expect(identity.nameTop).toBeLessThan(identity.iconBottom);
|
|
expect(identity.nameBottom).toBeGreaterThan(identity.iconTop);
|
|
await expect(bracket.locator('.mobile-bracket .bracket-bet-summary')).toHaveCount(0);
|
|
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('tournament and betting pages expose same-row navigation tabs beside close', async ({ page }) => {
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await openTournament(page);
|
|
|
|
const navigation = page.getByRole('tablist', { name: '토너먼트와 베팅장 이동' });
|
|
const tournamentTab = navigation.getByRole('tab', { name: '토너먼트' });
|
|
const bettingTab = navigation.getByRole('tab', { name: '베팅장' });
|
|
const close = page.getByRole('button', { name: '창 닫기' }).first();
|
|
await expect(tournamentTab).toHaveAttribute('aria-selected', 'true');
|
|
|
|
const headerCenters = await Promise.all(
|
|
[tournamentTab, bettingTab, close].map(async (control) => {
|
|
const box = await control.boundingBox();
|
|
return box ? box.y + box.height / 2 : -1;
|
|
})
|
|
);
|
|
expect(Math.max(...headerCenters) - Math.min(...headerCenters)).toBeLessThan(1);
|
|
|
|
await bettingTab.click();
|
|
await expect(page).toHaveURL(/\/betting$/);
|
|
await expect(page.getByRole('tab', { name: '베팅장' })).toHaveAttribute('aria-selected', 'true');
|
|
await page.getByRole('tab', { name: '토너먼트' }).click();
|
|
await expect(page).toHaveURL(/\/tournament$/);
|
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
|
|
});
|
|
|
|
test('betting realtime refresh is shared across tabs and preserves local interaction state', async ({
|
|
page,
|
|
context,
|
|
}) => {
|
|
test.setTimeout(40_000);
|
|
const follower = await context.newPage();
|
|
await Promise.all([
|
|
page.setViewportSize({ width: 390, height: 844 }),
|
|
follower.setViewportSize({ width: 390, height: 844 }),
|
|
]);
|
|
const state = { tournamentStage: 6, totalAmount: 2800 };
|
|
const operations: Array<{ operation: string; grant: string | undefined }> = [];
|
|
const fixtureOptions = {
|
|
realtimeState: state,
|
|
onOperation: (operation: string, headers: Record<string, string>) => {
|
|
operations.push({ operation, grant: headers['x-sammo-realtime-access-grant'] });
|
|
},
|
|
};
|
|
|
|
await Promise.all([installFakeEventSource(page), installFakeEventSource(follower)]);
|
|
await Promise.all([installFixture(page, fixtureOptions), installFixture(follower, fixtureOptions)]);
|
|
await Promise.all([page.goto('betting'), follower.goto('betting')]);
|
|
await Promise.all([
|
|
expect(page.getByRole('tab', { name: '전력전' })).toBeVisible(),
|
|
expect(follower.getByRole('tab', { name: '전력전' })).toBeVisible(),
|
|
]);
|
|
await follower.getByRole('tab', { name: '통솔전' }).click();
|
|
await follower.locator('.mobile-bracket').getByLabel('관우 베팅 금액', { exact: true }).fill('50');
|
|
|
|
await expect
|
|
.poll(async () => {
|
|
const counts = await Promise.all(
|
|
[page, follower].map((candidate) =>
|
|
candidate.evaluate(() =>
|
|
(
|
|
window as unknown as {
|
|
__tournamentEventSourceCount: () => number;
|
|
}
|
|
).__tournamentEventSourceCount()
|
|
)
|
|
)
|
|
);
|
|
return counts.reduce((sum, count) => sum + count, 0);
|
|
})
|
|
.toBe(1);
|
|
const activeSourceUrls = (
|
|
await Promise.all(
|
|
[page, follower].map((candidate) =>
|
|
candidate.evaluate(() =>
|
|
(
|
|
window as unknown as {
|
|
__tournamentEventSourceUrls: () => string[];
|
|
}
|
|
).__tournamentEventSourceUrls()
|
|
)
|
|
)
|
|
)
|
|
).flat();
|
|
expect(activeSourceUrls).toHaveLength(1);
|
|
expect(new URL(activeSourceUrls[0]!).searchParams.get('scope')).toBe('tournament');
|
|
|
|
const before = {
|
|
snapshot: operations.filter(({ operation }) => operation === 'tournament.getSnapshot').length,
|
|
betting: operations.filter(({ operation }) => operation === 'tournament.getBettingSummary').length,
|
|
rankings: operations.filter(({ operation }) => operation === 'tournament.getRankings').length,
|
|
};
|
|
state.totalAmount = 3333;
|
|
const payload = {
|
|
type: 'tournamentViewInvalidated',
|
|
refreshGrant: 'opaque-e2e-grant',
|
|
invalidation: { snapshot: false, betting: true, rankings: false },
|
|
};
|
|
await Promise.all(
|
|
[page, follower].map((candidate) =>
|
|
candidate.evaluate((event) => {
|
|
(
|
|
window as unknown as {
|
|
__emitTournamentEvent: (type: string, payload: unknown) => void;
|
|
}
|
|
).__emitTournamentEvent('tournamentViewInvalidated', event);
|
|
}, payload)
|
|
)
|
|
);
|
|
|
|
await expect
|
|
.poll(() => operations.filter(({ operation }) => operation === 'tournament.getBettingSummary').length)
|
|
.toBe(before.betting + 1);
|
|
await expect(page.locator('.section-title small')).toContainText('전체 금액 : 3333');
|
|
await expect(follower.locator('.section-title small')).toContainText('전체 금액 : 3333');
|
|
await expect(follower.getByRole('tab', { name: '통솔전' })).toHaveAttribute('aria-selected', 'true');
|
|
await expect(follower.locator('.mobile-bracket').getByLabel('관우 베팅 금액', { exact: true })).toHaveValue('50');
|
|
expect(operations.filter(({ operation }) => operation === 'tournament.getSnapshot')).toHaveLength(before.snapshot);
|
|
expect(operations.filter(({ operation }) => operation === 'tournament.getRankings')).toHaveLength(before.rankings);
|
|
expect(
|
|
operations.filter(
|
|
({ operation, grant }) => operation === 'tournament.getBettingSummary' && grant === 'opaque-e2e-grant'
|
|
)
|
|
).toHaveLength(1);
|
|
|
|
const recoveryBefore = {
|
|
snapshot: operations.filter(({ operation }) => operation === 'tournament.getSnapshot').length,
|
|
betting: operations.filter(({ operation }) => operation === 'tournament.getBettingSummary').length,
|
|
rankings: operations.filter(({ operation }) => operation === 'tournament.getRankings').length,
|
|
};
|
|
for (const visibilityState of ['hidden', 'visible'] as const) {
|
|
await Promise.all(
|
|
[page, follower].map((candidate) =>
|
|
candidate.evaluate((nextVisibilityState) => {
|
|
Object.defineProperty(document, 'visibilityState', {
|
|
configurable: true,
|
|
value: nextVisibilityState,
|
|
});
|
|
document.dispatchEvent(new Event('visibilitychange'));
|
|
}, visibilityState)
|
|
)
|
|
);
|
|
}
|
|
await expect
|
|
.poll(() => operations.filter(({ operation }) => operation === 'tournament.getSnapshot').length)
|
|
.toBe(recoveryBefore.snapshot + 1);
|
|
expect(operations.filter(({ operation }) => operation === 'tournament.getBettingSummary')).toHaveLength(
|
|
recoveryBefore.betting + 1
|
|
);
|
|
expect(operations.filter(({ operation }) => operation === 'tournament.getRankings')).toHaveLength(
|
|
recoveryBefore.rankings + 1
|
|
);
|
|
await expect(follower.locator('.mobile-bracket').getByLabel('관우 베팅 금액', { exact: true })).toHaveValue('50');
|
|
});
|
|
|
|
test('tournament and betting close only their script-opened popup window', async ({ page }, testInfo) => {
|
|
const baseURL = testInfo.project.use.baseURL;
|
|
expect(typeof baseURL).toBe('string');
|
|
await page.goto('about:blank');
|
|
|
|
for (const route of ['tournament', 'betting'] as const) {
|
|
const popupPromise = page.waitForEvent('popup');
|
|
await page.evaluate(() => window.open('about:blank', '_blank', 'noopener'));
|
|
const popup = await popupPromise;
|
|
await installFixture(popup, { tournamentStage: route === 'betting' ? 6 : 1 });
|
|
await popup.goto(new URL(route, baseURL as string).href);
|
|
|
|
await expect(popup.getByRole('button', { name: '창 닫기' }).first()).toBeVisible();
|
|
expect(await popup.evaluate(() => window.opener)).toBeNull();
|
|
|
|
const closed = popup.waitForEvent('close');
|
|
await popup.getByRole('button', { name: '창 닫기' }).first().click();
|
|
await closed;
|
|
|
|
expect(page.isClosed()).toBe(false);
|
|
expect(page.url()).toBe('about:blank');
|
|
}
|
|
});
|
|
|
|
test('betting bracket shows intelligence for debate tournament candidates', async ({ page }) => {
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await installFixture(page, { tournamentType: 3, tournamentStage: 6 });
|
|
await page.goto('betting');
|
|
|
|
await expect(page.locator('.betting-bracket .bracket-core-stat').first()).toHaveText('지력 80');
|
|
await expect(page.locator('.betting-bracket .bracket-odds').first()).toHaveText('배당 28.00');
|
|
await expect(page.locator('.betting-bracket .bracket-my-bet').first()).toHaveText('내 투자 금120');
|
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
|
|
});
|
|
|
|
for (const width of [1365, 1101, 800, 390, 320]) {
|
|
test(`inline individual betting needs exactly 16 submissions at ${width}px`, async ({ page }, testInfo) => {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
const { placedBets } = await installFixture(page, { tournamentStage: 6 });
|
|
await page.goto('betting');
|
|
const bracket = page.locator(width > 1100 ? '.desktop-bracket' : '.mobile-bracket');
|
|
const cards = bracket.locator('.betting-candidate');
|
|
await expect(cards).toHaveCount(16);
|
|
await expect(page.locator('dialog')).toHaveCount(0);
|
|
await page.evaluate(() => document.fonts.ready);
|
|
const geometry = await cards.evaluateAll((elements) =>
|
|
elements.map((element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
const selectors = [
|
|
'.general-identity-icon',
|
|
'.general-identity-name',
|
|
'.bracket-core-stat',
|
|
'.bracket-odds',
|
|
'.bracket-my-bet',
|
|
'.bracket-return',
|
|
'input',
|
|
'select',
|
|
'.bracket-bet-button',
|
|
];
|
|
const fields = selectors.map((selector) => {
|
|
const el = element.querySelector<HTMLElement>(selector)!;
|
|
const style = getComputedStyle(el);
|
|
return {
|
|
selector,
|
|
rect: el.getBoundingClientRect().toJSON(),
|
|
font: style.font,
|
|
fontSize: Number.parseFloat(style.fontSize),
|
|
color: style.color,
|
|
scrollWidth: el.scrollWidth,
|
|
clientWidth: el.clientWidth,
|
|
};
|
|
});
|
|
return { rect: rect.toJSON(), fields };
|
|
})
|
|
);
|
|
for (const card of geometry) {
|
|
const iconRect = card.fields.find((field) => field.selector === '.general-identity-icon')!.rect;
|
|
expect(iconRect.width).toBe(width > 1100 ? 64 : 40);
|
|
expect(iconRect.height).toBe(iconRect.width);
|
|
for (const field of card.fields) {
|
|
if (
|
|
[
|
|
'.general-identity-name',
|
|
'.bracket-core-stat',
|
|
'.bracket-odds',
|
|
'.bracket-my-bet',
|
|
'.bracket-return',
|
|
].includes(field.selector)
|
|
) {
|
|
expect(field.rect.left).toBeGreaterThanOrEqual(iconRect.right + 5);
|
|
expect(field.fontSize).toBeGreaterThanOrEqual(width > 1100 ? 14 : 13);
|
|
}
|
|
expect(field.rect.left).toBeGreaterThanOrEqual(card.rect.left - 1);
|
|
expect(field.rect.right).toBeLessThanOrEqual(card.rect.right + 1);
|
|
expect(field.rect.top).toBeGreaterThanOrEqual(card.rect.top - 1);
|
|
expect(field.rect.bottom).toBeLessThanOrEqual(card.rect.bottom + 1);
|
|
expect(field.scrollWidth).toBeLessThanOrEqual(field.clientWidth + 1);
|
|
if (['input', 'select', '.bracket-bet-button'].includes(field.selector))
|
|
expect(field.rect.height).toBeGreaterThanOrEqual(44);
|
|
}
|
|
for (let index = 0; index < card.fields.length; index++) {
|
|
const a = card.fields[index].rect;
|
|
for (const field of card.fields.slice(index + 1)) {
|
|
const b = field.rect;
|
|
expect(
|
|
Math.min(a.right, b.right) > Math.max(a.left, b.left) + 1 &&
|
|
Math.min(a.bottom, b.bottom) > Math.max(a.top, b.top) + 1
|
|
).toBe(false);
|
|
}
|
|
}
|
|
}
|
|
if (width > 1100) {
|
|
const containment = await bracket.evaluate((element) => {
|
|
const bounds = element.getBoundingClientRect();
|
|
return [...element.querySelectorAll('.desktop-bracket-name')].every((card) => {
|
|
const rect = card.getBoundingClientRect();
|
|
return rect.left >= bounds.left - 1 && rect.right <= bounds.right + 1;
|
|
});
|
|
});
|
|
expect(containment).toBe(true);
|
|
expect(geometry[0].rect.width).toBeGreaterThan(300);
|
|
for (let index = 1; index < geometry.length; index++)
|
|
expect(geometry[index].rect.top).toBeGreaterThanOrEqual(geometry[index - 1].rect.bottom);
|
|
}
|
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
|
|
await writeFile(testInfo.outputPath('inline-geometry.json'), JSON.stringify(geometry, null, 2));
|
|
await writeFile(
|
|
testInfo.outputPath('inline-dom.html'),
|
|
await page.locator('#tournament-betting-container').evaluate((el) => el.outerHTML)
|
|
);
|
|
await page.screenshot({ path: testInfo.outputPath('inline-before.png'), fullPage: true });
|
|
await page.screenshot({ path: testInfo.outputPath('inline-viewport.png') });
|
|
const candidateImage = cards.first().locator('.general-identity-icon');
|
|
await expect(candidateImage).toBeVisible();
|
|
const icon = await candidateImage.evaluate((image: HTMLImageElement) => ({
|
|
rect: image.getBoundingClientRect().toJSON(),
|
|
naturalWidth: image.naturalWidth,
|
|
naturalHeight: image.naturalHeight,
|
|
objectFit: getComputedStyle(image).objectFit,
|
|
}));
|
|
expect(icon.naturalWidth).toBeGreaterThan(0);
|
|
expect(icon.naturalHeight).toBeGreaterThan(0);
|
|
expect(icon.objectFit).toBe('cover');
|
|
await writeFile(testInfo.outputPath('inline-icon.json'), JSON.stringify(icon, null, 2));
|
|
const buttons = bracket.locator('.bracket-bet-button');
|
|
const firstButton = buttons.first();
|
|
await firstButton.hover();
|
|
await expect(firstButton).toHaveCSS('filter', 'brightness(1.25)');
|
|
await firstButton.focus();
|
|
await expect(firstButton).toBeFocused();
|
|
// One click per candidate; no target-selection, dialog, confirmation or batch action.
|
|
for (let index = 0; index < 16; index++) {
|
|
await buttons.nth(index).click();
|
|
await expect.poll(() => placedBets.length).toBe(index + 1);
|
|
await expect(cards.nth(index).getByRole('status')).toHaveText('10금 베팅 완료');
|
|
}
|
|
expect(placedBets).toEqual(geometry.map((_, index) => ({ targetId: index + 1, amount: 10 })));
|
|
await expect(cards.first().locator('.bracket-my-bet')).toHaveText('내 투자 금130');
|
|
await expect(cards.first().locator('.bracket-odds')).toHaveText('배당 26.91');
|
|
await expect(cards.first().locator('.bracket-return')).toHaveText('예상 환수 금3,498');
|
|
await expect(page.getByText('남은 한도 680금')).toBeVisible();
|
|
await page.screenshot({ path: testInfo.outputPath('inline-after.png'), fullPage: true });
|
|
});
|
|
}
|
|
|
|
for (const width of [1365, 390]) {
|
|
test(`inline amount presets, defaults, errors and repeated bets at ${width}px`, async ({ page }, testInfo) => {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
const betFailure = { message: null as string | null };
|
|
const { placedBets } = await installFixture(page, { tournamentStage: 6, betFailure, betDelayMs: 200 });
|
|
await page.goto('betting');
|
|
const bracket = page.locator(width > 1100 ? '.desktop-bracket' : '.mobile-bracket');
|
|
const first = bracket.locator('.betting-candidate').first();
|
|
const second = bracket.locator('.betting-candidate').nth(1);
|
|
const input = first.getByRole('spinbutton');
|
|
await expect(input).toHaveValue('10');
|
|
await expect(page.locator('input[type=number]:visible')).toHaveCount(16);
|
|
await expect(page.getByRole('region', { name: '베팅 한도' }).getByRole('spinbutton')).toHaveCount(0);
|
|
await first.getByRole('combobox').selectOption('50');
|
|
await expect(input).toHaveValue('50');
|
|
await expect(second.getByRole('spinbutton')).toHaveValue('10');
|
|
await input.fill('37');
|
|
await second.getByRole('spinbutton').fill('23');
|
|
await page.getByRole('button', { name: '갱신', exact: true }).click();
|
|
await expect(input).toHaveValue('37');
|
|
await expect(second.getByRole('spinbutton')).toHaveValue('23');
|
|
for (const invalid of ['', '9', '10.5', '841']) {
|
|
await input.fill(invalid);
|
|
await expect(first.getByRole('button', { name: '관우에게 베팅하기' })).toBeDisabled();
|
|
}
|
|
expect(placedBets).toHaveLength(0);
|
|
await input.fill('37');
|
|
betFailure.message = '금이 부족합니다.';
|
|
await first.getByRole('button').click();
|
|
await expect(first.getByRole('status')).toHaveText('금이 부족합니다.');
|
|
await expect(input).toHaveValue('37');
|
|
expect(placedBets).toHaveLength(0);
|
|
await page.screenshot({ path: testInfo.outputPath('inline-error.png') });
|
|
betFailure.message = null;
|
|
await first.getByRole('button').click();
|
|
await expect(first.getByRole('button')).toBeDisabled();
|
|
await first.getByRole('button').dispatchEvent('click');
|
|
await expect(second.getByRole('button')).toBeEnabled();
|
|
await expect(first.getByRole('status')).toHaveText('37금 베팅 완료');
|
|
await expect(first.getByRole('button')).toBeEnabled();
|
|
await first.getByRole('button').click();
|
|
await expect.poll(() => placedBets.length).toBe(2);
|
|
expect(placedBets).toEqual([
|
|
{ targetId: 1, amount: 37 },
|
|
{ targetId: 1, amount: 37 },
|
|
]);
|
|
await expect(first.locator('.bracket-my-bet')).toHaveText('내 투자 금194');
|
|
await second.getByRole('spinbutton').fill('766');
|
|
await second.getByRole('button').click();
|
|
await expect(page.getByText('남은 한도 0금')).toBeVisible();
|
|
await expect(bracket.locator('.bracket-bet-button:enabled')).toHaveCount(0);
|
|
});
|
|
}
|
|
|
|
test('closed betting keeps investment and return visible without submit controls', async ({ page }) => {
|
|
await installFixture(page, { tournamentStage: 7 });
|
|
await page.goto('betting');
|
|
await expect(page.locator('.inline-bet')).toHaveCount(0);
|
|
await expect(page.locator('.desktop-bracket .bracket-return').first()).toHaveText('예상 환수 금3,360');
|
|
});
|