Merge remote-tracking branch 'origin/main' into fix/main-lumen-button-coverage-20260817
This commit is contained in:
@@ -230,12 +230,14 @@ const buildPayload = (action: BattleSimJobPayload['action']): BattleSimJobPayloa
|
|||||||
describe('battle sim processor', () => {
|
describe('battle sim processor', () => {
|
||||||
it('returns the fixed-seed battle summary instead of only a successful shape', () => {
|
it('returns the fixed-seed battle summary instead of only a successful shape', () => {
|
||||||
const payload = buildPayload('battle');
|
const payload = buildPayload('battle');
|
||||||
|
payload.repeatCnt = 1000;
|
||||||
const result = processBattleSimJob(payload);
|
const result = processBattleSimJob(payload);
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
result: true,
|
result: true,
|
||||||
reason: 'success',
|
reason: 'success',
|
||||||
datetime: '2026-01-01 00:00:00',
|
datetime: '2026-01-01 00:00:00',
|
||||||
|
repeatCnt: 1,
|
||||||
avgWar: 1,
|
avgWar: 1,
|
||||||
phase: 2,
|
phase: 2,
|
||||||
killed: 626,
|
killed: 626,
|
||||||
@@ -277,6 +279,7 @@ describe('battle sim processor', () => {
|
|||||||
const second = processBattleSimJob(secondPayload);
|
const second = processBattleSimJob(secondPayload);
|
||||||
|
|
||||||
expect(first).toEqual(second);
|
expect(first).toEqual(second);
|
||||||
|
expect(first.repeatCnt).toBe(2);
|
||||||
expect(observedSeeds).toEqual(['server-repeat-0', 'server-repeat-1']);
|
expect(observedSeeds).toEqual(['server-repeat-0', 'server-repeat-1']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ const importedGeneral = {
|
|||||||
type Fixture = {
|
type Fixture = {
|
||||||
hasGeneral: boolean;
|
hasGeneral: boolean;
|
||||||
failNextSimulation?: boolean;
|
failNextSimulation?: boolean;
|
||||||
|
prepareDelayMs?: number;
|
||||||
requests: string[];
|
requests: string[];
|
||||||
preparedPayloads: BattleSimJobPayload[];
|
preparedPayloads: BattleSimJobPayload[];
|
||||||
serverResults: BattleSimResultPayload[];
|
serverResults: BattleSimResultPayload[];
|
||||||
@@ -256,6 +257,9 @@ const installApi = async (page: Page, fixture: Fixture) => {
|
|||||||
}, gameProfile);
|
}, gameProfile);
|
||||||
await page.route(gameTrpcRoute, async (route) => {
|
await page.route(gameTrpcRoute, async (route) => {
|
||||||
const operations = operationNames(route);
|
const operations = operationNames(route);
|
||||||
|
if (fixture.prepareDelayMs && operations.includes('battle.prepareSimulation')) {
|
||||||
|
await new Promise((resolveDelay) => setTimeout(resolveDelay, fixture.prepareDelayMs));
|
||||||
|
}
|
||||||
const rawRequestBody: unknown = route.request().postData() ? route.request().postDataJSON() : {};
|
const rawRequestBody: unknown = route.request().postData() ? route.request().postDataJSON() : {};
|
||||||
const requestBody =
|
const requestBody =
|
||||||
rawRequestBody && typeof rawRequestBody === 'object' ? (rawRequestBody as Record<string, unknown>) : {};
|
rawRequestBody && typeof rawRequestBody === 'object' ? (rawRequestBody as Record<string, unknown>) : {};
|
||||||
@@ -446,8 +450,21 @@ test('keeps simulation available without a game general and preserves input afte
|
|||||||
await expect(page.getByText('시뮬레이터 입력 오류')).toBeVisible();
|
await expect(page.getByText('시뮬레이터 입력 오류')).toBeVisible();
|
||||||
await expect(page.getByLabel('시드')).toHaveValue('keep-this-seed');
|
await expect(page.getByLabel('시드')).toHaveValue('keep-this-seed');
|
||||||
|
|
||||||
|
fixture.prepareDelayMs = 1_500;
|
||||||
await page.getByRole('button', { name: '전투', exact: true }).click();
|
await page.getByRole('button', { name: '전투', exact: true }).click();
|
||||||
await expect(page.getByText('전투를 진행 중입니다.')).toHaveCount(0);
|
const progressToast = page.getByTestId('game-toast').filter({ hasText: '전투를 진행 중입니다.' });
|
||||||
|
await expect(progressToast).toBeVisible();
|
||||||
|
const progressToastRect = await progressToast.boundingBox();
|
||||||
|
expect(progressToastRect?.x).toBeGreaterThanOrEqual(0);
|
||||||
|
expect((progressToastRect?.x ?? 0) + (progressToastRect?.width ?? 0)).toBeLessThanOrEqual(500);
|
||||||
|
if (artifactRoot) {
|
||||||
|
await page.screenshot({
|
||||||
|
path: resolve(artifactRoot, 'battle-simulator-progress-mobile.png'),
|
||||||
|
fullPage: true,
|
||||||
|
animations: 'disabled',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await expect(progressToast).toHaveCount(0);
|
||||||
await expect(page.getByText('시뮬레이터 입력 오류')).toHaveCount(0);
|
await expect(page.getByText('시뮬레이터 입력 오류')).toHaveCount(0);
|
||||||
expect(await readBrowserWorkerResult(page, 0)).toEqual(fixture.serverResults[0]);
|
expect(await readBrowserWorkerResult(page, 0)).toEqual(fixture.serverResults[0]);
|
||||||
|
|
||||||
@@ -468,6 +485,7 @@ test('runs 1000 battles in the Chromium worker and matches the Node processor ex
|
|||||||
test.setTimeout(60_000);
|
test.setTimeout(60_000);
|
||||||
const fixture: Fixture = {
|
const fixture: Fixture = {
|
||||||
hasGeneral: false,
|
hasGeneral: false,
|
||||||
|
prepareDelayMs: 500,
|
||||||
requests: [],
|
requests: [],
|
||||||
preparedPayloads: [],
|
preparedPayloads: [],
|
||||||
serverResults: [],
|
serverResults: [],
|
||||||
@@ -478,13 +496,35 @@ test('runs 1000 battles in the Chromium worker and matches the Node processor ex
|
|||||||
|
|
||||||
await page.getByLabel('반복 횟수').selectOption('1000');
|
await page.getByLabel('반복 횟수').selectOption('1000');
|
||||||
await page.getByLabel('시드').fill('');
|
await page.getByLabel('시드').fill('');
|
||||||
|
const worldSettings = page.locator('[data-parity-id="world-settings"]');
|
||||||
|
const worldSettingsTop = (await worldSettings.boundingBox())?.y;
|
||||||
await page.getByRole('button', { name: '전투', exact: true }).click();
|
await page.getByRole('button', { name: '전투', exact: true }).click();
|
||||||
await expect(page.getByText('전투를 진행 중입니다.')).toHaveCount(0, { timeout: 30_000 });
|
|
||||||
|
const progressToast = page.getByTestId('game-toast').filter({ hasText: '전투를 진행 중입니다.' });
|
||||||
|
await expect(progressToast).toBeVisible();
|
||||||
|
expect(await page.locator('.game-toast-viewport').evaluate((element) => getComputedStyle(element).position)).toBe(
|
||||||
|
'fixed'
|
||||||
|
);
|
||||||
|
expect((await worldSettings.boundingBox())?.y).toBe(worldSettingsTop);
|
||||||
|
if (artifactRoot) {
|
||||||
|
await page.screenshot({
|
||||||
|
path: resolve(artifactRoot, 'battle-simulator-progress-desktop.png'),
|
||||||
|
fullPage: true,
|
||||||
|
animations: 'disabled',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await expect(progressToast).toHaveCount(0, { timeout: 30_000 });
|
||||||
|
|
||||||
expect(fixture.preparedPayloads).toHaveLength(1);
|
expect(fixture.preparedPayloads).toHaveLength(1);
|
||||||
expect(fixture.preparedPayloads[0]?.seeds).toHaveLength(1000);
|
expect(fixture.preparedPayloads[0]?.seeds).toHaveLength(1000);
|
||||||
expect(new Set(fixture.preparedPayloads[0]?.seeds).size).toBe(1000);
|
expect(new Set(fixture.preparedPayloads[0]?.seeds).size).toBe(1000);
|
||||||
expect(await readBrowserWorkerResult(page, 0)).toEqual(fixture.serverResults[0]);
|
expect(await readBrowserWorkerResult(page, 0)).toEqual(fixture.serverResults[0]);
|
||||||
|
const battleSummary = page.locator('[data-parity-id="battle-summary"]');
|
||||||
|
await expect(battleSummary.locator('tr').filter({ hasText: '전투 횟수' }).locator('td')).toHaveText('1,000');
|
||||||
|
await expect(battleSummary.locator('tr').filter({ hasText: '전투 일시' }).locator('td')).toHaveText(
|
||||||
|
/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/u
|
||||||
|
);
|
||||||
|
expect(fixture.preparedPayloads[0]?.attackerGeneral.turntime).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/u);
|
||||||
expect(fixture.requests).not.toContain('battle.simulate');
|
expect(fixture.requests).not.toContain('battle.simulate');
|
||||||
expect(fixture.requests).not.toContain('battle.getSimulation');
|
expect(fixture.requests).not.toContain('battle.getSimulation');
|
||||||
const workerUrls = await page.evaluate(() => {
|
const workerUrls = await page.evaluate(() => {
|
||||||
|
|||||||
@@ -327,9 +327,27 @@ const install = async (page: Page, state: FixtureState) => {
|
|||||||
const rawRequestBody: unknown = route.request().postData() ? route.request().postDataJSON() : {};
|
const rawRequestBody: unknown = route.request().postData() ? route.request().postDataJSON() : {};
|
||||||
const requestBody =
|
const requestBody =
|
||||||
rawRequestBody && typeof rawRequestBody === 'object' ? (rawRequestBody as Record<string, unknown>) : {};
|
rawRequestBody && typeof rawRequestBody === 'object' ? (rawRequestBody as Record<string, unknown>) : {};
|
||||||
|
const queryInputText = new URL(route.request().url()).searchParams.get('input');
|
||||||
|
let queryInput: Record<string, unknown> = {};
|
||||||
|
if (queryInputText) {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(queryInputText);
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
queryInput = parsed as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
queryInput = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
const results = operations.map((operation, operationIndex) => {
|
const results = operations.map((operation, operationIndex) => {
|
||||||
const rawPayload =
|
const rawPayload =
|
||||||
requestBody[String(operationIndex)] ?? (operations.length === 1 ? requestBody : undefined);
|
requestBody[String(operationIndex)] ??
|
||||||
|
queryInput[String(operationIndex)] ??
|
||||||
|
(operations.length === 1
|
||||||
|
? Object.keys(requestBody).length > 0
|
||||||
|
? requestBody
|
||||||
|
: queryInput
|
||||||
|
: undefined);
|
||||||
const payload =
|
const payload =
|
||||||
rawPayload && typeof rawPayload === 'object' ? (rawPayload as TrpcRequestPayload) : undefined;
|
rawPayload && typeof rawPayload === 'object' ? (rawPayload as TrpcRequestPayload) : undefined;
|
||||||
const jsonInput =
|
const jsonInput =
|
||||||
@@ -537,10 +555,12 @@ const install = async (page: Page, state: FixtureState) => {
|
|||||||
return response(battleCenter(state));
|
return response(battleCenter(state));
|
||||||
}
|
}
|
||||||
if (operation === 'nation.getGeneralLog') {
|
if (operation === 'nation.getGeneralLog') {
|
||||||
const type = new URL(route.request().url()).searchParams.get('input')?.includes('generalAction')
|
const type =
|
||||||
? 'generalAction'
|
typeof jsonInput.type === 'string' &&
|
||||||
: operation;
|
['generalHistory', 'battleDetail', 'battleResult', 'generalAction'].includes(jsonInput.type)
|
||||||
return response({ type, generalId: 7, logs: [{ id: 1, text: '<Y>감찰 기록</>' }] });
|
? jsonInput.type
|
||||||
|
: 'generalAction';
|
||||||
|
return response({ type, generalId: 7, logs: [{ id: 1, text: `<Y>${type} 감찰 기록</>` }] });
|
||||||
}
|
}
|
||||||
return response({ ok: true });
|
return response({ ok: true });
|
||||||
});
|
});
|
||||||
@@ -1310,7 +1330,9 @@ test('감찰부 keeps the selector interaction and shows the permission error pa
|
|||||||
await expect(page.locator('.selector-row select').nth(1)).toHaveValue('8');
|
await expect(page.locator('.selector-row select').nth(1)).toHaveValue('8');
|
||||||
await page.getByRole('button', { name: '다음 ▶' }).click();
|
await page.getByRole('button', { name: '다음 ▶' }).click();
|
||||||
await expect(page.locator('.selector-row select').nth(1)).toHaveValue('7');
|
await expect(page.locator('.selector-row select').nth(1)).toHaveValue('7');
|
||||||
await expect(page.locator('.battle-general-name')).toContainText('검증장수 【 간의대부 | 건강 】');
|
await expect(page.locator('.battle-general-name')).toContainText('검증장수');
|
||||||
|
await expect(page.locator('.battle-general-name')).toContainText('간의대부');
|
||||||
|
await expect(page.locator('.battle-general-name')).toContainText('건강');
|
||||||
await expect(page.locator('.battle-general-extra')).toContainText('계급29품관');
|
await expect(page.locator('.battle-general-extra')).toContainText('계급29품관');
|
||||||
await expect(page.locator('.battle-general-card')).toContainText('병종보병');
|
await expect(page.locator('.battle-general-card')).toContainText('병종보병');
|
||||||
await expect(page.locator('.battle-general-card')).not.toContainText('che_');
|
await expect(page.locator('.battle-general-card')).not.toContainText('che_');
|
||||||
@@ -1321,6 +1343,11 @@ test('감찰부 keeps the selector interaction and shows the permission error pa
|
|||||||
expect(battleImages[1]?.backgroundImage).toContain('/game/crewtype1.png');
|
expect(battleImages[1]?.backgroundImage).toContain('/game/crewtype1.png');
|
||||||
await expect(page.locator('.battle-general-card [role="progressbar"]')).toHaveCount(14);
|
await expect(page.locator('.battle-general-card [role="progressbar"]')).toHaveCount(14);
|
||||||
await expect(page.locator('.battle-general-card [aria-label*="1,275,975 (EX+)"]')).toHaveCount(5);
|
await expect(page.locator('.battle-general-card [aria-label*="1,275,975 (EX+)"]')).toHaveCount(5);
|
||||||
|
await expect(page.locator('.general-meta')).toHaveCount(0);
|
||||||
|
await expect(page.locator('.battle-general-extra__recent-value')).toHaveText('01-01 00:00');
|
||||||
|
await expect(page.locator('.battle-general-extra__recent-value')).not.toContainText('2026');
|
||||||
|
await expect(page.locator('.log-block')).toHaveCount(4);
|
||||||
|
await expect(page.locator('.log-block[data-log-type="battleResult"]')).toContainText('battleResult 감찰 기록');
|
||||||
expect(
|
expect(
|
||||||
await page
|
await page
|
||||||
.locator('.battle-general-card [role="progressbar"]')
|
.locator('.battle-general-card [role="progressbar"]')
|
||||||
@@ -1330,7 +1357,11 @@ test('감찰부 keeps the selector interaction and shows the permission error pa
|
|||||||
const geometry = await page.locator('.battle-page').evaluate((element) => {
|
const geometry = await page.locator('.battle-page').evaluate((element) => {
|
||||||
const selector = element.querySelector<HTMLElement>('.selector-row')!;
|
const selector = element.querySelector<HTMLElement>('.selector-row')!;
|
||||||
const controls = [...selector.children].map((child) => (child as HTMLElement).getBoundingClientRect());
|
const controls = [...selector.children].map((child) => (child as HTMLElement).getBoundingClientRect());
|
||||||
const logBlock = element.querySelector<HTMLElement>('.log-block')!.getBoundingClientRect();
|
const logBlocks = [...element.querySelectorAll<HTMLElement>('.log-block')];
|
||||||
|
const logBlock = logBlocks[0]!.getBoundingClientRect();
|
||||||
|
const recentLabel = element.querySelector<HTMLElement>('.battle-general-extra__recent-label')!;
|
||||||
|
const recentValue = element.querySelector<HTMLElement>('.battle-general-extra__recent-value')!;
|
||||||
|
const previousStat = recentLabel.previousElementSibling as HTMLElement;
|
||||||
return {
|
return {
|
||||||
width: element.getBoundingClientRect().width,
|
width: element.getBoundingClientRect().width,
|
||||||
fontSize: getComputedStyle(element).fontSize,
|
fontSize: getComputedStyle(element).fontSize,
|
||||||
@@ -1341,6 +1372,11 @@ test('감찰부 keeps the selector interaction and shows the permission error pa
|
|||||||
backgroundImage: getComputedStyle(element).backgroundImage,
|
backgroundImage: getComputedStyle(element).backgroundImage,
|
||||||
generalBackgroundImage: getComputedStyle(element.querySelector<HTMLElement>('.battle-general-card')!)
|
generalBackgroundImage: getComputedStyle(element.querySelector<HTMLElement>('.battle-general-card')!)
|
||||||
.backgroundImage,
|
.backgroundImage,
|
||||||
|
logBackgroundImages: logBlocks.map((block) => getComputedStyle(block).backgroundImage),
|
||||||
|
recentLabelTop: recentLabel.getBoundingClientRect().top,
|
||||||
|
recentValueTop: recentValue.getBoundingClientRect().top,
|
||||||
|
recentValueWidth: recentValue.getBoundingClientRect().width,
|
||||||
|
previousStatTop: previousStat.getBoundingClientRect().top,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
expect(geometry.width).toBe(1000);
|
expect(geometry.width).toBe(1000);
|
||||||
@@ -1352,16 +1388,41 @@ test('감찰부 keeps the selector interaction and shows the permission error pa
|
|||||||
expect(geometry.logBlockWidth).toBeCloseTo(500, 0);
|
expect(geometry.logBlockWidth).toBeCloseTo(500, 0);
|
||||||
expect(geometry.backgroundImage).toContain('back_walnut.jpg');
|
expect(geometry.backgroundImage).toContain('back_walnut.jpg');
|
||||||
expect(geometry.generalBackgroundImage).toContain('back_blue.jpg');
|
expect(geometry.generalBackgroundImage).toContain('back_blue.jpg');
|
||||||
|
expect(geometry.logBackgroundImages).toHaveLength(4);
|
||||||
|
expect(geometry.logBackgroundImages.every((background) => background.includes('back_walnut.jpg'))).toBe(true);
|
||||||
|
expect(geometry.recentLabelTop).toBeCloseTo(geometry.recentValueTop, 0);
|
||||||
|
expect(geometry.recentLabelTop).toBeGreaterThan(geometry.previousStatTop);
|
||||||
|
expect(geometry.recentValueWidth).toBeGreaterThan(400);
|
||||||
await persistParityArtifact(page, 'core-battle-center-desktop', geometry);
|
await persistParityArtifact(page, 'core-battle-center-desktop', geometry);
|
||||||
|
|
||||||
await page.setViewportSize({ width: 500, height: 900 });
|
await page.setViewportSize({ width: 500, height: 900 });
|
||||||
const mobileGeometry = await page.locator('.selector-row').evaluate((element) => ({
|
const mobileGeometry = await page.locator('.battle-page').evaluate((element) => {
|
||||||
columns: getComputedStyle(element).gridTemplateColumns,
|
const selector = element.querySelector<HTMLElement>('.selector-row')!;
|
||||||
controlWidths: [...element.children].map((child) => (child as HTMLElement).getBoundingClientRect().width),
|
const battleResult = element.querySelector<HTMLElement>('.log-block[data-log-type="battleResult"]')!;
|
||||||
}));
|
const footer = element.querySelector<HTMLElement>('.battle-footer')!;
|
||||||
|
const pageRect = element.getBoundingClientRect();
|
||||||
|
const resultRect = battleResult.getBoundingClientRect();
|
||||||
|
const footerRect = footer.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
columns: getComputedStyle(selector).gridTemplateColumns,
|
||||||
|
controlWidths: [...selector.children].map((child) => (child as HTMLElement).getBoundingClientRect().width),
|
||||||
|
pageHeight: pageRect.height,
|
||||||
|
pageOverflow: getComputedStyle(element).overflow,
|
||||||
|
resultBottomWithinPage: resultRect.bottom <= pageRect.bottom,
|
||||||
|
footerAfterResult: footerRect.top >= resultRect.bottom,
|
||||||
|
resultBackgroundImage: getComputedStyle(battleResult).backgroundImage,
|
||||||
|
};
|
||||||
|
});
|
||||||
expect(mobileGeometry.columns.split(' ')).toHaveLength(4);
|
expect(mobileGeometry.columns.split(' ')).toHaveLength(4);
|
||||||
expect(mobileGeometry.controlWidths[0]).toBeCloseTo(83.33, 0);
|
expect(mobileGeometry.controlWidths[0]).toBeCloseTo(83.33, 0);
|
||||||
expect(mobileGeometry.controlWidths[1]).toBeCloseTo(125, 0);
|
expect(mobileGeometry.controlWidths[1]).toBeCloseTo(125, 0);
|
||||||
|
expect(mobileGeometry.pageHeight).toBeGreaterThan(0);
|
||||||
|
expect(mobileGeometry.pageOverflow).toBe('visible');
|
||||||
|
expect(mobileGeometry.resultBottomWithinPage).toBe(true);
|
||||||
|
expect(mobileGeometry.footerAfterResult).toBe(true);
|
||||||
|
expect(mobileGeometry.resultBackgroundImage).toContain('back_walnut.jpg');
|
||||||
|
await page.locator('.log-block[data-log-type="battleResult"]').scrollIntoViewIfNeeded();
|
||||||
|
await expect(page.locator('.log-block[data-log-type="battleResult"]')).toBeInViewport();
|
||||||
await persistParityArtifact(page, 'core-battle-center-mobile', mobileGeometry);
|
await persistParityArtifact(page, 'core-battle-center-mobile', mobileGeometry);
|
||||||
|
|
||||||
await page.unrouteAll({ behavior: 'wait' });
|
await page.unrouteAll({ behavior: 'wait' });
|
||||||
|
|||||||
@@ -748,6 +748,7 @@ const persistArtifact = async (page: Page, name: string) => {
|
|||||||
return {
|
return {
|
||||||
viewport: { width: innerWidth, height: innerHeight },
|
viewport: { width: innerWidth, height: innerHeight },
|
||||||
global: describe('.main-global-menu'),
|
global: describe('.main-global-menu'),
|
||||||
|
bottomGlobalPopup: describe('[data-menu-position="bottom"] .main-menu-popup__list'),
|
||||||
nation: describe('.main-nation-menu'),
|
nation: describe('.main-nation-menu'),
|
||||||
bottom: describe('.main-mobile-bottom'),
|
bottom: describe('.main-mobile-bottom'),
|
||||||
globalPopup: describe('#mobile-global-menu'),
|
globalPopup: describe('#mobile-global-menu'),
|
||||||
@@ -915,6 +916,20 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
|
|||||||
await gameInfoButton.press('Enter');
|
await gameInfoButton.press('Enter');
|
||||||
await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'true');
|
await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'true');
|
||||||
await expect(global.locator('#global-menu-game-info')).toBeVisible();
|
await expect(global.locator('#global-menu-game-info')).toBeVisible();
|
||||||
|
const topMenuGeometry = await gameInfoButton.evaluate((button) => {
|
||||||
|
const popup = button.parentElement?.querySelector<HTMLElement>('.main-menu-popup__list');
|
||||||
|
const caret = button.querySelector<HTMLElement>('.menu-caret');
|
||||||
|
if (!popup || !caret) throw new Error('top global menu popup geometry is incomplete');
|
||||||
|
return {
|
||||||
|
trigger: button.getBoundingClientRect().toJSON(),
|
||||||
|
popup: popup.getBoundingClientRect().toJSON(),
|
||||||
|
caretBorderTopWidth: getComputedStyle(caret).borderTopWidth,
|
||||||
|
caretBorderBottomWidth: getComputedStyle(caret).borderBottomWidth,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(topMenuGeometry.popup.top).toBeGreaterThanOrEqual(topMenuGeometry.trigger.bottom + 1);
|
||||||
|
expect(topMenuGeometry.caretBorderTopWidth).toBe('4px');
|
||||||
|
expect(topMenuGeometry.caretBorderBottomWidth).toBe('0px');
|
||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'false');
|
await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'false');
|
||||||
await expect(gameInfoButton).toBeFocused();
|
await expect(gameInfoButton).toBeFocused();
|
||||||
@@ -922,9 +937,72 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
|
|||||||
await gameInfoButton.click();
|
await gameInfoButton.click();
|
||||||
await page.getByRole('heading', { name: '메인 화면 검증 시나리오' }).click();
|
await page.getByRole('heading', { name: '메인 화면 검증 시나리오' }).click();
|
||||||
await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'false');
|
await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
|
||||||
|
const bottomGlobal = page.locator('[data-menu-position="bottom"]');
|
||||||
|
const bottomGameInfoButton = bottomGlobal.locator('[data-menu-id="game-info"]');
|
||||||
|
await bottomGameInfoButton.click();
|
||||||
|
await expect(bottomGameInfoButton).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
await expect(bottomGlobal.locator('#global-menu-game-info')).toBeVisible();
|
||||||
|
const bottomMenuGeometry = await bottomGameInfoButton.evaluate((button) => {
|
||||||
|
const popup = button.parentElement?.querySelector<HTMLElement>('.main-menu-popup__list');
|
||||||
|
const caret = button.querySelector<HTMLElement>('.menu-caret');
|
||||||
|
if (!popup || !caret) throw new Error('bottom global menu popup geometry is incomplete');
|
||||||
|
return {
|
||||||
|
trigger: button.getBoundingClientRect().toJSON(),
|
||||||
|
popup: popup.getBoundingClientRect().toJSON(),
|
||||||
|
caretBorderTopWidth: getComputedStyle(caret).borderTopWidth,
|
||||||
|
caretBorderBottomWidth: getComputedStyle(caret).borderBottomWidth,
|
||||||
|
boxShadow: getComputedStyle(popup).boxShadow,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(bottomMenuGeometry.popup.bottom).toBeLessThanOrEqual(bottomMenuGeometry.trigger.top - 1);
|
||||||
|
expect(bottomMenuGeometry.caretBorderTopWidth).toBe('0px');
|
||||||
|
expect(bottomMenuGeometry.caretBorderBottomWidth).toBe('4px');
|
||||||
|
expect(bottomMenuGeometry.boxShadow).toContain('0px -8px 18px');
|
||||||
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
|
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the repeated bottom global menu opens upward on the mobile document', async ({ page }, testInfo) => {
|
||||||
|
const state: NavigationFixture = {
|
||||||
|
officerLevel: 5,
|
||||||
|
permission: 2,
|
||||||
|
nationLevel: 3,
|
||||||
|
stage: 1,
|
||||||
|
npcMode: 1,
|
||||||
|
scenarioTitle: '하단 메뉴 방향 검증 시나리오',
|
||||||
|
generalMeCalls: 0,
|
||||||
|
operations: [],
|
||||||
|
};
|
||||||
|
await installFixture(page, state);
|
||||||
|
await page.setViewportSize({ width: 500, height: 900 });
|
||||||
|
await waitForMain(page);
|
||||||
|
|
||||||
|
const bottomGlobal = page.locator('[data-menu-position="bottom"]');
|
||||||
|
const gameInfoButton = bottomGlobal.locator('[data-menu-id="game-info"]');
|
||||||
|
await gameInfoButton.click();
|
||||||
|
await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
await expect(bottomGlobal.locator('#global-menu-game-info')).toBeVisible();
|
||||||
|
const geometry = await gameInfoButton.evaluate((button) => {
|
||||||
|
const popup = button.parentElement?.querySelector<HTMLElement>('.main-menu-popup__list');
|
||||||
|
const caret = button.querySelector<HTMLElement>('.menu-caret');
|
||||||
|
if (!popup || !caret) throw new Error('mobile bottom global menu popup geometry is incomplete');
|
||||||
|
return {
|
||||||
|
trigger: button.getBoundingClientRect().toJSON(),
|
||||||
|
popup: popup.getBoundingClientRect().toJSON(),
|
||||||
|
caretBorderTopWidth: getComputedStyle(caret).borderTopWidth,
|
||||||
|
caretBorderBottomWidth: getComputedStyle(caret).borderBottomWidth,
|
||||||
|
viewportHeight: window.innerHeight,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(geometry.popup.bottom).toBeLessThanOrEqual(geometry.trigger.top - 1);
|
||||||
|
expect(geometry.popup.top).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(geometry.popup.bottom).toBeLessThanOrEqual(geometry.viewportHeight);
|
||||||
|
expect(geometry.caretBorderTopWidth).toBe('0px');
|
||||||
|
expect(geometry.caretBorderBottomWidth).toBe('4px');
|
||||||
|
await bottomGlobal.screenshot({ path: testInfo.outputPath('mobile-bottom-global-dropup.png') });
|
||||||
|
await persistArtifact(page, `${basePath.slice(1)}-mobile-bottom-dropup`);
|
||||||
|
});
|
||||||
|
|
||||||
test('main general card uses local turn time and command clock tracks corrected server time', async ({ page }) => {
|
test('main general card uses local turn time and command clock tracks corrected server time', async ({ page }) => {
|
||||||
const state: NavigationFixture = {
|
const state: NavigationFixture = {
|
||||||
officerLevel: 0,
|
officerLevel: 0,
|
||||||
|
|||||||
@@ -163,6 +163,17 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
|
|||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.main-global-menu[data-menu-position='bottom'] .main-menu-popup__list {
|
||||||
|
top: auto;
|
||||||
|
bottom: calc(100% + 2px);
|
||||||
|
box-shadow: 0 -8px 18px rgb(0 0 0 / 45%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-global-menu[data-menu-position='bottom'] .menu-caret {
|
||||||
|
border-top-width: 0;
|
||||||
|
border-bottom: 4px solid currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
.main-menu-split .main-menu-popup__list {
|
.main-menu-split .main-menu-popup__list {
|
||||||
right: 0;
|
right: 0;
|
||||||
left: auto;
|
left: auto;
|
||||||
|
|||||||
@@ -286,28 +286,26 @@ onMounted(() => {
|
|||||||
><strong>{{ selectedGeneral.battleStats.killCrew.toLocaleString('ko-KR') }}</strong>
|
><strong>{{ selectedGeneral.battleStats.killCrew.toLocaleString('ko-KR') }}</strong>
|
||||||
<span>피살</span
|
<span>피살</span
|
||||||
><strong>{{ selectedGeneral.battleStats.deathCrew.toLocaleString('ko-KR') }}</strong>
|
><strong>{{ selectedGeneral.battleStats.deathCrew.toLocaleString('ko-KR') }}</strong>
|
||||||
<span>최근 전투</span><strong>{{ selectedGeneral.recentWar || '-' }}</strong>
|
<span class="battle-general-extra__recent-label">최근 전투</span>
|
||||||
|
<strong class="battle-general-extra__recent-value">
|
||||||
|
{{
|
||||||
|
formatServerDateTime(selectedGeneral.recentWar, {
|
||||||
|
format: 'monthDayTime',
|
||||||
|
fallback: '-',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<LegacyGeneralProgress :general="selectedGeneral" :show-primary="false" />
|
<LegacyGeneralProgress :general="selectedGeneral" :show-primary="false" />
|
||||||
</template>
|
</template>
|
||||||
</GeneralBasicCard>
|
</GeneralBasicCard>
|
||||||
<div v-if="selectedGeneral" class="general-meta">
|
|
||||||
<div>
|
|
||||||
최근 턴:
|
|
||||||
{{
|
|
||||||
formatServerDateTime(selectedGeneral.turnTime, { format: 'hourMinute', fallback: '-' })
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
<div>최근 전투: {{ selectedGeneral.recentWar || '-' }}</div>
|
|
||||||
<div>전투 횟수: {{ selectedGeneral.warnum }}</div>
|
|
||||||
</div>
|
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stack">
|
<div class="stack">
|
||||||
<PanelCard title="장수 기록" subtitle="열전과 전투 기록">
|
<PanelCard title="장수 기록" subtitle="열전과 전투 기록">
|
||||||
<div class="log-grid">
|
<div class="log-grid">
|
||||||
<div v-for="type in logTypes" :key="type" class="log-block">
|
<div v-for="type in logTypes" :key="type" class="log-block" :data-log-type="type">
|
||||||
<div class="log-title">{{ logLabels[type] }}</div>
|
<div class="log-title">{{ logLabels[type] }}</div>
|
||||||
<SkeletonLines v-if="loading || logLoading" :lines="3" />
|
<SkeletonLines v-if="loading || logLoading" :lines="3" />
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -356,14 +354,6 @@ onMounted(() => {
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.general-meta {
|
|
||||||
margin: 0;
|
|
||||||
padding: 6px 8px;
|
|
||||||
color: #ccc;
|
|
||||||
display: grid;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.battle-general-extra {
|
.battle-general-extra {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(6, 1fr);
|
grid-template-columns: repeat(6, 1fr);
|
||||||
@@ -390,6 +380,15 @@ onMounted(() => {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.battle-general-extra > .battle-general-extra__recent-label {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battle-general-extra > .battle-general-extra__recent-value {
|
||||||
|
grid-column: 2 / -1;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.log-grid {
|
.log-grid {
|
||||||
display: contents;
|
display: contents;
|
||||||
}
|
}
|
||||||
@@ -397,7 +396,8 @@ onMounted(() => {
|
|||||||
.log-block {
|
.log-block {
|
||||||
border: 1px solid #666;
|
border: 1px solid #666;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: #000;
|
background-color: #302016;
|
||||||
|
background-image: var(--sammo-texture-walnut);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,8 +492,8 @@ onMounted(() => {
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
height: 1268px;
|
height: auto;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
.battle-top {
|
.battle-top {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
@@ -530,7 +530,6 @@ onMounted(() => {
|
|||||||
@media (max-width: 991px) {
|
@media (max-width: 991px) {
|
||||||
.battle-page {
|
.battle-page {
|
||||||
width: 500px;
|
width: 500px;
|
||||||
height: 1411px;
|
|
||||||
}
|
}
|
||||||
.battle-top {
|
.battle-top {
|
||||||
grid-template-columns: 89px 89px 1fr 0 0;
|
grid-template-columns: 89px 89px 1fr 0 0;
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import type { BattleSimRequestPayload, BattleSimResultPayload } from '@sammo-ts/
|
|||||||
import PanelCard from '../components/ui/PanelCard.vue';
|
import PanelCard from '../components/ui/PanelCard.vue';
|
||||||
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
||||||
import BattleGeneralCard from '../components/battle/BattleGeneralCard.vue';
|
import BattleGeneralCard from '../components/battle/BattleGeneralCard.vue';
|
||||||
|
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
import { getNpcColor } from '../utils/npcColor';
|
import { getNpcColor } from '../utils/npcColor';
|
||||||
import type { BattleSimOptions, GeneralDraft, InheritBuff } from '../utils/battleSimulatorTypes';
|
import type { BattleSimOptions, GeneralDraft, InheritBuff } from '../utils/battleSimulatorTypes';
|
||||||
import { BattleSimulatorWorkerClient } from '../utils/battleSimulatorWorkerClient';
|
import { BattleSimulatorWorkerClient } from '../utils/battleSimulatorWorkerClient';
|
||||||
|
import { formatSeoulDateTime } from '../utils/legacyDateTime';
|
||||||
|
|
||||||
type GeneralExport = Omit<GeneralDraft, 'id'>;
|
type GeneralExport = Omit<GeneralDraft, 'id'>;
|
||||||
|
|
||||||
@@ -67,8 +69,8 @@ const attackerGeneral = ref<GeneralDraft | null>(null);
|
|||||||
const defenders = ref<GeneralDraft[]>([]);
|
const defenders = ref<GeneralDraft[]>([]);
|
||||||
|
|
||||||
const isSimulating = ref(false);
|
const isSimulating = ref(false);
|
||||||
const statusMessage = ref<string | null>(null);
|
|
||||||
const simulationWorker = new BattleSimulatorWorkerClient();
|
const simulationWorker = new BattleSimulatorWorkerClient();
|
||||||
|
const { info: showInfoToast, dismissToast } = useGameFeedback();
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
simulationWorker.dispose();
|
simulationWorker.dispose();
|
||||||
@@ -428,13 +430,6 @@ const normalizeGeneralExport = (raw: Record<string, unknown>): GeneralExport =>
|
|||||||
inheritBuff: normalizeInheritBuff(raw.inheritBuff),
|
inheritBuff: normalizeInheritBuff(raw.inheritBuff),
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatBattleTime = (date: Date): string => {
|
|
||||||
const pad = (value: number) => String(value).padStart(2, '0');
|
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(
|
|
||||||
date.getMinutes()
|
|
||||||
)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildGeneralPayload = (
|
const buildGeneralPayload = (
|
||||||
general: GeneralDraft,
|
general: GeneralDraft,
|
||||||
nationId: number,
|
nationId: number,
|
||||||
@@ -492,7 +487,7 @@ const buildBattlePayload = (action: BattleSimRequestPayload['action']): BattleSi
|
|||||||
if (!attackerGeneral.value) {
|
if (!attackerGeneral.value) {
|
||||||
throw new Error('attacker_general_missing');
|
throw new Error('attacker_general_missing');
|
||||||
}
|
}
|
||||||
const now = formatBattleTime(new Date());
|
const now = formatSeoulDateTime(new Date());
|
||||||
|
|
||||||
const attackerNationPayload = {
|
const attackerNationPayload = {
|
||||||
nation: 1,
|
nation: 1,
|
||||||
@@ -583,7 +578,10 @@ const runSimulation = async (action: BattleSimRequestPayload['action']) => {
|
|||||||
if (action === 'battle') {
|
if (action === 'battle') {
|
||||||
battleResult.value = null;
|
battleResult.value = null;
|
||||||
}
|
}
|
||||||
statusMessage.value = action === 'battle' ? '전투를 진행 중입니다.' : '수비자 순서를 계산 중입니다.';
|
const progressToastId = showInfoToast(
|
||||||
|
action === 'battle' ? '전투를 진행 중입니다.' : '수비자 순서를 계산 중입니다.',
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = buildBattlePayload(action);
|
const payload = buildBattlePayload(action);
|
||||||
@@ -605,7 +603,7 @@ const runSimulation = async (action: BattleSimRequestPayload['action']) => {
|
|||||||
error.value = resolveErrorMessage(err);
|
error.value = resolveErrorMessage(err);
|
||||||
} finally {
|
} finally {
|
||||||
isSimulating.value = false;
|
isSimulating.value = false;
|
||||||
statusMessage.value = null;
|
dismissToast(progressToastId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -952,8 +950,8 @@ const summaryRows = computed(() => {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
{ label: '전투 일시', value: battleResult.value.datetime ?? '-' },
|
{ label: '전투 일시', value: formatSeoulDateTime(battleResult.value.datetime ?? '') || '-' },
|
||||||
{ label: '전투 횟수', value: formatNumber(battleResult.value.avgWar) },
|
{ label: '전투 횟수', value: formatNumber(battleResult.value.repeatCnt) },
|
||||||
{ label: '전투 페이즈', value: formatNumber(battleResult.value.phase) },
|
{ label: '전투 페이즈', value: formatNumber(battleResult.value.phase) },
|
||||||
{
|
{
|
||||||
label: '준 피해',
|
label: '준 피해',
|
||||||
@@ -1001,7 +999,6 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
|
|||||||
<button v-for="index in 5" :key="`legacy-button-${index}`" type="button"></button>
|
<button v-for="index in 5" :key="`legacy-button-${index}`" type="button"></button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
<div v-if="error" class="error">{{ error }}</div>
|
||||||
<div v-if="statusMessage" class="status">{{ statusMessage }}</div>
|
|
||||||
|
|
||||||
<PanelCard data-parity-id="world-settings" title="전역 설정">
|
<PanelCard data-parity-id="world-settings" title="전역 설정">
|
||||||
<div v-if="loading">
|
<div v-if="loading">
|
||||||
|
|||||||
@@ -481,6 +481,7 @@ export const processBattleSimJob = (
|
|||||||
result: true,
|
result: true,
|
||||||
reason: 'success',
|
reason: 'success',
|
||||||
datetime: payload.attackerGeneral.turntime,
|
datetime: payload.attackerGeneral.turntime,
|
||||||
|
repeatCnt,
|
||||||
lastWarLog: logBuckets,
|
lastWarLog: logBuckets,
|
||||||
avgWar,
|
avgWar,
|
||||||
phase: avgPhase,
|
phase: avgPhase,
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ export interface BattleSimResultPayload {
|
|||||||
result: boolean;
|
result: boolean;
|
||||||
reason: string;
|
reason: string;
|
||||||
datetime?: string;
|
datetime?: string;
|
||||||
|
/** Number of battles actually evaluated. A fixed seed intentionally collapses a repeated request to one run. */
|
||||||
|
repeatCnt?: number;
|
||||||
lastWarLog?: BattleSimLogBuckets;
|
lastWarLog?: BattleSimLogBuckets;
|
||||||
avgWar?: number;
|
avgWar?: number;
|
||||||
phase?: number;
|
phase?: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user