merge: 최신 main을 행동 기반 접속 집계에 통합

This commit is contained in:
2026-08-19 14:14:35 +00:00
12 changed files with 397 additions and 37 deletions
+179 -9
View File
@@ -63,6 +63,7 @@ type FixtureState = {
joinConfig?: Record<string, unknown>; joinConfig?: Record<string, unknown>;
createGeneralInputs?: Array<Record<string, unknown>>; createGeneralInputs?: Array<Record<string, unknown>>;
mainTraits?: { personal: string; specialDomestic: string; specialWar: string }; mainTraits?: { personal: string; specialDomestic: string; specialWar: string };
hiddenSeedLogText?: string;
recentRecords?: { recentRecords?: {
global: Array<{ id: number; text: string; createdAt?: string }>; global: Array<{ id: number; text: string; createdAt?: string }>;
general: Array<{ id: number; text: string; createdAt?: string }>; general: Array<{ id: number; text: string; createdAt?: string }>;
@@ -484,7 +485,10 @@ const install = async (page: Page, state: FixtureState) => {
}); });
if (operation === 'general.getMyLog') { if (operation === 'general.getMyLog') {
state.generalLogQueries = (state.generalLogQueries ?? 0) + 1; state.generalLogQueries = (state.generalLogQueries ?? 0) + 1;
return response({ type: 'generalAction', logs: [{ id: 1, text: '<Y>기록</>' }] }); return response({
type: 'generalAction',
logs: [{ id: 1, text: state.hiddenSeedLogText ?? '<Y>기록</>' }],
});
} }
if (operation === 'general.instantRetreat') { if (operation === 'general.instantRetreat') {
state.instantRetreatInputs?.push(jsonInput); state.instantRetreatInputs?.push(jsonInput);
@@ -560,7 +564,11 @@ const install = async (page: Page, state: FixtureState) => {
['generalHistory', 'battleDetail', 'battleResult', 'generalAction'].includes(jsonInput.type) ['generalHistory', 'battleDetail', 'battleResult', 'generalAction'].includes(jsonInput.type)
? jsonInput.type ? jsonInput.type
: 'generalAction'; : 'generalAction';
return response({ type, generalId: 7, logs: [{ id: 1, text: `<Y>${type} 감찰 기록</>` }] }); return response({
type,
generalId: 7,
logs: [{ id: 1, text: state.hiddenSeedLogText ?? `<Y>${type} 감찰 기록</>` }],
});
} }
return response({ ok: true }); return response({ ok: true });
}); });
@@ -676,7 +684,7 @@ test('메인 카드의 국가·수도·관직·계급·병종은 Ref 출력명
await expect(page.locator('.main-page')).not.toContainText('che_'); await expect(page.locator('.main-page')).not.toContainText('che_');
}); });
test('메인 장수 동향과 개인 전투 기록은 모두 21px 행 간격을 유지한다', async ({ page }) => { test('메인 장수 동향과 개인 전투 기록은 Ref 행 간격·색상·글자 크기를 유지한다', async ({ page }) => {
const state: FixtureState = { const state: FixtureState = {
permission: 'head', permission: 'head',
myset: 3, myset: 3,
@@ -704,13 +712,15 @@ test('메인 장수 동향과 개인 전투 기록은 모두 21px 행 간격을
id: 18608, id: 18608,
text: text:
'<S>◆</>186년 9월:<div class="small_war_log">' + '<S>◆</>186년 9월:<div class="small_war_log">' +
'<span class="me"><span class="crew_type">귀병</span> ' + '<span class="me"><span class="name_plate"><span class="crew_type">귀병</span> ' +
'<span class="name_plate_cover">【<span class="name">Administrator</span>】</span> ' + '<span class="name_plate_cover">【<span class="name">Administrator</span>】</span></span> ' +
'<span class="remain_crew">0</span>(<span class="killed_crew">-2209</span>)</span> ' + '<span class="crew_plate"><span class="remain_crew">0</span>' +
'<span class="killed_plate">(<span class="killed_crew">-2209</span>)</span></span></span> ' +
'<span class="war_type war_type_defense">←</span> ' + '<span class="war_type war_type_defense">←</span> ' +
'<span class="you"><span class="remain_crew">1361</span>' + '<span class="you"><span class="crew_plate"><span class="remain_crew">1361</span>' +
'(<span class="killed_crew">-5539</span>) <span class="crew_type">기병</span> ' + '<span class="killed_plate">(<span class="killed_crew">-5539</span>)</span></span> ' +
'<span class="name_plate_cover"><span class="name">ⓝ뇌동</span>】</span></span></div>', '<span class="name_plate"><span class="crew_type">기병</span> ' +
'<span class="name_plate_cover">【<span class="name">ⓝ뇌동</span>】</span></span></span></div>',
createdAt: '2026-01-01T03:54:00.000Z', createdAt: '2026-01-01T03:54:00.000Z',
}, },
], ],
@@ -754,6 +764,16 @@ test('메인 장수 동향과 개인 전투 기록은 모두 21px 행 간격을
const battle = element.querySelector<HTMLElement>('.small_war_log'); const battle = element.querySelector<HTMLElement>('.small_war_log');
if (!battle) throw new Error('전투 요약 markup을 찾지 못했습니다.'); if (!battle) throw new Error('전투 요약 markup을 찾지 못했습니다.');
const battleRect = battle.getBoundingClientRect(); const battleRect = battle.getBoundingClientRect();
const requireElement = (selector: string): HTMLElement => {
const target = element.querySelector<HTMLElement>(selector);
if (!target) throw new Error(`전투 요약 요소를 찾지 못했습니다: ${selector}`);
return target;
};
const diamond = requireElement('span[style*="skyblue"]');
const namePlate = requireElement('.me .name_plate');
const nameCover = requireElement('.me .name_plate_cover');
const crewPlate = requireElement('.me .crew_plate');
const arrow = requireElement('.war_type_defense');
return { return {
line: { top: lineRect.top, height: lineRect.height }, line: { top: lineRect.top, height: lineRect.height },
battle: { battle: {
@@ -762,6 +782,14 @@ test('메인 장수 동향과 개인 전투 기록은 모두 21px 행 간격을
display: getComputedStyle(battle).display, display: getComputedStyle(battle).display,
}, },
lineHeight: getComputedStyle(element).lineHeight, lineHeight: getComputedStyle(element).lineHeight,
styles: {
diamondColor: getComputedStyle(diamond).color,
namePlateFontSize: getComputedStyle(namePlate).fontSize,
nameCoverColor: getComputedStyle(nameCover).color,
crewPlateColor: getComputedStyle(crewPlate).color,
crewPlateFontSize: getComputedStyle(crewPlate).fontSize,
defenseArrowColor: getComputedStyle(arrow).color,
},
}; };
}); });
}; };
@@ -770,6 +798,14 @@ test('메인 장수 동향과 개인 전투 기록은 모두 21px 행 간격을
expect(geometry.line.height).toBe(21); expect(geometry.line.height).toBe(21);
expect(geometry.battle.height).toBe(21); expect(geometry.battle.height).toBe(21);
expect(geometry.battle.top).toBeCloseTo(geometry.line.top, 0); expect(geometry.battle.top).toBeCloseTo(geometry.line.top, 0);
expect(geometry.styles).toEqual({
diamondColor: 'rgb(135, 206, 235)',
namePlateFontSize: '10.5px',
nameCoverColor: 'rgb(255, 255, 0)',
crewPlateColor: 'rgb(255, 69, 0)',
crewPlateFontSize: '12.6px',
defenseArrowColor: 'rgb(255, 0, 255)',
});
}; };
const desktopGeometry = await inspect( const desktopGeometry = await inspect(
@@ -791,6 +827,80 @@ 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('전투시드는 메인·내 정보·감찰부에서 숨긴 채 선택할 수 있다', async ({ page }) => {
const seedText = '(전투시드: 0123456789abcdef)';
const logText =
'<D><b>위</b></>의 <Y>검증장수</>가 <G><b>낙양</b></>으로 ' +
`진격합니다.<span class="hidden_but_copyable">${seedText}</span>`;
const state: FixtureState = {
permission: 'head',
myset: 3,
settingMutations: [],
accessPages: [],
hiddenSeedLogText: logText,
recentRecords: {
global: [{ id: 18611, text: logText }],
general: [],
history: [],
},
};
await install(page, state);
const inspectHiddenSeed = async (selector: string) => {
const seed = page.locator(selector);
await expect(seed).toHaveCount(1);
return seed.evaluate((element) => {
const range = document.createRange();
range.selectNodeContents(element);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
const result = {
text: element.textContent,
selectedText: selection?.toString(),
color: style.color,
fontSize: style.fontSize,
width: rect.width,
height: rect.height,
};
selection?.removeAllRanges();
return result;
});
};
const assertHiddenSeed = (result: Awaited<ReturnType<typeof inspectHiddenSeed>>) => {
expect(result.text).toBe(seedText);
expect(result.selectedText).toBe(seedText);
expect(result.color).toBe('rgba(0, 0, 0, 0)');
expect(result.fontSize).toBe('0px');
expect(result.width).toBe(0);
expect(result.height).toBe(0);
};
for (const viewport of [
{ width: 1000, height: 900 },
{ width: 500, height: 900 },
]) {
await page.setViewportSize(viewport);
await page.goto('');
const mainSeed = await inspectHiddenSeed('[data-record-bucket="global"] .hidden_but_copyable');
assertHiddenSeed(mainSeed);
await persistParityArtifact(page, `core-hidden-battle-seed-main-${viewport.width}`, mainSeed);
await page.goto('my-page');
const myPageSeed = await inspectHiddenSeed('.log-panel:first-child .hidden_but_copyable');
assertHiddenSeed(myPageSeed);
await persistParityArtifact(page, `core-hidden-battle-seed-my-page-${viewport.width}`, myPageSeed);
await page.goto('battle-center');
const battleCenterSeed = await inspectHiddenSeed('[data-log-type="generalAction"] .hidden_but_copyable');
assertHiddenSeed(battleCenterSeed);
await persistParityArtifact(page, `core-hidden-battle-seed-battle-center-${viewport.width}`, battleCenterSeed);
}
});
test('메인 개인 기록의 공격·수비 시각은 Ref와 같은 90% 글자 크기로 표시한다', async ({ page }) => { test('메인 개인 기록의 공격·수비 시각은 Ref와 같은 90% 글자 크기로 표시한다', async ({ page }) => {
const state: FixtureState = { const state: FixtureState = {
permission: 'head', permission: 'head',
@@ -1099,6 +1209,66 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
await persistParityArtifact(page, 'core-my-page-mobile', mobile); await persistParityArtifact(page, 'core-my-page-mobile', mobile);
}); });
test('내 정보&설정의 지난 플레이는 기본 탐색과 분리되어 오른쪽에 정렬된다', async ({ page }) => {
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
await install(page, state);
for (const viewport of [
{ name: 'desktop', width: 1000, height: 900 },
{ name: 'mobile', width: 390, height: 844 },
] as const) {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await page.goto('my-page');
const pastPlaysLink = page.getByRole('link', { name: '지난 플레이' });
await expect(pastPlaysLink).toHaveAttribute('href', `${gameBasePath}/past-plays`);
await pastPlaysLink.hover();
await pastPlaysLink.focus();
await expect(pastPlaysLink).toBeFocused();
const geometry = await page.locator('.title-row').evaluate((element) => {
const title = element.getBoundingClientRect();
const actions = element.querySelector<HTMLElement>('.title-actions')!.getBoundingClientRect();
const navigation = element.querySelector<HTMLElement>('.navigation-actions')!.getBoundingClientRect();
const back = element.querySelector<HTMLAnchorElement>('.navigation-actions a')!.getBoundingClientRect();
const refresh = element.querySelector<HTMLButtonElement>('.navigation-actions button')!.getBoundingClientRect();
const past = element.querySelector<HTMLAnchorElement>('.past-plays-link')!.getBoundingClientRect();
const pastStyle = getComputedStyle(element.querySelector<HTMLAnchorElement>('.past-plays-link')!);
return {
title: { left: title.left, right: title.right },
actions: { left: actions.left, right: actions.right },
navigation: { left: navigation.left, right: navigation.right },
back: { top: back.top, right: back.right },
refresh: { top: refresh.top, right: refresh.right },
past: { top: past.top, left: past.left, right: past.right },
pastStyle: {
cursor: pastStyle.cursor,
minHeight: pastStyle.minHeight,
backgroundColor: pastStyle.backgroundColor,
},
scrollWidth: document.documentElement.scrollWidth,
};
});
expect(geometry.actions.left).toBeCloseTo(geometry.title.left + 1, 0);
expect(geometry.actions.right).toBeCloseTo(geometry.title.right - 1, 0);
expect(geometry.navigation.left).toBeCloseTo(geometry.actions.left, 0);
expect(geometry.past.right).toBeCloseTo(geometry.actions.right, 0);
expect(geometry.past.left).toBeGreaterThan(geometry.navigation.right);
expect(geometry.back.top).toBeCloseTo(geometry.past.top, 0);
expect(geometry.refresh.top).toBeCloseTo(geometry.past.top, 0);
expect(geometry.pastStyle).toEqual({
cursor: 'pointer',
minHeight: '34px',
backgroundColor: 'rgb(49, 95, 134)',
});
expect(geometry.scrollWidth).toBe(viewport.width);
await persistParityArtifact(page, `core-my-page-past-plays-${viewport.name}`, geometry);
await pastPlaysLink.click();
await expect(page).toHaveURL(new RegExp(`${gameBasePath}/past-plays$`, 'u'));
}
});
for (const [label, failure] of [ for (const [label, failure] of [
['daemon timeout', 'TIMEOUT'], ['daemon timeout', 'TIMEOUT'],
['engine transaction 오류', 'INTERNAL_SERVER_ERROR'], ['engine transaction 오류', 'INTERNAL_SERVER_ERROR'],
+36
View File
@@ -87,3 +87,39 @@ textarea {
.small_war_log { .small_war_log {
display: inline-block; display: inline-block;
} }
/*
* Ref's common stylesheet keeps diagnostic seeds in the DOM so they remain
* copyable, while removing them from every normal log rendering surface.
* This marker can arrive through main, personal, audit, and history log APIs,
* so the display contract belongs to the app-wide legacy markup boundary.
*/
.hidden_but_copyable {
color: rgba(0, 0, 0, 0) !important;
font-size: 0;
}
.small_war_log .war_type_attack {
color: cyan;
}
.small_war_log .war_type_defense {
color: magenta;
}
.small_war_log .war_type_siege {
color: white;
}
.small_war_log .name_plate {
font-size: 0.75em;
}
.small_war_log .name_plate_cover {
color: yellow;
}
.small_war_log .crew_plate {
color: orangered;
font-size: 90%;
}
@@ -84,11 +84,6 @@ const unavailableText: Record<GeneralRecordType, string> = {
border-bottom: 0; border-bottom: 0;
} }
.log-line :deep(.hidden_but_copyable) {
color: transparent !important;
font-size: 0;
}
.empty { .empty {
padding: 2px 8px; padding: 2px 8px;
color: #999; color: #999;
-5
View File
@@ -763,11 +763,6 @@ button {
vertical-align: top; vertical-align: top;
} }
.record-line :deep(.hidden_but_copyable) {
color: transparent !important;
font-size: 0;
}
.record-empty { .record-empty {
color: #aaa; color: #aaa;
} }
+17 -3
View File
@@ -365,9 +365,13 @@ onMounted(() => {
<main id="container" class="legacy-page bg0 responsive-settings-page" :class="`screen-${screenMode}`"> <main id="container" class="legacy-page bg0 responsive-settings-page" :class="`screen-${screenMode}`">
<div class="title-row"> <div class="title-row">
<span> </span> <span> </span>
<RouterLink class="legacy-button" to="/past-plays">지난 플레이</RouterLink> <div class="title-actions">
<RouterLink class="legacy-button" to="/">돌아가기</RouterLink> <div class="navigation-actions">
<button class="legacy-button" type="button" @click="() => loadPage()">새로고침</button> <RouterLink class="legacy-button" to="/">돌아가기</RouterLink>
<button class="legacy-button" type="button" @click="() => loadPage()">새로고침</button>
</div>
<RouterLink class="legacy-button past-plays-link" to="/past-plays">지난 플레이</RouterLink>
</div>
</div> </div>
<div v-if="error" class="error-row">{{ error }}</div> <div v-if="error" class="error-row">{{ error }}</div>
@@ -675,6 +679,16 @@ onMounted(() => {
height: 18px; height: 18px;
letter-spacing: 0; letter-spacing: 0;
} }
.title-actions {
display: flex;
width: 100%;
align-items: flex-start;
justify-content: space-between;
}
.navigation-actions {
display: flex;
gap: 4px;
}
.legacy-button, .legacy-button,
button, button,
select, select,
+21
View File
@@ -137,6 +137,27 @@ REF_PERSONAL_WAR_LOG_ARTIFACT_DIR=/path/to/ignored/artifacts \
`MENU_PARITY_ARTIFACT_DIR`를 지정하면 1200×900·500×900 screenshot과 computed `MENU_PARITY_ARTIFACT_DIR`를 지정하면 1200×900·500×900 screenshot과 computed
style JSON을 남깁니다. style JSON을 남깁니다.
같은 Ref collector는 “진격합니다.” 전투 seed marker도 함께 렌더링하여
desktop/mobile의 투명색, 0px 글자 크기, 0×0 rect와 selection text 보존을
수집합니다. Core의 대응 test는 `inGameMenus.spec.ts`의 “전투시드는 메인·내
정보·감찰부에서 숨긴 채 선택할 수 있다”이며 `PLAYWRIGHT_FRONTEND_MODE=production`
`MENU_PARITY_ARTIFACT_DIR`를 함께 지정하면 세 화면의 1000×900·500×900
screenshot과 computed JSON을 남깁니다.
개인 전투 결과 요약의 Ref 색상·글자 크기는 실제 `small_war_log` class 구조와
빌드 CSS를 사용하는 별도 정적 Chromium fixture로 재현합니다. 방어 화살표,
장수명 괄호, 병력 수치와 병종·장수/병력 글자 크기를 desktop/mobile에서
수집하며 live session·DB writer 검증과는 구분합니다.
```sh
REF_SAM_ROOT=/path/to/ref/sam \
REF_PERSONAL_BATTLE_LOG_ARTIFACT_DIR=/path/to/ignored/artifacts \
node tools/frontend-legacy-parity/reference-personal-battle-log-colors.mjs
```
대응하는 Core 검증은 `inGameMenus.spec.ts`의 “개인 전투 기록” test이며 같은
viewport에서 한 줄 geometry와 computed 색상·글자 크기를 함께 검사합니다.
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:
@@ -12,6 +12,7 @@ import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js';
import { JosaUtil } from '@sammo-ts/common'; import { JosaUtil } from '@sammo-ts/common';
import { z } from 'zod'; import { z } from 'zod';
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import type { GeneralTurnCommandSpec } from './index.js'; import type { GeneralTurnCommandSpec } from './index.js';
import type { MapDefinition } from '@sammo-ts/logic/world/types.js'; import type { MapDefinition } from '@sammo-ts/logic/world/types.js';
import { normalizeLegacyIntegerArg, parseArgsWithSchema } from '../parseArgs.js'; import { normalizeLegacyIntegerArg, parseArgsWithSchema } from '../parseArgs.js';
@@ -68,11 +69,8 @@ export class ActionResolver<
const destCityId = args.destCityId; const destCityId = args.destCityId;
const storedDestCityId = args.storedDestCityId ?? destCityId; const storedDestCityId = args.storedDestCityId ?? destCityId;
let destCityName = `도시(${destCityId})`; const destCityName =
if (context.map) { context.map?.cities.find((city) => city.id === destCityId)?.name ?? '알 수 없는 도시';
const c = context.map.cities.find((ct) => ct.id === destCityId);
if (c) destCityName = c.name;
}
const josaRo = JosaUtil.pick(destCityName, '로'); const josaRo = JosaUtil.pick(destCityName, '로');
@@ -132,6 +130,11 @@ export class ActionDefinition<
} }
} }
export const actionContextBuilder: ActionContextBuilder = (base, options) => ({
...base,
map: options.map,
});
export const commandSpec: GeneralTurnCommandSpec = { export const commandSpec: GeneralTurnCommandSpec = {
key: 'che_NPC능동', key: 'che_NPC능동',
category: '특수', // Valid category? Legacy didn't specify category in static prop usually, handled by mapping. Defaulting to '특수'. category: '특수', // Valid category? Legacy didn't specify category in static prop usually, handled by mapping. Defaulting to '특수'.
@@ -68,11 +68,8 @@ export class ActionResolver<
const goldCost = develCost * 5; const goldCost = develCost * 5;
// Log destination // Log destination
let destCityName = `도시(${destCityId})`; const destCityName =
if (context.map) { context.map?.cities.find((city) => city.id === destCityId)?.name ?? '알 수 없는 도시';
const c = context.map.cities.find((ct) => ct.id === destCityId);
if (c) destCityName = c.name;
}
const josaRo = JosaUtil.pick(destCityName, '로'); const josaRo = JosaUtil.pick(destCityName, '로');
@@ -68,11 +68,8 @@ export class ActionResolver<
const cost = context.develCost ?? 0; const cost = context.develCost ?? 0;
let destCityName = `도시(${destCityId})`; const destCityName =
if (context.map) { context.map?.cities.find((city) => city.id === destCityId)?.name ?? '알 수 없는 도시';
const c = context.map.cities.find((ct) => ct.id === destCityId);
if (c) destCityName = c.name;
}
const josaRo = JosaUtil.pick(destCityName, '로'); const josaRo = JosaUtil.pick(destCityName, '로');
@@ -0,0 +1,85 @@
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_BATTLE_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 record =
'<S>◆</>188년 2월:<div class="small_war_log">' +
'<span class="me"><span class="name_plate"><span class="crew_type">남귀</span> ' +
'<span class="name_plate_cover">【<span class="name">운영자</span>】</span></span> ' +
'<span class="crew_plate"><span class="remain_crew">0</span>' +
'<span class="killed_plate">(<span class="killed_crew">-4404</span>)</span></span></span> ' +
'<span class="war_type war_type_defense">←</span> ' +
'<span class="you"><span class="crew_plate"><span class="remain_crew">555</span>' +
'<span class="killed_plate">(<span class="killed_crew">-6845</span>)</span></span> ' +
'<span class="name_plate"><span class="crew_type">기병</span> ' +
'<span class="name_plate_cover">【<span class="name">ⓝ독야청정</span>】</span></span></span></div>';
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();
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><div class="fixture-line">${formatLog(record)}</div>` +
`</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').evaluate((element) => {
const requireElement = (selector) => {
const target = element.querySelector(selector);
if (!(target instanceof HTMLElement)) throw new Error(`Ref 전투기록 요소를 찾지 못했습니다: ${selector}`);
return target;
};
const rect = element.getBoundingClientRect();
const battle = requireElement('.small_war_log');
const battleRect = battle.getBoundingClientRect();
return {
text: element.textContent,
line: { height: rect.height, fontSize: getComputedStyle(element).fontSize },
battle: { height: battleRect.height, display: getComputedStyle(battle).display },
styles: {
diamondColor: getComputedStyle(requireElement('span[style*="skyblue"]')).color,
namePlateFontSize: getComputedStyle(requireElement('.me .name_plate')).fontSize,
nameCoverColor: getComputedStyle(requireElement('.me .name_plate_cover')).color,
crewPlateColor: getComputedStyle(requireElement('.me .crew_plate')).color,
crewPlateFontSize: getComputedStyle(requireElement('.me .crew_plate')).fontSize,
defenseArrowColor: getComputedStyle(requireElement('.war_type_defense')).color,
},
};
});
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-battle-log-colors-${viewport.name}.png`) }),
writeFile(
resolve(artifactDir, `ref-personal-battle-log-colors-${viewport.name}.json`),
`${JSON.stringify(output, null, 2)}\n`
),
]);
}
}
} finally {
await browser.close();
}
@@ -12,6 +12,8 @@ const css = await readFile(resolve(refRoot, 'dist_js/hwe_dynamic/vue/v_main.css'
const records = [ const records = [
'<C>●</>10월:천귀병으로 <Y>ⓝ염행</>의 보병을 <M>수비</>합니다. <1>12:54</>', '<C>●</>10월:천귀병으로 <Y>ⓝ염행</>의 보병을 <M>수비</>합니다. <1>12:54</>',
'<C>●</>10월:천귀병으로 <Y>ⓝ염행</>의 보병을 <M>공격</>합니다. <1>12:55</>', '<C>●</>10월:천귀병으로 <Y>ⓝ염행</>의 보병을 <M>공격</>합니다. <1>12:55</>',
'<D><b>위</b></>의 <Y>검증장수</>가 <G><b>낙양</b></>으로 진격합니다.' +
'<span class="hidden_but_copyable">(전투시드: 0123456789abcdef)</span>',
]; ];
const browser = await chromium.launch({ headless: true }); const browser = await chromium.launch({ headless: true });
@@ -43,6 +45,29 @@ try {
const time = spans.find((span) => /^\d{2}:\d{2}$/u.test(span.textContent ?? '')); const time = spans.find((span) => /^\d{2}:\d{2}$/u.test(span.textContent ?? ''));
const name = spans.find((span) => span.textContent === 'ⓝ염행'); const name = spans.find((span) => span.textContent === 'ⓝ염행');
const action = spans.find((span) => span.textContent === '수비' || span.textContent === '공격'); const action = spans.find((span) => span.textContent === '수비' || span.textContent === '공격');
const hiddenSeed = element.querySelector('.hidden_but_copyable');
if (hiddenSeed instanceof HTMLElement) {
const range = document.createRange();
range.selectNodeContents(hiddenSeed);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
const hiddenSeedRect = hiddenSeed.getBoundingClientRect();
const hiddenSeedStyle = getComputedStyle(hiddenSeed);
const result = {
text: hiddenSeed.textContent,
selectedText: selection?.toString(),
color: hiddenSeedStyle.color,
fontSize: hiddenSeedStyle.fontSize,
width: hiddenSeedRect.width,
height: hiddenSeedRect.height,
};
selection?.removeAllRanges();
return {
text: element.textContent,
hiddenSeed: result,
};
}
if ( if (
!(time instanceof HTMLElement) || !(time instanceof HTMLElement) ||
!(name instanceof HTMLElement) || !(name instanceof HTMLElement) ||
@@ -62,6 +87,7 @@ try {
timeFontSize: getComputedStyle(time).fontSize, timeFontSize: getComputedStyle(time).fontSize,
nameFontSize: getComputedStyle(name).fontSize, nameFontSize: getComputedStyle(name).fontSize,
actionFontSize: getComputedStyle(action).fontSize, actionFontSize: getComputedStyle(action).fontSize,
hiddenSeed: null,
}; };
}) })
); );
@@ -421,6 +421,21 @@ integration('general command success matrix', () => {
ignoredPathPatterns: ignoredLifecyclePaths, ignoredPathPatterns: ignoredLifecyclePaths,
}) })
).toEqual([]); ).toEqual([]);
if (action === 'che_이동' || action === 'che_강행') {
const actionLogSuffix = action === 'che_이동' ? '이동했습니다.' : '강행했습니다.';
expect(
semanticLogSignatures(
core.after.logs.filter((entry) => String(entry.text).includes(actionLogSuffix))
)
).toEqual(
semanticLogSignatures(
addedReferenceLogs(reference.before, reference.after.logs).filter((entry) =>
String(entry.text).includes(actionLogSuffix)
)
)
);
expect(core.after.logs.some((entry) => String(entry.text).includes('도시('))).toBe(false);
}
}, },
120_000 120_000
); );
@@ -564,6 +579,12 @@ integration('NPC active command boundary parity', () => {
ignoredPathPatterns: ignoredLifecyclePaths, ignoredPathPatterns: ignoredLifecyclePaths,
}) })
).toEqual([]); ).toEqual([]);
if (completed) {
expect(semanticLogSignatures(core.after.logs)).toEqual(
semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs))
);
expect(core.after.logs.some((entry) => String(entry.text).includes('도시('))).toBe(false);
}
}, },
120_000 120_000
); );