Merge remote-tracking branch 'origin/main' into feat/ref-nation-card-20260813

This commit is contained in:
2026-08-13 17:55:25 +00:00
9 changed files with 527 additions and 162 deletions
+146 -27
View File
@@ -465,6 +465,7 @@ test('enters general and nation command arguments and sends exact values', async
const requests = await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('/');
await expect(page.getByTestId('current-city-marker')).toHaveCount(0);
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
await page.getByTestId('command-picker').getByRole('button', { name: /화계/ }).click();
@@ -474,6 +475,12 @@ test('enters general and nation command arguments and sends exact values', async
await expect(form.getByTestId('command-argument-guidance')).toContainText('선택한 도시에 화계를 실행합니다.');
await expect(form.getByTestId('command-map-target-summary')).toContainText('현재 도시에서 0칸');
const commandMap = form.getByTestId('command-argument-map');
const currentCityMarker = commandMap.getByTestId('current-city-marker');
const selectionStatus = form.getByTestId('command-map-selection-status');
await expect(currentCityMarker).toHaveText('현재');
await expect(currentCityMarker).toHaveAttribute('aria-label', '현재 도시 업');
await expect(selectionStatus).toContainText('현재 도시업');
await expect(selectionStatus).toContainText('선택 도시업');
const mapCities = commandMap.locator('.city-base');
await expect(mapCities).toHaveCount(2);
await expect(commandMap.locator('.city-bg')).toHaveCount(2);
@@ -515,11 +522,17 @@ test('enters general and nation command arguments and sends exact values', async
await mapCities.nth(1).click();
await expect(form.locator('select')).toHaveValue('2');
await expect(form.getByTestId('command-map-target-summary')).toContainText('현재 도시에서 1칸');
await expect(selectionStatus).toContainText('현재 도시업');
await expect(selectionStatus).toContainText('선택 도시허창');
await expect(currentCityMarker).toHaveAttribute('aria-label', '현재 도시 업');
await expect(mapCities.nth(0)).toHaveClass(/mine/);
await expect(mapCities.nth(1)).toHaveClass(/selected/);
await mapCities.nth(1).hover();
expect(await mapCities.nth(1).evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer');
await mapCities.nth(1).focus();
await expect(mapCities.nth(1)).toBeFocused();
await expect(page).toHaveURL(/\/$/);
await commandMap.screenshot({ path: test.info().outputPath('main-city-current-marker-desktop.png') });
const mapGeometry = await form.getByTestId('command-argument-map').evaluate((element) => {
const area = element.querySelector<HTMLElement>('.map-area')!;
const rect = area.getBoundingClientRect();
@@ -565,7 +578,7 @@ test('enters general and nation command arguments and sends exact values', async
expect(Number.parseFloat(geometry.fontSize)).toBeGreaterThanOrEqual(10);
});
test('shows Ref recruitment details and preserves the 1000px desktop and 500px mobile information layouts', async ({
test('uses a Ref-style full recruitment page without horizontal overflow on desktop or mobile', async ({
page,
}, testInfo) => {
const requests = await install(page);
@@ -576,7 +589,10 @@ test('shows Ref recruitment details and preserves the 1000px desktop and 500px m
let picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: '내정', exact: true }).click();
await picker.getByRole('button', { name: '징병', exact: true }).click();
let form = picker.getByTestId('recruitment-command-form');
await expect(picker).toHaveAttribute('role', 'dialog');
await expect(picker).toHaveAttribute('aria-modal', 'true');
await expect(picker.getByRole('button', { name: '명령 입력 닫기', exact: true })).toBeFocused();
const form = picker.getByTestId('recruitment-command-form');
await expect(form).toContainText('현재 기술력 : 1등급');
await expect(form).toContainText('공격');
await expect(form).toContainText('방어');
@@ -606,19 +622,23 @@ test('shows Ref recruitment details and preserves the 1000px desktop and 500px m
})
: null;
return {
overlay: element.closest('[data-testid="command-picker"]')?.getBoundingClientRect().toJSON(),
formWidth: element.getBoundingClientRect().width,
rowHeight: row?.getBoundingClientRect().height ?? 0,
infoWidth: info?.getBoundingClientRect().width ?? 0,
imageNaturalSize: naturalSize,
scrollWidth: element.scrollWidth,
clientWidth: element.clientWidth,
bodyOverflow: getComputedStyle(document.body).overflow,
};
});
expect(desktopGeometry.formWidth).toBeCloseTo(986, 0);
expect(desktopGeometry.overlay).toMatchObject({ x: 0, y: 0, width: 1200, height: 900 });
expect(desktopGeometry.formWidth).toBe(1000);
expect(desktopGeometry.rowHeight).toBeGreaterThanOrEqual(64);
expect(desktopGeometry.infoWidth).toBeCloseTo(250, 0);
expect(desktopGeometry.imageNaturalSize).toEqual({ width: 128, height: 128 });
expect(desktopGeometry.scrollWidth).toBe(desktopGeometry.clientWidth);
expect(desktopGeometry.bodyOverflow).toBe('hidden');
const infantry = form.getByRole('button', { name: '보병 선택 가능', exact: true });
await infantry.getByRole('button', { name: '절반', exact: true }).click();
@@ -628,49 +648,116 @@ test('shows Ref recruitment details and preserves the 1000px desktop and 500px m
expect(JSON.stringify(requests)).toContain('"crewType":1100');
expect(JSON.stringify(requests)).toContain('"amount":3500');
await page.setViewportSize({ width: 390, height: 844 });
await page.setViewportSize({ width: 500, height: 844 });
await page.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: '내정', exact: true }).click();
await picker.getByRole('button', { name: '징병', exact: true }).click();
form = picker.getByTestId('recruitment-command-form');
await page.evaluate(() => window.scrollTo(0, 0));
const mobileGeometry = await form.evaluate((element) => {
const row = element.querySelector('.crew-row');
const image = row?.querySelector('.crew-image');
const info = row?.querySelector('.crew-info');
const selectedPanel = element.querySelector('.mobile-selected-panel');
await expect(picker.getByRole('button', { name: '명령 입력 닫기', exact: true })).toBeFocused();
const referenceWidthGeometry = await picker.evaluate((element) => {
const formElement = element.querySelector<HTMLElement>('[data-testid="recruitment-command-form"]')!;
const row = formElement.querySelector<HTMLElement>('.crew-row')!;
const selectedPanel = formElement.querySelector<HTMLElement>('.mobile-selected-panel')!;
return {
formLeft: element.getBoundingClientRect().left,
formWidth: element.getBoundingClientRect().width,
rowWidth: row?.getBoundingClientRect().width ?? 0,
rowHeight: row?.getBoundingClientRect().height ?? 0,
imageWidth: image?.getBoundingClientRect().width ?? 0,
infoWidth: info?.getBoundingClientRect().width ?? 0,
selectedDisplay: selectedPanel ? getComputedStyle(selectedPanel).display : '',
overlayWidth: element.getBoundingClientRect().width,
rowWidth: row.getBoundingClientRect().width,
rowGridColumns: getComputedStyle(row).gridTemplateColumns,
selectedWidth: selectedPanel.getBoundingClientRect().width,
selectedGridColumns: getComputedStyle(selectedPanel).gridTemplateColumns,
scrollWidth: element.scrollWidth,
clientWidth: element.clientWidth,
documentScrollWidth: document.documentElement.scrollWidth,
};
});
expect(mobileGeometry.formLeft).toBe(0);
expect(mobileGeometry.formWidth).toBe(500);
expect(mobileGeometry.rowWidth).toBe(500);
expect(referenceWidthGeometry).toMatchObject({
overlayWidth: 500,
rowWidth: 500,
rowGridColumns: '64px 76px 30px 30px 30px 270px',
selectedWidth: 500,
selectedGridColumns: '64px 76px 270px 90px',
scrollWidth: 500,
clientWidth: 500,
});
await page.setViewportSize({ width: 390, height: 844 });
const mobileGeometry = await picker.evaluate((element) => {
const formElement = element.querySelector<HTMLElement>('[data-testid="recruitment-command-form"]')!;
const row = formElement.querySelector('.crew-row');
const image = row?.querySelector('.crew-image');
const info = row?.querySelector('.crew-info');
const selectedPanel = formElement.querySelector('.mobile-selected-panel');
const rect = element.getBoundingClientRect();
const bottomElement = document.elementFromPoint(window.innerWidth / 2, window.innerHeight - 1);
return {
overlay: rect.toJSON(),
overlayCoversBottom: bottomElement instanceof Element && element.contains(bottomElement),
formLeft: formElement.getBoundingClientRect().left,
formWidth: formElement.getBoundingClientRect().width,
rowWidth: row?.getBoundingClientRect().width ?? 0,
rowHeight: row?.getBoundingClientRect().height ?? 0,
rowGridColumns: row ? getComputedStyle(row).gridTemplateColumns : '',
rowGridRows: row ? getComputedStyle(row).gridTemplateRows : '',
imageWidth: image?.getBoundingClientRect().width ?? 0,
infoWidth: info?.getBoundingClientRect().width ?? 0,
selectedWidth: selectedPanel?.getBoundingClientRect().width ?? 0,
selectedDisplay: selectedPanel ? getComputedStyle(selectedPanel).display : '',
actionsBottom: element.querySelector<HTMLElement>('.picker-actions')?.getBoundingClientRect().bottom ?? 0,
viewportHeight: window.innerHeight,
scrollWidth: element.scrollWidth,
clientWidth: element.clientWidth,
bodyOverflow: getComputedStyle(document.body).overflow,
};
});
expect(mobileGeometry.overlay).toMatchObject({ x: 0, y: 0, width: 390, height: 844 });
expect(mobileGeometry.overlayCoversBottom).toBe(true);
expect(mobileGeometry.rowGridColumns).toBe('64px 76px 30px 30px 30px 160px');
expect(mobileGeometry).toMatchObject({
formLeft: 0,
formWidth: 390,
rowWidth: 390,
imageWidth: 64,
selectedWidth: 390,
selectedDisplay: 'grid',
actionsBottom: 844,
viewportHeight: 844,
bodyOverflow: 'hidden',
});
expect(mobileGeometry.rowHeight).toBeGreaterThanOrEqual(64);
expect(mobileGeometry.rowHeight).toBeLessThanOrEqual(66);
expect(mobileGeometry.imageWidth).toBe(64);
expect(mobileGeometry.infoWidth).toBe(270);
expect(mobileGeometry.selectedDisplay).toBe('grid');
expect(mobileGeometry.infoWidth).toBeGreaterThanOrEqual(150);
expect(mobileGeometry.scrollWidth).toBe(mobileGeometry.clientWidth);
expect(mobileGeometry.documentScrollWidth).toBeGreaterThanOrEqual(500);
await page.setViewportSize({ width: 390, height: 360 });
await picker.evaluate((element) => (element.scrollTop = 160));
const stickyGeometry = await picker.evaluate((element) => {
const header = element.querySelector<HTMLElement>(':scope > header')!;
const listFront = element.querySelector<HTMLElement>('.recruitment-list-front')!;
const actions = element.querySelector<HTMLElement>('.picker-actions')!;
return {
scrollTop: element.scrollTop,
headerTop: header.getBoundingClientRect().top,
listFrontTop: listFront.getBoundingClientRect().top,
actionsBottom: actions.getBoundingClientRect().bottom,
viewportHeight: window.innerHeight,
};
});
expect(stickyGeometry.scrollTop).toBeGreaterThan(0);
expect(stickyGeometry.headerTop).toBe(0);
expect(stickyGeometry.listFrontTop).toBeGreaterThanOrEqual(44);
expect(stickyGeometry.actionsBottom).toBe(stickyGeometry.viewportHeight);
await page.setViewportSize({ width: 390, height: 844 });
await picker.evaluate((element) => (element.scrollTop = 0));
await page.screenshot({ path: testInfo.outputPath('recruitment-mobile.png') });
await picker.getByRole('button', { name: '명령 다시 선택', exact: true }).click();
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).overflow)).not.toBe('hidden');
await picker.getByRole('button', { name: '내정', exact: true }).click();
await picker.getByRole('button', { name: '모병', exact: true }).click();
const mercenaryForm = picker.getByTestId('recruitment-command-form');
await expect(mercenaryForm).toContainText('모병은 가격 2배의 자금이 소요됩니다.');
await expect(mercenaryForm.locator('.mobile-selected-panel output')).toHaveText('1,346금');
await page.keyboard.press('Escape');
await expect(picker).toHaveCount(0);
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).overflow)).not.toBe('hidden');
});
test('uses the map to choose a nation target in the chief command window', async ({ page }) => {
@@ -685,6 +772,9 @@ test('uses the map to choose a nation target in the chief command window', async
await expect(form.getByTestId('command-argument-guidance')).toContainText('초반 제한');
await form.getByTestId('command-argument-map').locator('.city-base').nth(1).click();
await expect(form.locator('select')).toHaveValue('2');
await expect(form.getByTestId('command-map-selection-status')).toContainText('현재 도시업');
await expect(form.getByTestId('command-map-selection-status')).toContainText('선택 국가적국');
await expect(form.getByTestId('current-city-marker')).toHaveAttribute('aria-label', '현재 도시 업');
await expect(form.getByTestId('command-map-target-summary')).toContainText('수도 허창 · 도시 1개');
await expect(page).toHaveURL(/\/che\/chief-center$/);
await page.screenshot({ path: test.info().outputPath('chief-nation-map-option.png'), fullPage: true });
@@ -697,6 +787,16 @@ test('fits the city map option window inside the Ref-compatible 500px mobile pag
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
const picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: /화계/ }).click();
const mobileForm = picker.getByTestId('command-argument-form');
const mobileMap = mobileForm.getByTestId('command-argument-map');
const mobileCities = mobileMap.locator('.city-base');
await mobileCities.nth(1).click();
await expect(mobileForm.locator('select')).toHaveValue('2');
await expect(mobileForm.getByTestId('command-map-selection-status')).toContainText('현재 도시업');
await expect(mobileForm.getByTestId('command-map-selection-status')).toContainText('선택 도시허창');
await expect(mobileMap.getByTestId('current-city-marker')).toHaveAttribute('aria-label', '현재 도시 업');
await expect(mobileCities.nth(0)).toHaveClass(/mine/);
await expect(mobileCities.nth(1)).toHaveClass(/selected/);
const geometry = await picker.evaluate((element) => {
const map = element.querySelector<HTMLElement>('[data-testid="command-argument-map"] .map-area')!;
const pickerRect = element.getBoundingClientRect();
@@ -708,6 +808,19 @@ test('fits the city map option window inside the Ref-compatible 500px mobile pag
pickerScrollWidth: element.scrollWidth,
mapWidth: mapRect.width,
mapHeight: mapRect.height,
marker: (() => {
const marker = element.querySelector<HTMLElement>('[data-testid="current-city-marker"]')!;
const rect = marker.getBoundingClientRect();
const style = getComputedStyle(marker);
return {
left: rect.left,
right: rect.right,
top: rect.top,
bottom: rect.bottom,
pointerEvents: style.pointerEvents,
borderColor: style.borderColor,
};
})(),
};
});
expect(geometry.pickerX).toBeGreaterThanOrEqual(0);
@@ -716,6 +829,12 @@ test('fits the city map option window inside the Ref-compatible 500px mobile pag
expect(geometry.pickerScrollWidth).toBeLessThanOrEqual(geometry.pickerWidth);
expect(geometry.mapWidth).toBeGreaterThan(470);
expect(geometry.mapHeight / geometry.mapWidth).toBeCloseTo(5 / 7, 2);
expect(geometry.marker.left).toBeGreaterThanOrEqual(0);
expect(geometry.marker.right).toBeLessThanOrEqual(500);
expect(geometry.marker.top).toBeGreaterThanOrEqual(0);
expect(geometry.marker.bottom).toBeLessThanOrEqual(900);
expect(geometry.marker.pointerEvents).toBe('none');
expect(geometry.marker.borderColor).toBe('rgb(130, 207, 255)');
await page.screenshot({ path: test.info().outputPath('main-city-map-option-mobile.png'), fullPage: true });
});
+24 -11
View File
@@ -35,6 +35,7 @@ type NavigationFixture = {
currentYear?: number;
currentMonth?: number;
scenarioTitle?: string;
lastExecuted?: string | null;
latestVote?: { id: number; title: string; hasVoted: boolean } | null;
globalRecords?: Array<{ id: number; text: string }>;
generalRecords?: Array<{ id: number; text: string }>;
@@ -441,7 +442,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
onlineNations: '위(1)',
onlineGenerals: '메뉴검증장수',
nationNotice: '<p>국가 방침</p>',
lastExecuted: null,
lastExecuted: state.lastExecuted === undefined ? '2026-08-13T00:05:06.000Z' : state.lastExecuted,
latestVote:
state.latestVote === undefined
? { id: 9, title: '메뉴 설문', hasVoted: false }
@@ -585,6 +586,10 @@ const persistArtifact = async (page: Page, name: string) => {
globalPopup: describe('#mobile-global-menu'),
nationPopup: describe('#mobile-nation-menu'),
quickPopup: describe('#mobile-quick-menu'),
activityStatus: describe('.activity-status'),
executionStatus: describe('.execution-status'),
tournamentStatus: describe('.tournament-status'),
voteStatus: describe('.vote-status'),
commandMenu: describe('.reserved-command-editor details[open] .menu-items'),
commandDividers: [
...document.querySelectorAll<HTMLElement>('.reserved-command-editor details[open] .menu-divider'),
@@ -617,6 +622,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
stage: 1,
npcMode: 1,
scenarioTitle: '메인 화면 검증 시나리오',
lastExecuted: '2026-08-13T00:05:06.000Z',
generalMeCalls: 0,
operations: [],
};
@@ -633,31 +639,35 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
await expect(page.getByRole('heading', { name: '메인 화면 검증 시나리오', exact: true })).toHaveCount(1);
await expect(page.locator('.game-shell__subtitle')).toHaveText('185년 1월 · 턴 10분');
await expect(page.locator('.game-shell__subtitle')).not.toContainText('메인 화면 검증 시나리오');
await expect(page.locator('.execution-status')).toHaveText('동작 시각: 08-13 09:05');
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 참가 모집중');
await expect(page.locator('.vote-status')).toHaveText('설문: 메뉴 설문');
const headerStatusGeometry = await page.locator('.main-page').evaluate((element) => {
const title = element.querySelector<HTMLElement>('.game-shell__title');
const subtitle = element.querySelector<HTMLElement>('.game-shell__subtitle');
const activity = element.querySelector<HTMLElement>('.activity-status');
const execution = element.querySelector<HTMLElement>('.execution-status');
const tournament = element.querySelector<HTMLElement>('.tournament-status');
const survey = element.querySelector<HTMLElement>('.vote-status');
if (!title || !subtitle || !activity || !tournament || !survey) {
if (!title || !subtitle || !activity || !execution || !tournament || !survey) {
throw new Error('main header status geometry is incomplete');
}
return {
title: title.getBoundingClientRect().toJSON(),
subtitle: subtitle.getBoundingClientRect().toJSON(),
activity: activity.getBoundingClientRect().toJSON(),
execution: execution.getBoundingClientRect().toJSON(),
tournament: tournament.getBoundingClientRect().toJSON(),
survey: survey.getBoundingClientRect().toJSON(),
activityColumns: getComputedStyle(activity).gridTemplateColumns,
};
});
expect(headerStatusGeometry.subtitle.y).toBeGreaterThanOrEqual(headerStatusGeometry.title.bottom);
expect(headerStatusGeometry.activity.width).toBeCloseTo(666.67, 0);
expect(headerStatusGeometry.activity.width).toBeCloseTo(1000, 0);
expect(headerStatusGeometry.execution.width).toBeCloseTo(333.33, 0);
expect(headerStatusGeometry.tournament.width).toBeCloseTo(333.33, 0);
expect(headerStatusGeometry.survey.width).toBeCloseTo(333.33, 0);
expect(headerStatusGeometry.activityColumns.split(' ')).toHaveLength(2);
expect(headerStatusGeometry.activityColumns.split(' ')).toHaveLength(3);
const tournamentStatusLink = page.locator('.tournament-status a');
const surveyStatusLink = page.locator('.vote-status a');
await tournamentStatusLink.hover();
@@ -1265,6 +1275,7 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy
stage: 6,
npcMode: 1,
scenarioTitle: '모바일 검증 시나리오',
lastExecuted: null,
latestVote: null,
generalMeCalls: 0,
operations: [],
@@ -1286,25 +1297,27 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy
await page.setViewportSize({ width: 500, height: 900 });
await expect(page.getByRole('heading', { name: '모바일 검증 시나리오', exact: true })).toHaveCount(1);
await expect(page.locator('.game-shell__subtitle')).toHaveText('185년 1월 · 턴 10분');
await expect(page.locator('.execution-status')).toHaveText('동작 시각: 기록 없음');
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 베팅 진행중');
await expect(page.locator('.vote-status')).toHaveText('설문: 진행 중인 설문 없음');
const activityGeometry = await page.locator('.activity-status').evaluate((element) => {
const execution = element.querySelector<HTMLElement>('.execution-status');
const tournament = element.querySelector<HTMLElement>('.tournament-status');
const survey = element.querySelector<HTMLElement>('.vote-status');
if (!tournament || !survey) throw new Error('activity status is incomplete');
if (!execution || !tournament || !survey) throw new Error('activity status is incomplete');
return {
width: element.getBoundingClientRect().width,
executionWidth: execution.getBoundingClientRect().width,
tournamentWidth: tournament.getBoundingClientRect().width,
surveyWidth: survey.getBoundingClientRect().width,
columns: getComputedStyle(element).gridTemplateColumns,
};
});
expect(activityGeometry).toMatchObject({
width: 500,
tournamentWidth: 250,
surveyWidth: 250,
columns: '250px 250px',
});
expect(activityGeometry.width).toBe(500);
expect(activityGeometry.executionWidth).toBeCloseTo(166.67, 0);
expect(activityGeometry.tournamentWidth).toBeCloseTo(166.67, 0);
expect(activityGeometry.surveyWidth).toBeCloseTo(166.67, 0);
expect(activityGeometry.columns.split(' ')).toHaveLength(3);
await expect
.poll(() =>
page