fix(game-ui): 개인 공격·수비 기록 시각 글자 크기를 맞춤
Ref의 <1> 시각 표식과 같은 90% 글자 크기를 적용하고, 기존 표식 시각의 중복 추가를 막는다. 동일 공격·수비 문구의 Ref/Core Chromium 측정과 /che, /hwe 회귀 검증을 추가한다.
This commit is contained in:
@@ -791,6 +791,97 @@ test('메인 장수 동향과 개인 전투 기록은 모두 21px 행 간격을
|
|||||||
await persistParityArtifact(page, 'core-main-personal-battle-log-inline-mobile', mobileGeometry);
|
await persistParityArtifact(page, 'core-main-personal-battle-log-inline-mobile', mobileGeometry);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('메인 개인 기록의 공격·수비 시각은 Ref와 같은 90% 글자 크기로 표시한다', async ({ page }) => {
|
||||||
|
const state: FixtureState = {
|
||||||
|
permission: 'head',
|
||||||
|
myset: 3,
|
||||||
|
settingMutations: [],
|
||||||
|
accessPages: [],
|
||||||
|
recentRecords: {
|
||||||
|
global: [],
|
||||||
|
general: [
|
||||||
|
{
|
||||||
|
id: 18703,
|
||||||
|
text: '<C>●</>10월:천귀병으로 <Y>ⓝ염행</>의 보병을 <M>수비</>합니다.',
|
||||||
|
createdAt: '2026-01-01T03:54:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 18702,
|
||||||
|
text: '<C>●</>10월:천귀병으로 <Y>ⓝ염행</>의 보병을 <M>공격</>합니다.',
|
||||||
|
createdAt: '2026-01-01T03:55:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 18701,
|
||||||
|
text: '<C>●</>10월:이미 기록된 시각 <1>12:34</>',
|
||||||
|
createdAt: '2026-01-01T03:56:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
history: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await install(page, state);
|
||||||
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
|
await page.goto('');
|
||||||
|
|
||||||
|
const inspect = async (selector: string) => {
|
||||||
|
const lines = page.locator(selector);
|
||||||
|
await expect(lines).toHaveCount(3);
|
||||||
|
await expect(lines.nth(0)).toHaveText('●10월:천귀병으로 ⓝ염행의 보병을 수비합니다. 12:54');
|
||||||
|
await expect(lines.nth(1)).toHaveText('●10월:천귀병으로 ⓝ염행의 보병을 공격합니다. 12:55');
|
||||||
|
await expect(lines.nth(2)).toHaveText('●10월:이미 기록된 시각 12:34');
|
||||||
|
|
||||||
|
return lines.evaluateAll((elements) =>
|
||||||
|
elements.map((element) => {
|
||||||
|
const spans = [...element.querySelectorAll<HTMLElement>('span')];
|
||||||
|
const time = spans.find((span) => /^\d{2}:\d{2}$/u.test(span.textContent ?? ''));
|
||||||
|
const name = spans.find((span) => span.textContent === 'ⓝ염행');
|
||||||
|
const action = spans.find((span) => span.textContent === '수비' || span.textContent === '공격');
|
||||||
|
if (!time) throw new Error('개인 기록 시각 span을 찾지 못했습니다.');
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
text: element.textContent,
|
||||||
|
row: {
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
clientWidth: element.clientWidth,
|
||||||
|
scrollWidth: element.scrollWidth,
|
||||||
|
fontSize: getComputedStyle(element).fontSize,
|
||||||
|
lineHeight: getComputedStyle(element).lineHeight,
|
||||||
|
},
|
||||||
|
time: {
|
||||||
|
fontSize: getComputedStyle(time).fontSize,
|
||||||
|
lineHeight: getComputedStyle(time).lineHeight,
|
||||||
|
},
|
||||||
|
nameFontSize: name ? getComputedStyle(name).fontSize : null,
|
||||||
|
actionFontSize: action ? getComputedStyle(action).fontSize : null,
|
||||||
|
timeSpanCount: spans.filter((span) => /^\d{2}:\d{2}$/u.test(span.textContent ?? '')).length,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const assertFontContract = (measurements: Awaited<ReturnType<typeof inspect>>) => {
|
||||||
|
expect(measurements.map((entry) => entry.row.fontSize)).toEqual(['14px', '14px', '14px']);
|
||||||
|
expect(measurements.map((entry) => entry.row.lineHeight)).toEqual(['21px', '21px', '21px']);
|
||||||
|
expect(measurements.map((entry) => entry.row.height)).toEqual([21, 21, 21]);
|
||||||
|
expect(measurements.map((entry) => entry.time.fontSize)).toEqual(['12.6px', '12.6px', '12.6px']);
|
||||||
|
expect(measurements.map((entry) => entry.timeSpanCount)).toEqual([1, 1, 1]);
|
||||||
|
expect(measurements[0]?.nameFontSize).toBe('14px');
|
||||||
|
expect(measurements[0]?.actionFontSize).toBe('14px');
|
||||||
|
expect(measurements[1]?.nameFontSize).toBe('14px');
|
||||||
|
expect(measurements[1]?.actionFontSize).toBe('14px');
|
||||||
|
expect(measurements.every((entry) => entry.row.scrollWidth <= entry.row.clientWidth)).toBe(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const desktop = await inspect('.record-zone [data-record-bucket="general"] .record-line');
|
||||||
|
assertFontContract(desktop);
|
||||||
|
await persistParityArtifact(page, 'core-main-personal-war-log-time-font-desktop', desktop);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 500, height: 900 });
|
||||||
|
const mobile = await inspect('.record-zone-mobile [data-record-bucket="general"] .record-line');
|
||||||
|
assertFontContract(mobile);
|
||||||
|
await persistParityArtifact(page, 'core-main-personal-war-log-time-font-mobile', mobile);
|
||||||
|
});
|
||||||
|
|
||||||
test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ page }) => {
|
test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ page }) => {
|
||||||
const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [], accessPages: [] };
|
const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [], accessPages: [] };
|
||||||
await install(page, state);
|
await install(page, state);
|
||||||
|
|||||||
@@ -66,11 +66,12 @@ const nationAccess = computed(() => ({
|
|||||||
}));
|
}));
|
||||||
const nationColor = computed(() => nation.value?.color ?? '#000000');
|
const nationColor = computed(() => nation.value?.color ?? '#000000');
|
||||||
const voteActive = computed(() => Boolean(frontStatus.value?.latestVote));
|
const voteActive = computed(() => Boolean(frontStatus.value?.latestVote));
|
||||||
|
const recordTimeSuffixPattern = /\d{2}:\d{2}(?:<\/>)?\s*$/u;
|
||||||
const formatRecord = (entry: { text: string; createdAt?: string | Date }, appendTime = false): string => {
|
const formatRecord = (entry: { text: string; createdAt?: string | Date }, appendTime = false): string => {
|
||||||
if (!appendTime || /\d{2}:\d{2}\s*$/u.test(entry.text)) return formatLog(entry.text);
|
if (!appendTime || recordTimeSuffixPattern.test(entry.text)) return formatLog(entry.text);
|
||||||
const time = formatServerDateTime(entry.createdAt, { format: 'hourMinute', fallback: '' });
|
const time = formatServerDateTime(entry.createdAt, { format: 'hourMinute', fallback: '' });
|
||||||
if (!time) return formatLog(entry.text);
|
if (!time) return formatLog(entry.text);
|
||||||
return formatLog(`${entry.text} ${time}`);
|
return formatLog(`${entry.text} <1>${time}</>`);
|
||||||
};
|
};
|
||||||
|
|
||||||
let surveyNoticeTimer: ReturnType<typeof setTimeout> | null = null;
|
let surveyNoticeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|||||||
@@ -122,6 +122,21 @@ Adding or changing a frontend route requires:
|
|||||||
Pixel snapshots may be added after these structural assertions pass. Dynamic
|
Pixel snapshots may be added after these structural assertions pass. Dynamic
|
||||||
regions must not be hidden merely to make a pixel threshold pass.
|
regions must not be hidden merely to make a pixel threshold pass.
|
||||||
|
|
||||||
|
개인 공격·수비 기록의 Ref 글자 크기는 checkout의 실제 `formatLog.ts`와 빌드된
|
||||||
|
`v_main.css`를 사용하는 정적 Chromium fixture로 독립 재현할 수 있습니다. 이
|
||||||
|
helper는 고정된 비민감 기록 문구만 렌더링하므로 live PHP session이나 DB 저장
|
||||||
|
경로 검증을 대신하지 않습니다.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
REF_SAM_ROOT=/path/to/ref/sam \
|
||||||
|
REF_PERSONAL_WAR_LOG_ARTIFACT_DIR=/path/to/ignored/artifacts \
|
||||||
|
node tools/frontend-legacy-parity/reference-personal-war-log-font.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
대응하는 Core 검증은 `inGameMenus.spec.ts`의 “공격·수비 시각” test이며,
|
||||||
|
`MENU_PARITY_ARTIFACT_DIR`를 지정하면 1200×900·500×900 screenshot과 computed
|
||||||
|
style JSON을 남깁니다.
|
||||||
|
|
||||||
To refresh the PHP ranking evidence after building the ignored reference
|
To refresh the PHP ranking evidence after building the ignored reference
|
||||||
webpack assets, run:
|
webpack assets, run:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
|
import { chromium } from '@playwright/test';
|
||||||
|
|
||||||
|
const refRoot = resolve(process.env.REF_SAM_ROOT ?? '/home/letrhee/sam_rebuild/ref/sam');
|
||||||
|
const artifactDir = process.env.REF_PERSONAL_WAR_LOG_ARTIFACT_DIR;
|
||||||
|
const formatterUrl = pathToFileURL(resolve(refRoot, 'hwe/ts/utilGame/formatLog.ts')).href;
|
||||||
|
const { formatLog } = await import(formatterUrl);
|
||||||
|
const css = await readFile(resolve(refRoot, 'dist_js/hwe_dynamic/vue/v_main.css'), 'utf8');
|
||||||
|
const records = [
|
||||||
|
'<C>●</>10월:천귀병으로 <Y>ⓝ염행</>의 보병을 <M>수비</>합니다. <1>12:54</>',
|
||||||
|
'<C>●</>10월:천귀병으로 <Y>ⓝ염행</>의 보병을 <M>공격</>합니다. <1>12:55</>',
|
||||||
|
];
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
try {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
colorScheme: 'dark',
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
locale: 'ko-KR',
|
||||||
|
timezoneId: 'UTC',
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
const lines = records.map((record) => `<div class="fixture-line">${formatLog(record)}</div>`).join('');
|
||||||
|
await page.setContent(
|
||||||
|
`<!doctype html><html><head><style>${css}</style></head><body>` +
|
||||||
|
`<div id="container"><div class="RecordZone row gx-0"><div class="GeneralLog col col-12 col-lg-6">` +
|
||||||
|
`<div class="bg1 center s-border-tb title">개인 기록</div>${lines}</div></div></div></body></html>`,
|
||||||
|
{ waitUntil: 'networkidle' }
|
||||||
|
);
|
||||||
|
await page.evaluate(() => document.fonts.ready);
|
||||||
|
|
||||||
|
for (const viewport of [
|
||||||
|
{ name: 'desktop', width: 1200, height: 900 },
|
||||||
|
{ name: 'mobile', width: 500, height: 900 },
|
||||||
|
]) {
|
||||||
|
await page.setViewportSize(viewport);
|
||||||
|
const measurement = await page.locator('.fixture-line').evaluateAll((elements) =>
|
||||||
|
elements.map((element) => {
|
||||||
|
const spans = [...element.querySelectorAll('span')];
|
||||||
|
const time = spans.find((span) => /^\d{2}:\d{2}$/u.test(span.textContent ?? ''));
|
||||||
|
const name = spans.find((span) => span.textContent === 'ⓝ염행');
|
||||||
|
const action = spans.find((span) => span.textContent === '수비' || span.textContent === '공격');
|
||||||
|
if (
|
||||||
|
!(time instanceof HTMLElement) ||
|
||||||
|
!(name instanceof HTMLElement) ||
|
||||||
|
!(action instanceof HTMLElement)
|
||||||
|
) {
|
||||||
|
throw new Error('Ref 개인 공격·수비 기록의 비교 span을 찾지 못했습니다.');
|
||||||
|
}
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
text: element.textContent,
|
||||||
|
row: {
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
fontSize: getComputedStyle(element).fontSize,
|
||||||
|
lineHeight: getComputedStyle(element).lineHeight,
|
||||||
|
},
|
||||||
|
timeFontSize: getComputedStyle(time).fontSize,
|
||||||
|
nameFontSize: getComputedStyle(name).fontSize,
|
||||||
|
actionFontSize: getComputedStyle(action).fontSize,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const output = { viewport, measurement };
|
||||||
|
console.log(JSON.stringify(output));
|
||||||
|
if (artifactDir) {
|
||||||
|
await mkdir(artifactDir, { recursive: true });
|
||||||
|
await Promise.all([
|
||||||
|
page.screenshot({ path: resolve(artifactDir, `ref-personal-war-log-font-${viewport.name}.png`) }),
|
||||||
|
writeFile(
|
||||||
|
resolve(artifactDir, `ref-personal-war-log-font-${viewport.name}.json`),
|
||||||
|
`${JSON.stringify(output, null, 2)}\n`
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user