diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index a7c616b8..c2a6b351 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -791,6 +791,97 @@ test('메인 장수 동향과 개인 전투 기록은 모두 21px 행 간격을 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: '●10월:천귀병으로 ⓝ염행의 보병을 수비합니다.', + createdAt: '2026-01-01T03:54:00.000Z', + }, + { + id: 18702, + text: '●10월:천귀병으로 ⓝ염행의 보병을 공격합니다.', + createdAt: '2026-01-01T03:55:00.000Z', + }, + { + id: 18701, + text: '●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('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>) => { + 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 }) => { const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [], accessPages: [] }; await install(page, state); diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index a1afda8d..1e4daab3 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -66,11 +66,12 @@ const nationAccess = computed(() => ({ })); const nationColor = computed(() => nation.value?.color ?? '#000000'); 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 => { - 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: '' }); if (!time) return formatLog(entry.text); - return formatLog(`${entry.text} ${time}`); + return formatLog(`${entry.text} <1>${time}`); }; let surveyNoticeTimer: ReturnType | null = null; diff --git a/docs/frontend-legacy-parity.md b/docs/frontend-legacy-parity.md index 8ac69488..a4e217cf 100644 --- a/docs/frontend-legacy-parity.md +++ b/docs/frontend-legacy-parity.md @@ -122,6 +122,21 @@ Adding or changing a frontend route requires: Pixel snapshots may be added after these structural assertions pass. Dynamic 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 webpack assets, run: diff --git a/tools/frontend-legacy-parity/reference-personal-war-log-font.mjs b/tools/frontend-legacy-parity/reference-personal-war-log-font.mjs new file mode 100644 index 00000000..3d81d429 --- /dev/null +++ b/tools/frontend-legacy-parity/reference-personal-war-log-font.mjs @@ -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 = [ + '●10월:천귀병으로 ⓝ염행의 보병을 수비합니다. <1>12:54', + '●10월:천귀병으로 ⓝ염행의 보병을 공격합니다. <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) => `
${formatLog(record)}
`).join(''); + await page.setContent( + `` + + `
` + + `
개인 기록
${lines}
`, + { 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(); +}