Merge remote-tracking branch 'origin/main' into feature/recruitment-command-parity-20260813

# Conflicts:
#	app/game-api/src/router/turns/index.ts
#	app/game-api/src/turns/commandInput.ts
#	app/game-frontend/e2e/commandArguments.spec.ts
#	app/game-frontend/src/components/command/ReservedCommandEditor.vue
#	app/game-frontend/src/components/command/types.ts
#	app/game-frontend/src/views/ChiefCenterView.vue
This commit is contained in:
2026-08-13 16:28:35 +00:00
83 changed files with 4463 additions and 1050 deletions
+21 -5
View File
@@ -274,6 +274,8 @@ test('matches the ref meeting-room geometry, typography, textures, and controls'
'src',
'https://sam-image.hided.net/icons/22.jpg'
);
await expect(page.locator('.article-header .date')).toHaveText('07-26 19:20');
await expect(page.locator('.comment-row .date')).toHaveText('07-26 19:25');
if (artifactRoot) {
await page.screenshot({
path: resolve(artifactRoot, 'board-core-desktop.png'),
@@ -392,7 +394,9 @@ test('uses the ref 500px responsive form widths', async ({ page }) => {
await expect(page.getByRole('heading', { name: '기밀실' })).toBeVisible();
});
test('retains article and comment input after a failed mutation, then reloads after success', async ({ page }, testInfo) => {
test('retains article and comment input after a failed mutation, then reloads after success', async ({
page,
}, testInfo) => {
const state: BoardFixture = {
permission: 2,
canMeeting: true,
@@ -408,7 +412,9 @@ test('retains article and comment input after a failed mutation, then reloads af
await page.locator('#board-title').fill('새 제목');
await page.locator('#board-content').fill('새 내용');
await page.locator('#submitArticle').click();
const articleToast = page.getByTestId('game-toast').filter({ hasText: '게시물 등록에 실패했습니다: 접속 제한입니다.' });
const articleToast = page
.getByTestId('game-toast')
.filter({ hasText: '게시물 등록에 실패했습니다: 접속 제한입니다.' });
await expect(articleToast).toHaveAttribute('data-feedback-kind', 'error');
await expect(articleToast).toHaveAttribute('role', 'alert');
await expect(page.locator('#board-title')).toHaveValue('새 제목');
@@ -416,7 +422,13 @@ test('retains article and comment input after a failed mutation, then reloads af
const desktopToastGeometry = await articleToast.evaluate((element) => {
const rect = element.getBoundingClientRect();
return { left: rect.left, right: rect.right, top: rect.top, width: rect.width, viewportWidth: window.innerWidth };
return {
left: rect.left,
right: rect.right,
top: rect.top,
width: rect.width,
viewportWidth: window.innerWidth,
};
});
expect(desktopToastGeometry.left).toBeGreaterThanOrEqual(0);
expect(desktopToastGeometry.right).toBeLessThanOrEqual(desktopToastGeometry.viewportWidth);
@@ -435,10 +447,14 @@ test('retains article and comment input after a failed mutation, then reloads af
await page.setViewportSize({ width: 390, height: 844 });
const documentWidthBeforeToast = await page.evaluate(() => document.documentElement.scrollWidth);
await commentInput.press('Enter');
const commentToast = page.getByTestId('game-toast').filter({ hasText: '댓글 등록에 실패했습니다: 접속 제한입니다.' });
const commentToast = page
.getByTestId('game-toast')
.filter({ hasText: '댓글 등록에 실패했습니다: 접속 제한입니다.' });
await expect(commentToast).toBeVisible();
await expect
.poll(async () => commentToast.evaluate((element) => window.innerHeight - element.getBoundingClientRect().bottom))
.poll(async () =>
commentToast.evaluate((element) => window.innerHeight - element.getBoundingClientRect().bottom)
)
.toBeGreaterThanOrEqual(0);
const mobileToastGeometry = await commentToast.evaluate((element) => {
const rect = element.getBoundingClientRect();
+156 -9
View File
@@ -15,11 +15,11 @@ const operations = (route: Route) =>
const inputOptions = {
cities: [
{ value: 1, label: '업 (아국)' },
{ value: 2, label: '허창 (적국)' },
{ value: 2, label: '허창 (적국)', description: '적국 · 예주 · 대도시' },
],
nations: [
{ value: 1, label: '아국', color: '#008000' },
{ value: 2, label: '적국', color: '#800000' },
{ value: 2, label: '적국', color: '#800000', description: '수도 허창' },
],
generals: [
{ value: 1, label: '장수 (아국 · 업)' },
@@ -75,6 +75,14 @@ const inputOptions = {
},
],
},
context: {
actorGold: 1000,
actorRice: 1000,
citySecurity: 500,
nationGold: 5000,
nationRice: 6000,
nationLevel: 1,
},
};
const commandTable = {
general: [
@@ -152,6 +160,27 @@ const commandTable = {
},
],
},
{
category: '외교',
values: [
{
key: 'che_선전포고',
name: '선전포고',
reqArg: true,
possible: true,
status: 'needsInput',
inputFields: [
{
key: 'destNationId',
label: '대상 국가',
kind: 'select',
required: true,
optionSource: 'nations',
},
],
},
],
},
],
inputOptions,
};
@@ -177,8 +206,46 @@ const generalContext = {
dedication: 0,
items: { horse: 'None', weapon: 'None', book: 'None', item: 'None' },
},
city: { id: 1, name: '업', level: 8, region: 1, population: 1000, populationMax: 2000 },
nation: { id: 1, name: '아국', color: '#008000', level: 1 },
city: {
id: 1,
name: '업',
level: 8,
levelName: '특',
region: 1,
regionName: '하북',
nationId: 1,
nationName: '아국',
population: 1000,
populationMax: 2000,
agriculture: 100,
agricultureMax: 200,
commerce: 100,
commerceMax: 200,
security: 100,
securityMax: 200,
trust: 70,
trade: 100,
defence: 100,
defenceMax: 200,
wall: 100,
wallMax: 200,
supplyState: 1,
frontState: 0,
},
nation: {
id: 1,
name: '아국',
color: '#008000',
level: 1,
levelName: '호족',
gold: 5000,
rice: 6000,
tech: 100,
typeCode: 'che_중립',
typeName: '중립',
capitalCityId: 1,
capitalCityName: '업',
},
settings: {},
penalties: {},
};
@@ -247,8 +314,11 @@ const install = async (page: Page, rejectGeneral = false) => {
if (name === 'world.getMapLayout')
return response({
mapName: 'che',
cityList: [{ id: 1, name: '업', level: 8, region: 1, x: 100, y: 100, path: [] }],
regionMap: { 1: '하북' },
cityList: [
{ id: 1, name: '업', level: 8, region: 1, x: 100, y: 100, path: [2] },
{ id: 2, name: '허창', level: 7, region: 2, x: 240, y: 180, path: [1] },
],
regionMap: { 1: '하북', 2: '예주' },
levelMap: { 8: '특' },
});
if (name === 'auth.status') return response({ ok: true });
@@ -271,8 +341,14 @@ const install = async (page: Page, rejectGeneral = false) => {
startYear: 180,
year: 200,
month: 1,
cityList: [[1, 8, 0, 1, 1, 1]],
nationList: [[1, '아국', '#008000', 1]],
cityList: [
[1, 8, 0, 1, 1, 1],
[2, 7, 40, 2, 2, 1],
],
nationList: [
[1, '아국', '#008000', 1],
[2, '적국', '#800000', 2],
],
spyList: {},
shownByGeneralList: [],
myCity: 1,
@@ -341,13 +417,35 @@ const install = async (page: Page, rejectGeneral = false) => {
test('enters general and nation command arguments and sends exact values', async ({ page }) => {
const requests = await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('/');
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
await page.getByTestId('command-picker').getByRole('button', { name: /화계/ }).click();
const form = page.getByTestId('command-argument-form');
await expect(form).toBeVisible();
await form.locator('select').selectOption('2');
await expect(form.getByTestId('command-argument-map')).toBeVisible();
await expect(form.getByTestId('command-argument-guidance')).toContainText('선택한 도시에 화계를 실행합니다.');
await expect(form.getByTestId('command-map-target-summary')).toContainText('현재 도시에서 0칸');
await form.getByTestId('command-argument-map').locator('.map-city').nth(1).click();
await expect(form.locator('select')).toHaveValue('2');
await expect(form.getByTestId('command-map-target-summary')).toContainText('현재 도시에서 1칸');
await form.getByTestId('command-argument-map').locator('.map-city').nth(1).hover();
expect(
await form
.getByTestId('command-argument-map')
.locator('.map-city')
.nth(1)
.evaluate((element) => getComputedStyle(element).cursor)
).toBe('pointer');
await form.getByTestId('command-argument-map').locator('.map-city').nth(1).focus();
await expect(form.getByTestId('command-argument-map').locator('.map-city').nth(1)).toBeFocused();
await expect(page).toHaveURL(/\/$/);
const mapGeometry = await form.getByTestId('command-argument-map').evaluate((element) => {
const area = element.querySelector<HTMLElement>('.map-area')!;
const rect = area.getBoundingClientRect();
return { width: rect.width, height: rect.height };
});
await page.getByTestId('command-picker').getByRole('button', { name: '입력', exact: true }).click();
await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText('화계');
@@ -379,6 +477,9 @@ test('enters general and nation command arguments and sends exact values', async
expect(JSON.stringify(requests)).toContain('"amount":300');
expect(JSON.stringify(requests)).toContain('"destGeneralId":2');
expect(mapGeometry.width).toBeGreaterThan(650);
expect(mapGeometry.height / mapGeometry.width).toBeCloseTo(5 / 7, 2);
expect(geometry.width).toBeGreaterThan(200);
expect(geometry.rowHeight).toBeGreaterThanOrEqual(34);
expect(geometry.borderStyle).toBe('solid');
@@ -493,6 +594,52 @@ test('shows Ref recruitment details and preserves the 1000px desktop and 500px m
await expect(mercenaryForm.locator('.mobile-selected-panel output')).toHaveText('1,346금');
});
test('uses the map to choose a nation target in the chief command window', async ({ page }) => {
await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('/che/chief-center');
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
const picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: /^(?:국가:)?외교$/, exact: true }).click();
await picker.getByRole('button', { name: /선전포고/ }).click();
const form = picker.getByTestId('command-argument-form');
await expect(form.getByTestId('command-argument-guidance')).toContainText('초반 제한');
await form.getByTestId('command-argument-map').locator('.map-city').nth(1).click();
await expect(form.locator('select')).toHaveValue('2');
await expect(form.getByTestId('command-map-target-summary')).toContainText('수도 허창 · 도시 1개');
await expect(page).toHaveURL(/\/che\/chief-center$/);
await page.screenshot({ path: test.info().outputPath('chief-nation-map-option.png'), fullPage: true });
});
test('fits the city map option window inside the Ref-compatible 500px mobile page', async ({ page }) => {
await install(page);
await page.setViewportSize({ width: 500, height: 900 });
await page.goto('/');
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
const picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: /화계/ }).click();
const geometry = await picker.evaluate((element) => {
const map = element.querySelector<HTMLElement>('[data-testid="command-argument-map"] .map-area')!;
const pickerRect = element.getBoundingClientRect();
const mapRect = map.getBoundingClientRect();
return {
pickerX: pickerRect.x,
pickerRight: pickerRect.right,
pickerWidth: pickerRect.width,
pickerScrollWidth: element.scrollWidth,
mapWidth: mapRect.width,
mapHeight: mapRect.height,
};
});
expect(geometry.pickerX).toBeGreaterThanOrEqual(0);
expect(geometry.pickerRight).toBeLessThanOrEqual(500);
expect(geometry.pickerWidth).toBeGreaterThanOrEqual(488);
expect(geometry.pickerScrollWidth).toBeLessThanOrEqual(geometry.pickerWidth);
expect(geometry.mapWidth).toBeGreaterThan(470);
expect(geometry.mapHeight / geometry.mapWidth).toBeCloseTo(5 / 7, 2);
await page.screenshot({ path: test.info().outputPath('main-city-map-option-mobile.png'), fullPage: true });
});
test('keeps the entered command visible and reports a server validation error', async ({ page }) => {
await install(page, true);
await page.goto('/');
+63 -14
View File
@@ -1,6 +1,6 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { basename, resolve } from 'node:path';
import { expect, test, type Page, type Route } from '@playwright/test';
import { expect, test, type Locator, type Page, type Route } from '@playwright/test';
import { gameBasePath, gameProfile, gameTrpcRoute } from './gameTestPaths.js';
const response = (data: unknown) => ({ result: { data } });
@@ -20,6 +20,23 @@ const persistParityArtifact = async (page: Page, name: string, geometry: unknown
]);
};
const readGeneralPanelImages = async (panel: Locator) =>
panel.evaluate((element) =>
[...element.querySelectorAll<HTMLElement>('.general-image')].map((image) => {
const rect = image.getBoundingClientRect();
const style = getComputedStyle(image);
return {
label: image.getAttribute('aria-label'),
width: rect.width,
height: rect.height,
backgroundImage: style.backgroundImage,
backgroundSize: style.backgroundSize,
pointerEvents: style.pointerEvents,
userSelect: style.userSelect,
};
})
);
type FixtureState = {
permission: 'head' | 'member';
myset: number;
@@ -545,9 +562,15 @@ test('재야 메인은 국가 틀과 성격·특기 표기명을 Chromium에 표
const generalCard = page.locator('.general-card');
await expect(generalCard).toContainText('성격안전');
await expect(generalCard).toContainText('전투특기신산');
await expect(generalCard).toContainText('내정특기상재');
await expect(generalCard).toContainText('특기상재 / 신산');
await expect(generalCard).not.toContainText('che_');
await expect(generalCard).toHaveAttribute('data-general-basic-card', '');
const mainImages = await readGeneralPanelImages(generalCard);
expect(mainImages).toHaveLength(2);
expect(mainImages[0]).toMatchObject({ width: 64, height: 64, pointerEvents: 'none', userSelect: 'none' });
expect(mainImages[0]?.backgroundImage).toContain('/icons/default.jpg');
expect(mainImages[1]).toMatchObject({ width: 64, height: 64, pointerEvents: 'none', userSelect: 'none' });
expect(mainImages[1]?.backgroundImage).toContain('/game/crewtype1.png');
const geometry = await nationCard.evaluate((element) => {
const rect = element.getBoundingClientRect();
@@ -587,9 +610,9 @@ test('메인 카드의 국가·수도·관직·계급·병종은 Ref 출력명
await expect(nationCard).toContainText('국가 등급주자사');
const generalCard = page.locator('.general-card');
await expect(generalCard.locator('.general-title')).toContainText('검증장수 · 간의대부');
await expect(generalCard.locator('.general-title')).toContainText('검증장수 간의대부 | 건강 】');
await expect(generalCard).toContainText('병종보병');
await expect(generalCard).toContainText('계급29품관');
await expect(generalCard).toContainText('계급 29품관');
const cityCard = page.locator('.city-card');
await expect(cityCard.locator('.title')).toContainText('【중원 | 특】 업');
@@ -646,11 +669,19 @@ 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 });
await page.goto('my-page');
await expect(page.locator('.general-table')).toHaveAttribute('data-general-basic-card', '');
const myPageImages = await readGeneralPanelImages(page.locator('.general-table'));
expect(myPageImages.map(({ width, height }) => ({ width, height }))).toEqual([
{ width: 64, height: 64 },
{ width: 64, height: 64 },
]);
expect(myPageImages[0]?.backgroundImage).toContain('/icons/default.jpg');
expect(myPageImages[1]?.backgroundImage).toContain('/game/crewtype1.png');
await expect(page.locator('.legacy-general-details')).toContainText('계급 29품관');
await expect(page.locator('.legacy-general-details')).toContainText('병종 보병');
await expect(page.locator('.item-group')).toContainText('명마');
@@ -696,7 +727,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 +797,39 @@ 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>('[data-general-basic-card] .general-icon')!.getBoundingClientRect();
const name = element.querySelector<HTMLElement>('[data-general-basic-card] .general-title')!.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,
iconTop: icon.top,
iconBottom: icon.bottom,
nameTop: name.top,
nameBottom: name.bottom,
},
};
});
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 - 1);
expect(mobile.identity.nameTop).toBeLessThan(mobile.identity.iconBottom);
expect(mobile.identity.nameBottom).toBeGreaterThan(mobile.identity.iconTop);
await persistParityArtifact(page, 'core-my-page-mobile', mobile);
});
@@ -1109,10 +1153,15 @@ test('감찰부 keeps the selector interaction and shows the permission error pa
await expect(page.locator('.selector-row select').nth(1)).toHaveValue('8');
await page.getByRole('button', { name: '다음 ▶' }).click();
await expect(page.locator('.selector-row select').nth(1)).toHaveValue('7');
await expect(page.locator('.battle-general-name')).toContainText('검증장수 (간의대부)');
await expect(page.locator('.battle-general-name')).toContainText('검증장수 간의대부 | 건강 】');
await expect(page.locator('.battle-general-extra')).toContainText('계급29품관');
await expect(page.locator('.battle-general-extra')).toContainText('병종보병');
await expect(page.locator('.battle-general-card')).toContainText('병종보병');
await expect(page.locator('.battle-general-card')).not.toContainText('che_');
await expect(page.locator('.battle-general-card')).toHaveAttribute('data-general-basic-card', '');
const battleImages = await readGeneralPanelImages(page.locator('.battle-general-card'));
expect(battleImages).toHaveLength(2);
expect(battleImages[0]?.backgroundImage).toContain('/icons/default.jpg');
expect(battleImages[1]?.backgroundImage).toContain('/game/crewtype1.png');
await expect(page.locator('.battle-general-card [role="progressbar"]')).toHaveCount(14);
await expect(page.locator('.battle-general-card [aria-label*="1,275,975 (EX+)"]')).toHaveCount(5);
expect(
+240 -3
View File
@@ -34,6 +34,8 @@ type NavigationFixture = {
largeCommandTable?: boolean;
currentYear?: number;
currentMonth?: number;
scenarioTitle?: string;
latestVote?: { id: number; title: string; hasVoted: boolean } | null;
globalRecords?: Array<{ id: number; text: string }>;
generalRecords?: Array<{ id: number; text: string }>;
worldHistory?: Array<{ id: number; text: string }>;
@@ -309,6 +311,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
year: state.currentYear ?? 185,
month: state.currentMonth ?? 1,
turnTerm: 10,
scenarioTitle: state.scenarioTitle ?? '',
});
}
if (operation === 'dashboard.getContextBundleDelta') {
@@ -421,7 +424,10 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
onlineGenerals: '메뉴검증장수',
nationNotice: '<p>국가 방침</p>',
lastExecuted: null,
latestVote: { id: 9, title: '메뉴 설문', hasVoted: false },
latestVote:
state.latestVote === undefined
? { id: 9, title: '메뉴 설문', hasVoted: false }
: state.latestVote,
});
}
if (operation === 'board.getAccess') {
@@ -508,7 +514,7 @@ const installRealtimeHarness = async (page: Page) => {
const waitForMain = async (page: Page) => {
await page.goto('./');
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
await expect(page.locator('.game-shell__title')).toBeVisible();
await expect(page.locator('.main-global-menu').first()).toBeVisible();
await expect(page.locator('.main-nation-menu')).toBeVisible();
await expect(page.locator('[data-navigation-id="npc-list"]')).toHaveCount(3);
@@ -561,8 +567,24 @@ const persistArtifact = async (page: Page, name: string) => {
globalPopup: describe('#mobile-global-menu'),
nationPopup: describe('#mobile-nation-menu'),
quickPopup: describe('#mobile-quick-menu'),
commandMenu: describe('.reserved-command-editor details[open] .menu-items'),
commandDividers: [...document.querySelectorAll<HTMLElement>('.reserved-command-editor details[open] .menu-divider')].map(
(element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
borderTop: style.borderTop,
margin: style.margin,
};
}
),
};
});
const commandMenu = page.locator('.reserved-command-editor details[open] .menu-items').first();
if (await commandMenu.isVisible()) {
await commandMenu.screenshot({ path: resolve(target, `${name}-menu.png`) });
}
await Promise.all([
page.screenshot({ path: resolve(target, `${name}.png`), fullPage: true }),
writeFile(resolve(target, `${name}.json`), `${JSON.stringify(geometry, null, 2)}\n`),
@@ -576,6 +598,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
nationLevel: 3,
stage: 1,
npcMode: 1,
scenarioTitle: '메인 화면 검증 시나리오',
generalMeCalls: 0,
operations: [],
};
@@ -589,6 +612,45 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
await expect(page.locator('.main-mobile-bottom')).toBeHidden();
await expect(page.locator('.layout-desktop')).toBeVisible();
await expect(page.locator('.layout-mobile')).toHaveCount(0);
await expect(page.getByRole('heading', { name: '메인 화면 검증 시나리오', exact: true })).toHaveCount(1);
await expect(page.locator('.game-shell__subtitle')).toHaveText('185년 1월 · 턴 10분');
await expect(page.locator('.game-shell__subtitle')).not.toContainText('메인 화면 검증 시나리오');
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 참가 모집중');
await expect(page.locator('.vote-status')).toHaveText('설문: 메뉴 설문');
const headerStatusGeometry = await page.locator('.main-page').evaluate((element) => {
const title = element.querySelector<HTMLElement>('.game-shell__title');
const subtitle = element.querySelector<HTMLElement>('.game-shell__subtitle');
const activity = element.querySelector<HTMLElement>('.activity-status');
const tournament = element.querySelector<HTMLElement>('.tournament-status');
const survey = element.querySelector<HTMLElement>('.vote-status');
if (!title || !subtitle || !activity || !tournament || !survey) {
throw new Error('main header status geometry is incomplete');
}
return {
title: title.getBoundingClientRect().toJSON(),
subtitle: subtitle.getBoundingClientRect().toJSON(),
activity: activity.getBoundingClientRect().toJSON(),
tournament: tournament.getBoundingClientRect().toJSON(),
survey: survey.getBoundingClientRect().toJSON(),
activityColumns: getComputedStyle(activity).gridTemplateColumns,
};
});
expect(headerStatusGeometry.subtitle.y).toBeGreaterThanOrEqual(headerStatusGeometry.title.bottom);
expect(headerStatusGeometry.activity.width).toBeCloseTo(666.67, 0);
expect(headerStatusGeometry.tournament.width).toBeCloseTo(333.33, 0);
expect(headerStatusGeometry.survey.width).toBeCloseTo(333.33, 0);
expect(headerStatusGeometry.activityColumns.split(' ')).toHaveLength(2);
const tournamentStatusLink = page.locator('.tournament-status a');
const surveyStatusLink = page.locator('.vote-status a');
await tournamentStatusLink.hover();
await expect
.poll(() => tournamentStatusLink.evaluate((element) => getComputedStyle(element).cursor))
.toBe('pointer');
await surveyStatusLink.focus();
await expect(surveyStatusLink).toBeFocused();
await expect
.poll(() => surveyStatusLink.evaluate((element) => getComputedStyle(element).textDecorationLine))
.toContain('underline');
const contentOrder = await page
.locator('.record-zone, [data-menu-position="middle"], .desktop-message-panel, [data-menu-position="bottom"]')
.evaluateAll((elements) =>
@@ -642,7 +704,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
await expect(gameInfoButton).toBeFocused();
await gameInfoButton.click();
await page.getByRole('heading', { name: '전장 현황' }).click();
await page.getByRole('heading', { name: '메인 화면 검증 시나리오' }).click();
await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'false');
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
});
@@ -882,6 +944,10 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
await tenthTurnButton.click();
const quickPicker = page.getByTestId('command-picker');
await expect(quickPicker).toBeVisible();
await tenthTurnButton.click();
await expect(quickPicker).toBeHidden();
await tenthTurnButton.click();
await expect(quickPicker).toBeVisible();
const quickPickerAlignment = await quickPicker.evaluate((element) => {
const row = element
.closest('.reserved-command-editor')
@@ -923,6 +989,30 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
expect(advancedControlGeometry.rangeTop).toBe(advancedControlGeometry.recentTop);
expect(advancedControlGeometry.advancedTop).toBeGreaterThan(advancedControlGeometry.rangeTop);
expect(advancedControlGeometry.advancedBottom).toBeLessThanOrEqual(advancedControlGeometry.queueTop);
const rangeMenu = page.locator('[data-main-target="commands"] .range-menu');
await rangeMenu.locator('summary').click();
const rangeDividers = rangeMenu.locator('.menu-divider');
await expect(rangeDividers).toHaveCount(1);
await expect(rangeDividers.first()).toBeVisible();
expect(await rangeDividers.first().evaluate((element) => getComputedStyle(element).borderTop)).toBe(
'1px solid rgb(68, 68, 68)'
);
await persistArtifact(page, `${basePath.slice(1)}-command-range-divider-desktop-1200`);
await rangeMenu.evaluate((element) => ((element as HTMLDetailsElement).open = false));
await expect(rangeMenu).not.toHaveAttribute('open', '');
const selectedMenu = page.locator('[data-main-target="commands"] .selected-menu');
await selectedMenu.locator('summary').click();
const selectedMenuDividers = selectedMenu.locator('.menu-divider');
await expect(selectedMenuDividers).toHaveCount(3);
await expect(selectedMenuDividers.first()).toBeVisible();
expect(await selectedMenuDividers.first().evaluate((element) => getComputedStyle(element).borderTop)).toBe(
'1px solid rgb(68, 68, 68)'
);
await persistArtifact(page, `${basePath.slice(1)}-command-selected-dividers-desktop-1200`);
await selectedMenu.evaluate((element) => ((element as HTMLDetailsElement).open = false));
await expect(selectedMenu).not.toHaveAttribute('open', '');
await page.locator('[data-main-target="commands"] .select-command').click();
const picker = page.getByTestId('command-picker');
await expect(picker).toBeVisible();
@@ -1111,6 +1201,11 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
expect(mobileGeometry.controlBoxes).toHaveLength(3);
expect(new Set(mobileGeometry.controlBoxes.map(({ y }) => y)).size).toBe(1);
await expect(page.locator('[data-main-target="commands"] .edit-column button')).toHaveCount(30);
const mobileTurnButton = page.getByRole('button', { name: '10턴 명령 입력' });
await mobileTurnButton.click();
await expect(page.getByTestId('command-picker')).toBeVisible();
await mobileTurnButton.click();
await expect(page.getByTestId('command-picker')).toBeHidden();
await captureProgress('mobile-500');
});
@@ -1121,6 +1216,8 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy
nationLevel: 3,
stage: 6,
npcMode: 1,
scenarioTitle: '모바일 검증 시나리오',
latestVote: null,
generalMeCalls: 0,
operations: [],
};
@@ -1139,6 +1236,27 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy
await expect(page.locator('.main-mobile-bottom')).toBeVisible();
await page.setViewportSize({ width: 500, height: 900 });
await expect(page.getByRole('heading', { name: '모바일 검증 시나리오', exact: true })).toHaveCount(1);
await expect(page.locator('.game-shell__subtitle')).toHaveText('185년 1월 · 턴 10분');
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 베팅 진행중');
await expect(page.locator('.vote-status')).toHaveText('설문: 진행 중인 설문 없음');
const activityGeometry = await page.locator('.activity-status').evaluate((element) => {
const tournament = element.querySelector<HTMLElement>('.tournament-status');
const survey = element.querySelector<HTMLElement>('.vote-status');
if (!tournament || !survey) throw new Error('activity status is incomplete');
return {
width: element.getBoundingClientRect().width,
tournamentWidth: tournament.getBoundingClientRect().width,
surveyWidth: survey.getBoundingClientRect().width,
columns: getComputedStyle(element).gridTemplateColumns,
};
});
expect(activityGeometry).toMatchObject({
width: 500,
tournamentWidth: 250,
surveyWidth: 250,
columns: '250px 250px',
});
await expect
.poll(() =>
page
@@ -1175,6 +1293,125 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy
await persistArtifact(page, `${basePath.slice(1)}-mobile-500`);
});
test('real mobile devices initially fit the complete 500px game canvas', async ({ browser }, testInfo) => {
test.setTimeout(60_000);
const configuredBaseUrl = testInfo.project.use.baseURL;
if (typeof configuredBaseUrl !== 'string') {
throw new Error('Playwright baseURL is required for the mobile viewport contract');
}
const deviceWidths = [360, 390, 480];
const measurements: Record<string, unknown> = {};
for (const deviceWidth of deviceWidths) {
const context = await browser.newContext({
baseURL: configuredBaseUrl,
viewport: { width: deviceWidth, height: 844 },
screen: { width: deviceWidth, height: 844 },
deviceScaleFactor: 1,
isMobile: true,
hasTouch: true,
colorScheme: 'dark',
});
const mobilePage = await context.newPage();
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
nationLevel: 3,
stage: 6,
npcMode: 1,
generalMeCalls: 0,
operations: [],
};
await installFixture(mobilePage, state);
await waitForMain(mobilePage);
const mainGeometry = await mobilePage.locator('.main-page').evaluate((element) => {
const rect = element.getBoundingClientRect();
return {
viewportMeta: document.querySelector<HTMLMetaElement>('meta[name="viewport"]')?.content,
screenWidth: screen.availWidth,
innerWidth: window.innerWidth,
layoutViewportWidth: document.documentElement.clientWidth,
visualViewportWidth: window.visualViewport?.width ?? null,
visualViewportScale: window.visualViewport?.scale ?? null,
documentScrollWidth: document.documentElement.scrollWidth,
canvas: {
left: rect.left,
right: rect.right,
width: rect.width,
},
};
});
expect(mainGeometry.viewportMeta).toBe('width=500');
expect(mainGeometry.screenWidth).toBe(deviceWidth);
expect(mainGeometry.layoutViewportWidth).toBe(500);
expect(mainGeometry.visualViewportWidth).toBeCloseTo(500, 2);
expect(mainGeometry.visualViewportScale).toBeCloseTo(deviceWidth / 500, 2);
expect(mainGeometry.documentScrollWidth).toBeLessThanOrEqual(mainGeometry.innerWidth);
expect(mainGeometry.canvas).toEqual({ left: 0, right: 500, width: 500 });
expect(mainGeometry.canvas.right).toBeLessThanOrEqual((mainGeometry.visualViewportWidth ?? 0) + 0.01);
if (artifactRoot) {
await mkdir(artifactRoot, { recursive: true });
await mobilePage.screenshot({
path: resolve(artifactRoot, `initial-mobile-fit-${deviceWidth}.png`),
fullPage: true,
});
}
const routeGeometry: Record<string, unknown> = {};
if (deviceWidth === 390) {
for (const target of [
'chief-center',
'battle-center',
'inherit',
'nation-betting',
]) {
await mobilePage.goto(target);
await expect
.poll(() => mobilePage.locator('#app').evaluate((element) => getComputedStyle(element).minWidth))
.toBe('500px');
routeGeometry[target] = await mobilePage.locator('#app').evaluate((element) => {
const rect = element.getBoundingClientRect();
return {
viewportMeta: document.querySelector<HTMLMetaElement>('meta[name="viewport"]')?.content,
layoutViewportWidth: document.documentElement.clientWidth,
visualViewportWidth: window.visualViewport?.width ?? null,
left: rect.left,
right: rect.right,
width: rect.width,
};
});
const geometry = routeGeometry[target] as {
viewportMeta: string;
layoutViewportWidth: number;
visualViewportWidth: number;
left: number;
right: number;
width: number;
};
expect(geometry.viewportMeta).toBe('width=500');
expect(geometry.layoutViewportWidth).toBe(500);
expect(geometry.visualViewportWidth).toBeCloseTo(500, 2);
expect(geometry.left).toBeCloseTo(0, 2);
expect(geometry.right).toBeCloseTo(500, 2);
expect(geometry.width).toBeCloseTo(500, 2);
}
}
measurements[String(deviceWidth)] = { main: mainGeometry, routes: routeGeometry };
await context.close();
}
if (artifactRoot) {
await writeFile(
resolve(artifactRoot, 'initial-mobile-fit-computed-dom.json'),
`${JSON.stringify(measurements, null, 2)}\n`
);
}
});
test('nation menu presentation follows the server-derived permission matrix', async ({ page }) => {
const state: NavigationFixture = {
officerLevel: 1,
@@ -18,6 +18,8 @@ const general = {
stats: { leadership: 70, strength: 60, intelligence: 50 },
experienceLevel: 9,
dedicationLevel: 1,
dedicationText: '30품관',
bill: 600,
injury: 0,
gold: 1000,
rice: 2000,
@@ -28,6 +30,24 @@ const general = {
refreshScoreTotal: 10,
permission: 'normal',
};
const otherGeneral = {
...general,
id: 2,
name: '다른장수',
npcState: 1,
stats: { leadership: 40, strength: 80, intelligence: 65 },
experienceLevel: 12,
dedicationLevel: 3,
dedicationText: '28품관',
bill: 1000,
gold: 3000,
rice: 500,
personality: { key: '용장', name: '용장', info: '공격적인 성격' },
specialDomestic: { key: '상재', name: '상재', info: '상업 특기' },
specialWar: { key: '돌격', name: '돌격', info: '전투 특기' },
belong: 4,
refreshScoreTotal: 20,
};
const install = async (page: Page, secretAllowed = true) => {
await page.addInitScript((profile) => {
localStorage.setItem('sammo-game-token', 'ga_general');
@@ -42,7 +62,7 @@ const install = async (page: Page, secretAllowed = true) => {
return response({
nation: { id: 1, name: '위', color: '#008000', level: 3 },
viewer: { generalId: 1, permission: 0 },
generals: [general],
generals: [general, otherGeneral],
});
if (operation === 'nation.getSecretGeneralList') {
if (!secretAllowed)
@@ -108,19 +128,91 @@ test('nation generals keeps the 1000px legacy grid and redacted member columns',
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('nation/generals');
await expect(page.locator('#nation-general-list')).toContainText('테스트장수');
await expect(page.locator('#nation-general-list')).toContainText('?');
const computed = await page.locator('.general-page').evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return { x: rect.x, width: rect.width, fontSize: style.fontSize, fontFamily: style.fontFamily };
});
expect(computed).toMatchObject({ x: 100, width: 1000, fontSize: '16px' });
expect(computed.fontFamily).toContain('Times New Roman');
expect(computed).toMatchObject({ x: 100, width: 1000, fontSize: '14px' });
expect(computed.fontFamily).toContain('Pretendard');
expect(await page.locator('#nation-general-list').evaluate((el) => getComputedStyle(el).borderCollapse)).toBe(
'separate'
);
expect((await page.locator('#nation-general-list').boundingBox())?.width).toBe(1030);
expect((await page.locator('#nation-general-list tbody tr').boundingBox())?.height).toBe(66);
expect((await page.locator('#nation-general-list').boundingBox())?.width).toBe(1000);
expect((await page.locator('#nation-general-list tbody tr').first().boundingBox())?.height).toBe(68);
await page.getByRole('button', { name: '보기 모드⌄' }).click();
await page.getByRole('button', { name: '전투', exact: true }).click();
await expect(page.locator('#nation-general-list')).toContainText('?');
});
test('nation generals restores Ref group, saved view, sort, and Korean search behavior', async ({ page }, testInfo) => {
await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('nation/generals');
const table = page.locator('#nation-general-list');
await page.screenshot({ path: testInfo.outputPath('core-initial.png'), fullPage: true });
const statGroupButton = page.getByRole('button', { name: '능력치 접기' });
await expect(statGroupButton).toHaveAttribute('aria-expanded', 'true');
expect(await statGroupButton.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)');
await statGroupButton.hover();
expect(await statGroupButton.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgb(48, 54, 56)');
await statGroupButton.focus();
await expect(statGroupButton).toBeFocused();
expect(await statGroupButton.evaluate((el) => getComputedStyle(el).outlineStyle)).toBe('solid');
await statGroupButton.click();
await page.screenshot({ path: testInfo.outputPath('core-stat-collapsed.png'), fullPage: true });
await expect(page.getByRole('button', { name: '능력치 펼치기' })).toHaveAttribute('aria-expanded', 'false');
await expect(table.locator('thead')).toContainText('통|무|지');
await expect(table.locator('tr[data-general-id="1"]')).toContainText('70|60|50');
await page.getByRole('button', { name: '능력치 펼치기' }).click();
await page.getByLabel('장수명 필터').fill('ㅌㅅㅌㅈㅅ');
await expect(table.locator('tr[data-general-id="1"]')).toBeVisible();
await expect(table.locator('tr[data-general-id="2"]')).toHaveCount(0);
await page.getByLabel('장수명 필터').fill('');
await page.getByLabel('통솔 필터').fill('>= 60');
await expect(table.locator('tr[data-general-id="1"]')).toBeVisible();
await expect(table.locator('tr[data-general-id="2"]')).toHaveCount(0);
await page.getByLabel('통솔 필터').fill('');
await page.getByRole('button', { name: '통솔 정렬' }).click();
await expect(table.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '1');
await page.getByRole('button', { name: '통솔 정렬' }).click();
await expect(table.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '2');
await page.getByRole('button', { name: '능력치 접기' }).click();
await page.getByRole('button', { name: '열 선택⌄' }).click();
await page.getByLabel('쌀', { exact: true }).uncheck();
await expect(page.getByRole('button', { name: '쌀 정렬' })).toHaveCount(0);
await page.getByRole('button', { name: '보기 모드⌄' }).click();
page.once('dialog', async (dialog) => {
expect(dialog.type()).toBe('prompt');
await dialog.accept('내 보기');
});
await page.getByRole('button', { name: /보관하기/ }).click();
await expect
.poll(() =>
page.evaluate(() => ({
settings: localStorage.getItem('GeneralListDisplaySetting'),
last: localStorage.getItem('LastUsedSettingsKey_pageNationGeneral'),
}))
)
.toMatchObject({ settings: expect.stringContaining('내 보기'), last: '[false,"내 보기"]' });
await page.reload();
await expect(page.getByRole('button', { name: '능력치 펼치기' })).toHaveAttribute('aria-expanded', 'false');
await expect(page.getByRole('button', { name: '쌀 정렬' })).toHaveCount(0);
await page.getByRole('button', { name: '보기 모드⌄' }).click();
await expect(page.getByRole('button', { name: '내 보기', exact: true })).toBeVisible();
page.once('dialog', async (dialog) => {
expect(dialog.type()).toBe('confirm');
await dialog.accept();
});
await page.getByRole('button', { name: '내 보기 설정 삭제' }).click();
await expect
.poll(() => page.evaluate(() => localStorage.getItem('GeneralListDisplaySetting')))
.not.toContain('내 보기');
});
test('both pages preserve the legacy 1000px overflow contract at 500px', async ({ page }) => {
+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'));
});