merge: align main UI grid with ref

This commit is contained in:
2026-08-12 07:45:55 +00:00
5 changed files with 235 additions and 115 deletions
+217 -33
View File
@@ -31,6 +31,7 @@ type NavigationFixture = {
forceSnapshotCalls?: number;
refreshDelayMs?: number;
largeCommandTable?: boolean;
reservedTurns?: Array<{ index: number; action: string; args: Record<string, unknown> }>;
dashboardResponses?: Array<{
bytes: number;
contextKind: string | null;
@@ -116,12 +117,13 @@ const emitReadModelChanges = (page: Page, changes: ReturnType<typeof readModelCh
const commandTableFixture = (large: boolean, blockedCount = 0) => ({
general: large
? [
{
category: '일반',
values: Array.from({ length: 48 }, (_, index) => ({
? ['내정', '군사', '계략'].map((category, categoryIndex) => ({
category,
values: Array.from({ length: 16 }, (_, localIndex) => {
const index = categoryIndex * 16 + localIndex;
return {
key: `command-${index}`,
name: `명령 ${index}`,
name: index === 0 ? '주민 선정과 장기 도시 개발' : `명령 ${index}`,
reqArg: index % 2 === 0,
possible: index >= blockedCount,
status: index >= blockedCount ? 'available' : 'blocked',
@@ -135,9 +137,9 @@ const commandTableFixture = (large: boolean, blockedCount = 0) => ({
max: 10_000,
},
],
})),
},
]
};
}),
}))
: [],
nation: [],
inputOptions: {
@@ -229,6 +231,7 @@ const generalContext = (state: NavigationFixture) => ({
dex: [350, 100_000, 500_000, 1_000_000, 1_275_975],
},
items: { horse: null, weapon: null, book: null, item: null },
turnTime: '0185-01-01T00:00:00.000Z',
},
city: {
id: 1,
@@ -400,7 +403,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
}
if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] });
if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') {
return response({ turns: [], revision: 0 });
return response({ turns: state.reservedTurns ?? [], revision: 0 });
}
if (operation === 'messages.getRecent') return response(emptyMessages(state.permission));
if (operation === 'messages.getContacts') return response({ nation: [] });
@@ -628,9 +631,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
});
test('main city and general cards render every ref bar plus dual dexterity progress at ref heights', async ({
page,
}) => {
test('main cards and command input stay inside their Ref-sized grid slots', async ({ page }) => {
const state: NavigationFixture = {
officerLevel: 1,
permission: 0,
@@ -639,6 +640,12 @@ test('main city and general cards render every ref bar plus dual dexterity progr
npcMode: 1,
generalMeCalls: 0,
operations: [],
largeCommandTable: true,
reservedTurns: Array.from({ length: 14 }, (_, index) => ({
index,
action: `command-${index}`,
args: {},
})),
};
await installFixture(page, state);
await page.setViewportSize({ width: 1200, height: 900 });
@@ -647,28 +654,15 @@ test('main city and general cards render every ref bar plus dual dexterity progr
const cityBars = page.locator('[data-main-target="city"] [role="progressbar"]');
const statBars = page.locator('[data-stat-progress] [role="progressbar"]');
const experienceBar = page.locator('[data-experience-progress] [role="progressbar"]');
const dexRows = page.locator('[data-dex-progress]');
await expect(cityBars).toHaveCount(8);
await expect(statBars).toHaveCount(3);
await expect(experienceBar).toHaveCount(1);
await expect(dexRows).toHaveCount(5);
await expect(dexRows.locator('[role="progressbar"]')).toHaveCount(10);
await expect(page.locator('[data-main-target="general"] [data-dex-progress]')).toHaveCount(0);
await expect(page.locator('[data-main-target="general"] [role="progressbar"]')).toHaveCount(4);
expect(await cityBars.first().evaluate((element) => element.getBoundingClientRect().height)).toBe(9);
expect(await statBars.first().evaluate((element) => element.getBoundingClientRect().height)).toBe(12);
expect(await experienceBar.evaluate((element) => element.getBoundingClientRect().height)).toBe(12);
const firstDexBars = dexRows.first().locator('[role="progressbar"]');
expect(await firstDexBars.nth(0).evaluate((element) => element.getBoundingClientRect().height)).toBe(12);
expect(await firstDexBars.nth(1).evaluate((element) => element.getBoundingClientRect().height)).toBe(9);
await expect(dexRows.nth(3).locator('[role="progressbar"]').nth(0)).toHaveAttribute('aria-valuemax', '100');
await expect(dexRows.nth(3).locator('[role="progressbar"]').nth(0)).toHaveAttribute(
'aria-label',
/1,000,000 \/ 1,275,975 \(EX\+\)/
);
await expect(dexRows.nth(3).locator('[role="progressbar"]').nth(1)).toHaveAttribute('aria-label', /까지 .* 남음/);
await expect(dexRows.nth(4).locator('[role="progressbar"]').nth(1)).toHaveAttribute('aria-label', /EX\+ 달성/);
const texture = await cityBars.first().evaluate((element) => getComputedStyle(element).backgroundImage);
const fillTexture = await cityBars
.first()
@@ -677,6 +671,100 @@ test('main city and general cards render every ref bar plus dual dexterity progr
expect(texture).toContain('/game/pr5.gif');
expect(fillTexture).toContain('/game/pb5.gif');
const desktopGeometry = await page.evaluate(() => {
const box = (element: Element) => {
const rect = element.getBoundingClientRect();
return {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
right: rect.right,
bottom: rect.bottom,
};
};
const target = (name: string) => {
const panel = document.querySelector<HTMLElement>(`[data-main-target="${name}"]`);
if (!panel) throw new Error(`${name} panel missing`);
return panel;
};
const general = target('general');
const nation = target('nation');
const city = target('city');
const commands = target('commands');
const generalBody = general.querySelector<HTMLElement>('.general-body');
const editor = commands.querySelector<HTMLElement>('.reserved-command-editor');
const controlPad = commands.querySelector<HTMLElement>('.control-pad');
const bottomActions = commands.querySelector<HTMLElement>('.bottom-actions');
if (!generalBody || !editor || !controlPad || !bottomActions) throw new Error('main layout probe missing');
const visibleControlBoxes = [...controlPad.children]
.filter((element) => getComputedStyle(element).display !== 'none')
.map(box);
const bottomActionBoxes = [...bottomActions.children].map(box);
return {
general: box(general),
generalBody: box(generalBody),
nation: box(nation),
city: box(city),
commands: box(commands),
editor: box(editor),
controlPad: box(controlPad),
controlColumns: getComputedStyle(controlPad).gridTemplateColumns,
visibleControlBoxes,
bottomActionBoxes,
commandHorizontalOverflow: commands.scrollWidth - commands.clientWidth,
commandVerticalOverflow: commands.scrollHeight - commands.clientHeight,
};
});
expect(desktopGeometry.general.y).toBe(desktopGeometry.nation.y);
expect(desktopGeometry.general.bottom).toBe(desktopGeometry.nation.bottom);
expect(desktopGeometry.commands.bottom).toBe(desktopGeometry.city.bottom);
expect(desktopGeometry.generalBody.bottom).toBeLessThanOrEqual(desktopGeometry.general.bottom);
expect(desktopGeometry.editor.right).toBeLessThanOrEqual(desktopGeometry.commands.right);
expect(desktopGeometry.commandHorizontalOverflow).toBeLessThanOrEqual(0);
expect(desktopGeometry.commandVerticalOverflow).toBeLessThanOrEqual(0);
expect(desktopGeometry.controlColumns.split(' ')).toHaveLength(3);
expect(desktopGeometry.visibleControlBoxes).toHaveLength(3);
expect(new Set(desktopGeometry.visibleControlBoxes.map(({ y }) => y)).size).toBe(1);
expect(desktopGeometry.bottomActionBoxes).toHaveLength(3);
expect(new Set(desktopGeometry.bottomActionBoxes.map(({ y }) => y)).size).toBe(1);
const modeButton = page.locator('[data-main-target="commands"] .control-pad').getByRole('button', {
name: '고급 모드',
});
await modeButton.hover();
await modeButton.focus();
await expect(modeButton).toBeFocused();
await modeButton.click();
await page.locator('[data-main-target="commands"] .select-command').click();
const picker = page.getByTestId('command-picker');
await expect(picker).toBeVisible();
const pickerGeometry = await picker.evaluate((element) => {
const rect = element.getBoundingClientRect();
const editor = element.closest('.reserved-command-editor')?.getBoundingClientRect();
const categories = element.querySelector<HTMLElement>('.category-list');
if (!editor || !categories) throw new Error('command picker geometry missing');
return {
left: rect.left,
right: rect.right,
editorLeft: editor.left,
editorRight: editor.right,
categoryColumns: getComputedStyle(categories).gridTemplateColumns,
categoryBoxes: [...categories.children].map((child) => {
const childRect = child.getBoundingClientRect();
return { x: childRect.x, y: childRect.y, width: childRect.width, height: childRect.height };
}),
horizontalOverflow: element.scrollWidth - element.clientWidth,
};
});
expect(pickerGeometry.left).toBeGreaterThanOrEqual(pickerGeometry.editorLeft);
expect(pickerGeometry.right).toBeLessThanOrEqual(pickerGeometry.editorRight);
expect(pickerGeometry.categoryColumns.split(' ')).toHaveLength(3);
expect(pickerGeometry.categoryBoxes).toHaveLength(3);
expect(new Set(pickerGeometry.categoryBoxes.map(({ y }) => y)).size).toBe(1);
expect(pickerGeometry.horizontalOverflow).toBeLessThanOrEqual(0);
await picker.getByRole('button', { name: '명령 입력 닫기' }).click();
const captureProgress = async (name: string) => {
if (!artifactRoot) return;
await mkdir(artifactRoot, { recursive: true });
@@ -696,27 +784,123 @@ test('main city and general cards render every ref bar plus dual dexterity progr
};
})
);
const layout = await page.evaluate(() => {
const describe = (selector: string) => {
const element = document.querySelector<HTMLElement>(selector);
if (!element) return null;
const rect = element.getBoundingClientRect();
return {
rect: {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
right: rect.right,
bottom: rect.bottom,
},
clientWidth: element.clientWidth,
clientHeight: element.clientHeight,
scrollWidth: element.scrollWidth,
scrollHeight: element.scrollHeight,
overflowX: getComputedStyle(element).overflowX,
overflowY: getComputedStyle(element).overflowY,
boxSizing: getComputedStyle(element).boxSizing,
cssWidth: getComputedStyle(element).width,
minWidth: getComputedStyle(element).minWidth,
paddingInline: getComputedStyle(element).paddingInline,
borderInline: `${getComputedStyle(element).borderLeftWidth} ${getComputedStyle(element).borderRightWidth}`,
};
};
return {
viewport: { width: innerWidth, height: innerHeight },
documentWidth: document.documentElement.scrollWidth,
roots: {
html: describe('html'),
body: describe('body'),
app: describe('#app'),
main: describe('main.main-page'),
},
overflowingElements: [...document.body.querySelectorAll<HTMLElement>('*')]
.map((element) => {
const rect = element.getBoundingClientRect();
return {
tag: element.tagName.toLowerCase(),
className: element.className,
testId: element.dataset.testid ?? null,
left: rect.left,
right: rect.right,
width: rect.width,
};
})
.filter(({ left, right }) => left < 0 || right > innerWidth)
.slice(0, 20),
city: describe('[data-main-target="city"]'),
nation: describe('[data-main-target="nation"]'),
general: describe('[data-main-target="general"]'),
commands: describe('[data-main-target="commands"]'),
commandEditor: describe('[data-main-target="commands"] .reserved-command-editor'),
commandPicker: describe('[data-testid="command-picker"]'),
};
});
await Promise.all([
page.screenshot({ path: resolve(artifactRoot, `progress-bars-${name}.png`), fullPage: true }),
writeFile(resolve(artifactRoot, `progress-bars-${name}.json`), `${JSON.stringify(measurement, null, 2)}\n`),
writeFile(
resolve(artifactRoot, `progress-bars-${name}.json`),
`${JSON.stringify({ layout, progress: measurement }, null, 2)}\n`
),
]);
};
await captureProgress('desktop-1200');
await page.locator('[data-main-target="commands"] .control-pad').getByRole('button', { name: '일반 모드' }).click();
await page.setViewportSize({ width: 500, height: 900 });
await expect(page.locator('.layout-mobile')).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(500);
await expect(page.locator('[data-main-target="city"] [role="progressbar"]')).toHaveCount(8);
await expect(page.locator('[data-main-target="general"] [role="progressbar"]')).toHaveCount(14);
await expect(page.locator('[data-main-target="general"] [role="progressbar"]')).toHaveCount(4);
expect(
await page
.locator('[data-main-target="city"] [role="progressbar"]')
.first()
.evaluate((element) => element.getBoundingClientRect().height)
).toBe(9);
const mobileGeometry = await page.locator('[data-main-target="commands"]').evaluate((commands) => {
const editor = commands.querySelector<HTMLElement>('.reserved-command-editor');
const controls = commands.querySelector<HTMLElement>('.control-pad');
if (!editor || !controls) throw new Error('mobile command layout probe missing');
const panelRect = commands.getBoundingClientRect();
const editorRect = editor.getBoundingClientRect();
const controlBoxes = [...controls.children]
.filter((element) => getComputedStyle(element).display !== 'none')
.map((element) => {
const rect = element.getBoundingClientRect();
return { y: rect.y, width: rect.width };
});
return {
panelWidth: panelRect.width,
panelHeight: panelRect.height,
editorLeft: editorRect.left,
editorRight: editorRect.right,
panelLeft: panelRect.left,
panelRight: panelRect.right,
horizontalOverflow: commands.scrollWidth - commands.clientWidth,
verticalOverflow: commands.scrollHeight - commands.clientHeight,
controlColumns: getComputedStyle(controls).gridTemplateColumns,
controlBoxes,
};
});
expect(mobileGeometry.panelHeight).toBe(645);
expect(mobileGeometry.editorLeft).toBeGreaterThanOrEqual(mobileGeometry.panelLeft);
expect(mobileGeometry.editorRight).toBeLessThanOrEqual(mobileGeometry.panelRight);
expect(mobileGeometry.horizontalOverflow).toBeLessThanOrEqual(0);
expect(mobileGeometry.verticalOverflow).toBeLessThanOrEqual(0);
expect(mobileGeometry.controlColumns.split(' ')).toHaveLength(3);
expect(mobileGeometry.controlBoxes).toHaveLength(3);
expect(new Set(mobileGeometry.controlBoxes.map(({ y }) => y)).size).toBe(1);
await captureProgress('mobile-500');
});
test('the 939/940 boundary switches to the Ref-style 502px single document', async ({ page }) => {
test('the 939/940 boundary switches to the Ref-style 500px single document', async ({ page }) => {
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
@@ -738,7 +922,7 @@ test('the 939/940 boundary switches to the Ref-style 502px single document', asy
await expect(page.locator('.layout-mobile')).toBeVisible();
expect(await gridColumnCount(page, '.main-global-menu')).toBe(4);
expect(await gridColumnCount(page, '.main-nation-menu')).toBe(5);
await expect(page.locator('.main-mobile-bottom')).toHaveCount(0);
await expect(page.locator('.main-mobile-bottom')).toBeVisible();
await page.setViewportSize({ width: 500, height: 900 });
const documentGeometry = await page.locator('.main-page').evaluate((element) => {
@@ -752,8 +936,8 @@ test('the 939/940 boundary switches to the Ref-style 502px single document', asy
});
expect(documentGeometry).toMatchObject({
x: 0,
width: 502,
scrollWidth: 502,
width: 500,
scrollWidth: 500,
});
expect(documentGeometry.height).toBeGreaterThan(900);
for (const selector of [
@@ -246,8 +246,8 @@ const clickOutsideMenu = (event: Event) => {
<strong>{{ props.name ?? '-' }}</strong
><span>{{ props.title }}</span>
</div>
<div class="clock">{{ props.currentTime }}</div>
<button type="button" @click="editMode = !editMode">{{ editMode ? '일반 모드' : '고급 모드' }}</button>
<div class="clock">{{ props.currentTime }}</div>
<details class="legacy-menu">
<summary>반복</summary>
<div class="menu-items">
@@ -364,7 +364,7 @@ const clickOutsideMenu = (event: Event) => {
</details>
</template>
<details class="legacy-menu">
<details v-if="props.compact" class="legacy-menu">
<summary>당기기</summary>
<div class="menu-items">
<button
@@ -379,7 +379,7 @@ const clickOutsideMenu = (event: Event) => {
</button>
</div>
</details>
<details class="legacy-menu">
<details v-if="props.compact" class="legacy-menu">
<summary>미루기</summary>
<div class="menu-items">
<button
@@ -169,7 +169,7 @@ const commandTitle = (command: CommandAvailability) =>
.category-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0;
}
@@ -2,7 +2,7 @@
import { computed } from 'vue';
import SkeletonLines from '../ui/SkeletonLines.vue';
import LegacyProgressBar from '../ui/LegacyProgressBar.vue';
import { dexProgress, legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress';
import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress';
interface GeneralStats {
leadership: number;
@@ -15,7 +15,6 @@ interface GeneralProgression {
dedicationLevel: number;
statExperience?: { leadership: number; strength: number; intelligence: number };
statUpgradeLimit?: number;
dex: number[];
}
interface GeneralInfo {
@@ -66,14 +65,6 @@ const statRows = computed(() => {
].map((entry) => ({ ...entry, limit, percent: ratioPercent(entry.accumulated, limit) }));
});
const dexRows = computed(() => {
const dex = props.general?.progression?.dex ?? [];
return ['보병', '궁병', '기병', '귀병', '차병'].map((label, index) => {
const value = dex[index] ?? 0;
return { label, value, progress: dexProgress(value) };
});
});
const experiencePercent = computed(() =>
legacyExperiencePercent(props.general?.experience ?? 0, props.general?.progression?.experienceLevel ?? 0)
);
@@ -130,35 +121,14 @@ const experiencePercent = computed(() =>
</div>
<span class="experience-total">명성 {{ props.general.experience.toLocaleString() }}</span>
</div>
<div class="dex-title"> </div>
<div v-for="row of dexRows" :key="row.label" class="dex-row" :data-dex-progress="row.label">
<span class="cell-label">{{ row.label }}</span>
<strong :style="{ color: row.progress.color }">{{ row.progress.name }}</strong>
<span class="dex-value">{{ (row.value / 1_000).toFixed(1) }}K</span>
<div class="dex-bars">
<LegacyProgressBar
:percent="row.progress.overallPercent"
:label="`${row.label} 전체 ${row.value.toLocaleString()} / 1,275,975 (EX+)`"
/>
<LegacyProgressBar
:height="7"
variant="grade"
:percent="row.progress.gradePercent"
:label="
row.progress.nextName
? `${row.label} ${row.progress.name}에서 ${row.progress.nextName}까지 ${row.progress.remaining.toLocaleString()} 남음`
: `${row.label} EX+ 달성`
"
/>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.general-title {
box-sizing: border-box;
height: 20px;
min-height: 20px;
padding: 1px 6px;
border-bottom: 1px solid #777;
@@ -171,13 +141,15 @@ const experiencePercent = computed(() =>
.stat-progress-grid {
display: grid;
grid-template-columns: repeat(3, minmax(30px, 1fr) minmax(34px, 1fr) 45px);
grid-auto-rows: 21px;
font-size: 12px;
}
.stat-progress-grid > *,
.legacy-grid > * {
box-sizing: border-box;
min-height: 22px;
height: 21px;
min-height: 0;
border-right: 1px solid #666;
border-bottom: 1px solid #666;
padding: 2px 4px;
@@ -186,8 +158,7 @@ const experiencePercent = computed(() =>
}
.cell-label,
.legacy-grid > span,
.dex-title {
.legacy-grid > span {
background: rgb(20 75 42 / 70%);
text-align: center;
}
@@ -207,13 +178,16 @@ const experiencePercent = computed(() =>
.legacy-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
grid-auto-rows: 21px;
font-size: 12px;
}
.experience-row {
display: grid;
box-sizing: border-box;
grid-template-columns: 32px 38px minmax(120px, 1fr) 112px;
min-height: 22px;
height: 20px;
min-height: 20px;
border-bottom: 1px solid #666;
font-size: 12px;
}
@@ -227,44 +201,6 @@ const experiencePercent = computed(() =>
text-align: center;
}
.dex-title {
min-height: 20px;
border-bottom: 1px solid #666;
font-size: 12px;
font-weight: 700;
line-height: 20px;
}
.dex-row {
display: grid;
grid-template-columns: 64px 40px 60px minmax(0, 1fr);
min-height: 26px;
border-bottom: 1px solid #666;
font-size: 12px;
}
.dex-row > * {
display: grid;
align-content: center;
box-sizing: border-box;
border-right: 1px solid #666;
padding: 0 3px;
text-align: center;
}
.dex-row > strong {
font-weight: 400;
}
.dex-value {
text-align: right;
}
.dex-bars {
gap: 1px;
padding: 1px;
}
.empty {
color: rgba(232, 221, 196, 0.6);
}
+3 -3
View File
@@ -585,7 +585,7 @@ button {
.layout-desktop > [data-main-target='commands'] {
grid-column: 8 / 11;
grid-row: 1;
height: 589px;
height: 645px;
width: 290px;
margin-left: 10px;
overflow-y: auto;
@@ -719,7 +719,7 @@ button {
}
.layout-mobile [data-main-target='commands'] {
height: 586px;
height: 645px;
overflow-y: auto;
}
@@ -780,7 +780,7 @@ button {
}
.main-page {
width: 502px;
width: 500px;
min-height: 3688px;
}