Merge branch 'main' into feature/nation-personnel-finance-parity

# Conflicts:
#	app/game-frontend/e2e/playwright.config.mjs
#	app/game-frontend/package.json
#	docs/frontend-legacy-parity.md
This commit is contained in:
2026-07-26 04:31:54 +00:00
42 changed files with 4790 additions and 1949 deletions
@@ -0,0 +1,119 @@
import { createHash } from 'node:crypto';
import { mkdir, readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { chromium } from '@playwright/test';
const targetUrl = process.env.REF_BOARD_URL ?? 'https://dev-sam-ref.hided.net/sam/';
const username = process.env.REF_BOARD_USER ?? 'refuser1';
const passwordFile = process.env.REF_BOARD_PASSWORD_FILE;
const artifactRoot = process.env.REF_BOARD_ARTIFACT_DIR;
if (!passwordFile) {
throw new Error('REF_BOARD_PASSWORD_FILE is required');
}
const password = (await readFile(passwordFile, 'utf8')).trim();
const login = async (context, page) => {
await page.goto(targetUrl, { waitUntil: 'networkidle', timeout: 60_000 });
const globalSalt = await page.locator('#global_salt').inputValue();
const passwordHash = createHash('sha512')
.update(globalSalt + password + globalSalt)
.digest('hex');
const response = await context.request.post(new URL('api.php?path=Login/LoginByID', targetUrl).toString(), {
data: { username, password: passwordHash },
});
const result = await response.json();
if (!response.ok() || result.result !== true) {
throw new Error('Reference login failed');
}
};
const measure = async (browser, name, viewport, isSecret) => {
const context = await browser.newContext({
viewport,
deviceScaleFactor: 1,
colorScheme: 'dark',
locale: 'ko-KR',
timezoneId: 'UTC',
ignoreHTTPSErrors: true,
});
try {
const page = await context.newPage();
await login(context, page);
const boardUrl = new URL('hwe/v_board.php', targetUrl);
if (isSecret) {
boardUrl.searchParams.set('isSecret', 'true');
}
await page.goto(boardUrl.toString(), { waitUntil: 'networkidle', timeout: 60_000 });
await page.locator('.articleFrame').first().waitFor({ state: 'visible' });
if (artifactRoot) {
const path = resolve(artifactRoot, `board-ref-${name}.png`);
await mkdir(dirname(path), { recursive: true });
await page.screenshot({ path, fullPage: true, animations: 'disabled' });
}
const geometry = await page.evaluate(() => {
const rect = (selector) => {
const box = document.querySelector(selector).getBoundingClientRect();
return { x: box.x, y: box.y, width: box.width, height: box.height };
};
const pageStyle = getComputedStyle(document.querySelector('#container'));
const articleText = getComputedStyle(document.querySelector('.articleFrame .text'));
return {
title: document.title,
container: rect('#container'),
topBar: rect('.back_bar'),
titleLabel: rect('#newArticle .articleTitle'),
submitArticle: rect('#submitArticle'),
articleAuthor: rect('.articleFrame .authorName'),
articleDate: rect('.articleFrame .date'),
icon: rect('.articleFrame .generalIcon'),
commentAuthor: rect('.articleFrame .comment .authorName'),
submitComment: rect('.articleFrame .submitComment'),
bottomButton: rect('.bg0[style] .back_btn'),
font: {
family: pageStyle.fontFamily,
size: pageStyle.fontSize,
lineHeight: pageStyle.lineHeight,
},
whiteSpace: articleText.whiteSpace,
walnut: getComputedStyle(document.querySelector('#newArticle')).backgroundImage,
green: getComputedStyle(document.querySelector('.articleFrame > .bg1')).backgroundImage,
blue: getComputedStyle(document.querySelector('.newArticleHeader')).backgroundImage,
submitStyle: {
backgroundColor: getComputedStyle(document.querySelector('#submitArticle')).backgroundColor,
borderColor: getComputedStyle(document.querySelector('#submitArticle')).borderColor,
color: getComputedStyle(document.querySelector('#submitArticle')).color,
},
};
});
const submit = page.locator('#submitArticle');
await submit.hover();
const hoverBackground = await submit.evaluate((element) => getComputedStyle(element).backgroundColor);
await submit.focus();
const focusOutline = await submit.evaluate((element) => getComputedStyle(element).outline);
return {
...geometry,
submitInteraction: {
hoverBackground,
focusOutline,
},
};
} finally {
await context.close();
}
};
const browser = await chromium.launch({ headless: true });
try {
const measurements = {
desktop: await measure(browser, 'desktop', { width: 1000, height: 800 }, false),
mobile: await measure(browser, 'mobile', { width: 500, height: 800 }, true),
};
process.stdout.write(`${JSON.stringify(measurements, null, 2)}\n`);
} finally {
await browser.close();
}
@@ -163,5 +163,63 @@ export const canonicalFrontendFixture = {
globalAction: ['<L>●</> 유비가 내정을 수행했습니다.'],
},
},
surveyList: {
polls: [
{
id: 2,
title: '선호하는 병종',
startAt: '2026-07-26T00:00:00.000Z',
endAt: null,
closedAt: null,
revealMode: 'after_vote',
optionsCount: 3,
multipleOptions: 1,
},
{
id: 1,
title: '지난 설문',
startAt: '2026-07-20T00:00:00.000Z',
endAt: '2026-07-21T00:00:00.000Z',
closedAt: '2026-07-21T00:00:00.000Z',
revealMode: 'after_vote',
optionsCount: 2,
multipleOptions: 1,
},
],
voteReward: 90,
},
surveyDetail: {
voteInfo: {
id: 2,
title: '선호하는 병종',
body: '',
options: ['보병', '기병', '궁병'],
multipleOptions: 1,
revealMode: 'after_vote',
openerGeneralId: 9,
openerName: '설문관리자',
startAt: '2026-07-26T00:00:00.000Z',
endAt: null,
closedAt: null,
},
votes: [
{ selection: [0], count: 2 },
{ selection: [1], count: 1 },
],
comments: [
{
id: 1,
voteId: 2,
generalId: 3,
nationId: 1,
generalName: '관우',
nationName: '촉',
text: '기병이 좋습니다.',
createdAt: '2026-07-26T01:23:00.000Z',
},
],
myVote: null,
userCnt: 17,
},
},
} as const;
@@ -111,6 +111,8 @@ const installHallFixture = async (page: Page): Promise<void> => {
};
const installAuthenticatedGameFixture = async (page: Page): Promise<void> => {
let surveyVoted = false;
const surveyComments = fixture.game.surveyDetail.comments.map((comment) => ({ ...comment }));
await installImages(page);
await page.addInitScript(
({ gameToken, profile }) => {
@@ -139,6 +141,38 @@ const installAuthenticatedGameFixture = async (page: Page): Promise<void> => {
if (operation === 'public.getMapLayout') return fixture.game.mapLayout;
if (operation === 'yearbook.getRange') return fixture.game.yearbookRange;
if (operation === 'yearbook.getHistory') return fixture.game.yearbook;
if (operation === 'vote.getVoteList') return fixture.game.surveyList;
if (operation === 'vote.getVoteDetail') {
return {
...fixture.game.surveyDetail,
votes: surveyVoted
? [
{ selection: [0], count: 3 },
{ selection: [1], count: 1 },
]
: fixture.game.surveyDetail.votes,
comments: surveyComments,
myVote: surveyVoted ? [0] : null,
};
}
if (operation === 'vote.submitVote') {
surveyVoted = true;
return { ok: true, wonLottery: false };
}
if (operation === 'vote.addComment') {
surveyComments.push({
id: surveyComments.length + 1,
voteId: 2,
generalId: 1,
nationId: 1,
generalName: '유비',
nationName: '촉',
text: '새 댓글',
createdAt: '2026-07-26T02:34:00.000Z',
});
return { ok: true };
}
if (operation === 'vote.getAdminStatus') return { ok: false };
throw new Error(`Unhandled authenticated game fixture operation: ${operation}`);
});
});
@@ -494,3 +528,104 @@ test.describe('yearbook legacy parity', () => {
await expect(page.getByLabel('연월 선택')).toBeVisible();
});
});
test.describe('survey legacy parity', () => {
test.beforeEach(async ({ page }) => {
await installAuthenticatedGameFixture(page);
});
for (const viewport of [
{ name: 'desktop', width: 1365, height: 768, containerWidth: 1000, commentNameWidth: 260 },
{ name: 'mobile', width: 390, height: 844, containerWidth: 500, commentNameWidth: 130 },
]) {
test(`renders the ref vote tables on ${viewport.name}`, async ({ page }) => {
await page.setViewportSize(viewport);
await page.goto('http://127.0.0.1:15102/che/survey');
await expect(page.getByText('설문 조사(90금과 추첨으로 유니크템 증정!)')).toBeVisible();
await expect(page.getByText('기병이 좋습니다.')).toBeVisible();
await expect(page.locator('#vote-new-panel')).toHaveCount(0);
if (artifactRoot) {
await page.screenshot({
path: resolve(artifactRoot, `survey-core-${viewport.name}.png`),
fullPage: true,
animations: 'disabled',
});
}
const geometry = await page.evaluate(() => {
const rect = (selector: string) =>
document.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
const container = document.querySelector<HTMLElement>('#container')!;
const title = document.querySelector<HTMLElement>('#vote-title')!;
return {
containerWidth: rect('#container').width,
containerHeight: rect('#container').height,
resultWidth: rect('#vote-result').width,
commentNameWidth: rect('#vote-comment .comment-name').width,
fontFamily: getComputedStyle(container).fontFamily,
fontSize: getComputedStyle(container).fontSize,
backgroundImage: getComputedStyle(container).backgroundImage,
title: {
height: rect('#vote-title').height,
fontSize: getComputedStyle(title).fontSize,
backgroundImage: getComputedStyle(title).backgroundImage,
},
};
});
expect(geometry.containerWidth).toBe(viewport.containerWidth);
expect(geometry.containerHeight).toBeLessThan(viewport.height);
expect(geometry.resultWidth).toBe(viewport.containerWidth);
expect(geometry.commentNameWidth).toBe(viewport.commentNameWidth);
expect(geometry.fontFamily).toContain('Pretendard');
expect(geometry.fontSize).toBe('14px');
expect(geometry.backgroundImage).toContain('back_walnut.jpg');
expect(geometry.title.height).toBeCloseTo(37.8, 0);
expect(geometry.title.fontSize).toBe('25.2px');
expect(geometry.title.backgroundImage).toContain('back_blue.jpg');
const secondOption = page.locator('#v-vote-1');
await secondOption.check();
await expect(secondOption).toBeChecked();
await secondOption.focus();
await expect(secondOption).toBeFocused();
const voteButton = page.getByRole('button', { name: '투표', exact: true });
const beforeHover = await voteButton.evaluate((element) => getComputedStyle(element).filter);
await voteButton.hover();
const afterHover = await voteButton.evaluate((element) => getComputedStyle(element).filter);
expect(afterHover).not.toBe(beforeHover);
});
}
test('submits a vote and comment through the real screen controls', async ({ page }) => {
await page.goto('http://127.0.0.1:15102/che/survey');
await page.getByRole('button', { name: '투표', exact: true }).click();
await expect(page.getByRole('status')).toHaveText('설문을 마쳤습니다.');
await expect(page.getByText('결산', { exact: true })).toBeVisible();
await page.getByLabel('댓글').fill('새 댓글');
await page.getByRole('button', { name: '댓글 달기' }).click();
await expect(page.getByText('새 댓글')).toBeVisible();
});
test('keeps the selected option after a vote API error', async ({ page }) => {
await page.route('**/che/api/trpc/**', async (route) => {
if (operationNames(route).includes('vote.submitVote')) {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: { message: '이미 설문조사를 완료하였습니다.' } }),
});
return;
}
await route.fallback();
});
await page.goto('http://127.0.0.1:15102/che/survey');
const secondOption = page.locator('#v-vote-1');
await secondOption.check();
await page.getByRole('button', { name: '투표', exact: true }).click();
await expect(page.getByRole('alert')).toBeVisible();
await expect(secondOption).toBeChecked();
});
});