merge: 최신 main을 tRPC JSON 본문 전송에 통합

This commit is contained in:
2026-08-17 11:12:09 +00:00
49 changed files with 1182 additions and 162 deletions
+109 -5
View File
@@ -53,6 +53,18 @@ const inputOptions = {
{ value: 1, label: '장수 (아국 · 업)' },
{ value: 2, label: '관우 (아국 · 업)' },
],
generalTargets: {
che_포상: [
{ value: 1, label: '장수 (아국 · 업)' },
{ value: 2, label: '관우 (아국 · 업)' },
{ value: 3, label: '여포NPC (아국 · 업)' },
],
che_몰수: [
{ value: 1, label: '장수 (아국 · 업)' },
{ value: 2, label: '관우 (아국 · 업)' },
{ value: 3, label: '여포NPC (아국 · 업)' },
],
},
crewTypes: [{ value: 1100, label: '보병' }],
armTypes: [{ value: 1, label: '보병' }],
nationTypes: [{ value: 'che_도적', label: '도적', description: '금 수입 증가, 쌀 수입 감소' }],
@@ -389,8 +401,8 @@ const chiefCenter = {
maxTurns: 12,
chiefs: [12, 10, 8, 6, 11, 9, 7, 5].map((officerLevel) => ({
officerLevel,
name: officerLevel === 5 ? '장수' : null,
npcState: officerLevel === 5 ? 0 : null,
name: officerLevel === 5 ? '장수' : `수뇌${officerLevel}`,
npcState: officerLevel === 8 ? 2 : 0,
turnTime: null,
revision: 0,
turns: turns(12),
@@ -801,6 +813,92 @@ test('shows every Ref chief command in the exact category and command order', as
await mobilePicker.screenshot({ path: test.info().outputPath('ref-chief-command-list-mobile-500.png') });
});
test('shows all 12 advanced chief turns before the actions and uses the full mobile chief matrix', async ({ page }) => {
await install(page);
await page.setViewportSize({ width: 500, height: 900 });
await page.goto('/che/chief-center');
const editor = page.locator('[data-command-scope="nation"]:visible');
await editor.getByRole('button', { name: '고급 모드', exact: true }).click();
await expect(editor.locator('.index-column > button')).toHaveCount(12);
await expect(editor.locator('.index-column > button').last()).toHaveText('12');
await expect(editor.locator('.advanced-actions')).toContainText('선택한 턴을');
await expect(editor.locator('.advanced-actions')).toContainText('명령 선택');
const frame = page.locator('.chief-overview-frame');
await expect(frame.locator('.chief-overview-row')).toHaveCount(2);
await expect(frame.locator('.overview-turn-index')).toHaveCount(4);
for (const gutter of await frame.locator('.overview-turn-index').all()) {
await expect(gutter.locator('span').filter({ hasText: /\d+/u })).toHaveText(
Array.from({ length: 12 }, (_, index) => String(index + 1))
);
}
await expect(frame.locator('.compact-name')).toHaveCount(8);
const geometry = await page.locator('.chief-page').evaluate((element) => {
const editorElement = element.querySelector<HTMLElement>('[data-command-scope="nation"]')!;
const queue = editorElement.querySelector<HTMLElement>('.queue-grid')!;
const lastTurn = editorElement.querySelectorAll<HTMLElement>('.action-column > div')[11]!;
const actions = editorElement.querySelector<HTMLElement>('.advanced-actions')!;
const overviewFrame = element.querySelector<HTMLElement>('.chief-overview-frame')!;
const firstOverviewRow = element.querySelector<HTMLElement>('.chief-overview-row')!;
const overviewRows = [...element.querySelectorAll<HTMLElement>('.chief-overview-row')];
const gutters = [...firstOverviewRow.querySelectorAll<HTMLElement>('.overview-turn-index')];
const cards = [...firstOverviewRow.querySelectorAll<HTMLElement>('.chief-card')];
const names = [...overviewFrame.querySelectorAll<HTMLElement>('.compact-name')];
const frameRect = overviewFrame.getBoundingClientRect();
const editorRect = editorElement.getBoundingClientRect();
const actionsRect = actions.getBoundingClientRect();
return {
editorBottom: editorRect.bottom,
editorHeight: editorRect.height,
queueBottom: queue.getBoundingClientRect().bottom,
lastTurnBottom: lastTurn.getBoundingClientRect().bottom,
actionsTop: actionsRect.top,
actionsBottom: actionsRect.bottom,
frameTop: frameRect.top,
frameWidth: frameRect.width,
rowWidth: firstOverviewRow.getBoundingClientRect().width,
rowEdges: overviewRows.map((item) => ({
top: item.getBoundingClientRect().top - frameRect.top,
bottom: item.getBoundingClientRect().bottom - frameRect.top,
})),
gutterWidths: gutters.map((item) => item.getBoundingClientRect().width),
gutterEdges: gutters.map((item) => ({
left: item.getBoundingClientRect().left - frameRect.left,
right: item.getBoundingClientRect().right - frameRect.left,
})),
cardWidths: cards.map((item) => item.getBoundingClientRect().width),
namesInsideFrame: names.every((item) => {
const rect = item.getBoundingClientRect();
return rect.top >= frameRect.top && rect.bottom <= frameRect.bottom && rect.height > 0;
}),
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
};
});
expect(geometry.lastTurnBottom).toBeLessThanOrEqual(geometry.actionsTop);
expect(geometry.queueBottom).toBeLessThanOrEqual(geometry.actionsTop);
expect(geometry.actionsBottom).toBeLessThanOrEqual(geometry.editorBottom);
expect(geometry.frameTop).toBeGreaterThanOrEqual(geometry.editorBottom);
expect(geometry.editorHeight).toBeGreaterThanOrEqual(404);
expect(geometry.frameWidth).toBe(500);
expect(geometry.rowWidth).toBe(500);
expect(geometry.rowEdges).toEqual([
{ top: 0, bottom: 155 },
{ top: 155, bottom: 310 },
]);
expect(geometry.gutterWidths).toEqual([12, 12]);
expect(geometry.gutterEdges).toEqual([
{ left: 0, right: 12 },
{ left: 488, right: 500 },
]);
expect(geometry.cardWidths).toEqual([119, 119, 119, 119]);
expect(geometry.namesInsideFrame).toBe(true);
expect(geometry.documentOverflow).toBeLessThanOrEqual(0);
await page.screenshot({ path: test.info().outputPath('chief-advanced-mobile-500.png'), fullPage: true });
});
test('enters general and nation command arguments and sends exact values', async ({ page }) => {
const requests = await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
@@ -891,7 +989,13 @@ test('enters general and nation command arguments and sends exact values', async
const chiefForm = chiefPicker.getByTestId('command-argument-form');
await chiefForm.getByRole('button', { name: '쌀' }).click();
await chiefForm.locator('input[type=number]').fill('300');
await chiefForm.locator('select').selectOption('2');
const chiefTarget = chiefForm.locator('select');
await expect(chiefTarget.locator('option')).toHaveText([
'장수 (아국 · 업)',
'관우 (아국 · 업)',
'여포NPC (아국 · 업)',
]);
await chiefTarget.selectOption('3');
const geometry = await chiefForm.evaluate((element) => {
const row = element.querySelector('.argument-row');
const rect = element.getBoundingClientRect();
@@ -905,13 +1009,13 @@ test('enters general and nation command arguments and sends exact values', async
});
await chiefPicker.getByRole('button', { name: '입력', exact: true }).click();
await expect(page.locator('[data-command-scope="nation"] .action-column > div').first()).toHaveText(
'【관우】 쌀 300 포상'
'【여포NPC】 쌀 300 포상'
);
expect(JSON.stringify(requests)).toContain('"destCityId":2');
expect(JSON.stringify(requests)).toContain('"isGold":false');
expect(JSON.stringify(requests)).toContain('"amount":300');
expect(JSON.stringify(requests)).toContain('"destGeneralId":2');
expect(JSON.stringify(requests)).toContain('"destGeneralId":3');
expect(mapGeometry.width).toBeGreaterThan(650);
expect(mapGeometry.height / mapGeometry.width).toBeCloseTo(5 / 7, 2);
+12
View File
@@ -875,6 +875,18 @@ test('current-city wraps dense general names and only shrinks reserved turns', a
expect(pageStyle.fontFamily).toContain('Pretendard');
expect(pageStyle.fontSize).toBe('14px');
const controlFonts = await page
.locator('.city-page')
.evaluate(() =>
['#citySelector', '.back-link'].map(
(selector) => getComputedStyle(document.querySelector(selector)!).fontFamily
)
);
expect(controlFonts).toHaveLength(2);
for (const fontFamily of controlFonts) {
expect(fontFamily).toContain('Pretendard');
}
const names = page.locator('.general-names');
await expect(names).toHaveCSS('white-space', 'normal');
const nameLineCount = await names.locator('span').evaluateAll((elements) => {
+91
View File
@@ -791,6 +791,97 @@ test('메인 장수 동향과 개인 전투 기록은 모두 21px 행 간격을
await persistParityArtifact(page, 'core-main-personal-battle-log-inline-mobile', mobileGeometry);
});
test('메인 개인 기록의 공격·수비 시각은 Ref와 같은 90% 글자 크기로 표시한다', async ({ page }) => {
const state: FixtureState = {
permission: 'head',
myset: 3,
settingMutations: [],
accessPages: [],
recentRecords: {
global: [],
general: [
{
id: 18703,
text: '<C>●</>10월:천귀병으로 <Y>ⓝ염행</>의 보병을 <M>수비</>합니다.',
createdAt: '2026-01-01T03:54:00.000Z',
},
{
id: 18702,
text: '<C>●</>10월:천귀병으로 <Y>ⓝ염행</>의 보병을 <M>공격</>합니다.',
createdAt: '2026-01-01T03:55:00.000Z',
},
{
id: 18701,
text: '<C>●</>10월:이미 기록된 시각 <1>12:34</>',
createdAt: '2026-01-01T03:56:00.000Z',
},
],
history: [],
},
};
await install(page, state);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('');
const inspect = async (selector: string) => {
const lines = page.locator(selector);
await expect(lines).toHaveCount(3);
await expect(lines.nth(0)).toHaveText('●10월:천귀병으로 ⓝ염행의 보병을 수비합니다. 12:54');
await expect(lines.nth(1)).toHaveText('●10월:천귀병으로 ⓝ염행의 보병을 공격합니다. 12:55');
await expect(lines.nth(2)).toHaveText('●10월:이미 기록된 시각 12:34');
return lines.evaluateAll((elements) =>
elements.map((element) => {
const spans = [...element.querySelectorAll<HTMLElement>('span')];
const time = spans.find((span) => /^\d{2}:\d{2}$/u.test(span.textContent ?? ''));
const name = spans.find((span) => span.textContent === 'ⓝ염행');
const action = spans.find((span) => span.textContent === '수비' || span.textContent === '공격');
if (!time) throw new Error('개인 기록 시각 span을 찾지 못했습니다.');
const rect = element.getBoundingClientRect();
return {
text: element.textContent,
row: {
width: rect.width,
height: rect.height,
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
fontSize: getComputedStyle(element).fontSize,
lineHeight: getComputedStyle(element).lineHeight,
},
time: {
fontSize: getComputedStyle(time).fontSize,
lineHeight: getComputedStyle(time).lineHeight,
},
nameFontSize: name ? getComputedStyle(name).fontSize : null,
actionFontSize: action ? getComputedStyle(action).fontSize : null,
timeSpanCount: spans.filter((span) => /^\d{2}:\d{2}$/u.test(span.textContent ?? '')).length,
};
})
);
};
const assertFontContract = (measurements: Awaited<ReturnType<typeof inspect>>) => {
expect(measurements.map((entry) => entry.row.fontSize)).toEqual(['14px', '14px', '14px']);
expect(measurements.map((entry) => entry.row.lineHeight)).toEqual(['21px', '21px', '21px']);
expect(measurements.map((entry) => entry.row.height)).toEqual([21, 21, 21]);
expect(measurements.map((entry) => entry.time.fontSize)).toEqual(['12.6px', '12.6px', '12.6px']);
expect(measurements.map((entry) => entry.timeSpanCount)).toEqual([1, 1, 1]);
expect(measurements[0]?.nameFontSize).toBe('14px');
expect(measurements[0]?.actionFontSize).toBe('14px');
expect(measurements[1]?.nameFontSize).toBe('14px');
expect(measurements[1]?.actionFontSize).toBe('14px');
expect(measurements.every((entry) => entry.row.scrollWidth <= entry.row.clientWidth)).toBe(true);
};
const desktop = await inspect('.record-zone [data-record-bucket="general"] .record-line');
assertFontContract(desktop);
await persistParityArtifact(page, 'core-main-personal-war-log-time-font-desktop', desktop);
await page.setViewportSize({ width: 500, height: 900 });
const mobile = await inspect('.record-zone-mobile [data-record-bucket="general"] .record-line');
assertFontContract(mobile);
await persistParityArtifact(page, 'core-main-personal-war-log-time-font-mobile', mobile);
});
test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ page }) => {
const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [], accessPages: [] };
await install(page, state);
@@ -118,7 +118,8 @@ const persistScreenshot = async (page: Page, name: string, fallbackPath: string)
await page.screenshot({ path: resolve(responsiveArtifactDir, `${name}.webp`), fullPage: true });
};
const installFixture = async (page: Page) => {
const installFixture = async (page: Page, options: { applicationOpen?: boolean } = {}) => {
let joined = false;
await page.addInitScript((profile) => {
window.localStorage.setItem('sammo-game-token', 'ga_tournament_bracket_playwright');
window.localStorage.setItem('sammo-game-profile', profile);
@@ -141,7 +142,7 @@ const installFixture = async (page: Page) => {
if (operation === 'tournament.getSnapshot') {
return response({
state: {
stage: 0,
stage: options.applicationOpen ? 1 : 0,
phase: 0,
type: 0,
auto: false,
@@ -151,11 +152,32 @@ const installFixture = async (page: Page) => {
nextAt: '2026-08-02T00:00:00.000Z',
winnerId: 1,
},
participants,
participants:
options.applicationOpen && !joined
? []
: options.applicationOpen
? [
{
...participants[0],
groupId: 0,
groupNo: 0,
win: 0,
draw: 0,
lose: 0,
gl: 0,
seedRank: 0,
finalRank: 0,
},
]
: participants,
matches,
betCount: 16,
});
}
if (operation === 'tournament.join') {
joined = true;
return response({ ok: true, count: 1 });
}
if (operation === 'tournament.getBettingSummary') {
return response({
totals: Object.fromEntries(
@@ -245,9 +267,67 @@ test('desktop bracket connects every real general slot to the next round', async
expect(geometry.horizontalIdentities).toBe(true);
expect(Math.abs(geometry.firstParentY - geometry.firstPairAverageY)).toBeLessThan(1);
const controls = await page.locator('#tournament-container').evaluate((container) => {
const bounds = (selector: string) => container.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
const refresh = bounds('.toolbar button:first-child');
const join = bounds('.join-button');
const close = bounds('.close-button');
return {
refresh: { width: refresh.width, height: refresh.height },
join: { width: join.width, height: join.height },
close: { width: close.width, height: close.height },
};
});
expect(controls.refresh).toEqual({ width: 72, height: 44 });
expect(controls.join).toEqual({ width: 72, height: 44 });
expect(controls.close).toEqual({ width: 88, height: 44 });
const firstSlot = page.locator('.desktop-bracket-name').first();
const oddsContainment = await firstSlot.evaluate((slot) => {
const card = slot.getBoundingClientRect();
const odds = slot.querySelector<HTMLElement>('.bracket-odds')!.getBoundingClientRect();
return {
cardTop: card.top,
cardBottom: card.bottom,
oddsTop: odds.top,
oddsBottom: odds.bottom,
cardHeight: card.height,
};
});
expect(oddsContainment.cardHeight).toBeGreaterThanOrEqual(82);
expect(oddsContainment.oddsTop).toBeGreaterThanOrEqual(oddsContainment.cardTop);
expect(oddsContainment.oddsBottom).toBeLessThanOrEqual(oddsContainment.cardBottom);
await persistScreenshot(page, 'tournament-desktop', testInfo.outputPath('tournament-bracket-desktop.webp'));
});
test('join refresh shows the assigned preliminary group immediately with accessible controls', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await installFixture(page, { applicationOpen: true });
await page.goto('tournament');
const refresh = page.getByRole('button', { name: '갱신' });
const join = page.getByRole('button', { name: '참가' });
const close = page.getByRole('button', { name: '창 닫기' }).first();
await expect(join).toBeEnabled();
await join.click();
await expect(page.getByRole('status')).toHaveText('참가 신청이 반영되었습니다.');
await expect(join).toBeDisabled();
await expect(page.locator('.preliminary-grid .general-identity', { hasText: names[0] })).toBeVisible();
for (const control of [refresh, join, close]) {
const box = await control.boundingBox();
expect(box?.height).toBe(44);
expect(box?.width).toBeGreaterThanOrEqual(72);
}
await refresh.focus();
await expect(refresh).toBeFocused();
await refresh.hover();
await expect(refresh).toHaveCSS('filter', 'brightness(1.25)');
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
});
test('mobile bracket exposes every round through tabs with standard horizontal identities', async ({
page,
}, testInfo) => {
@@ -325,6 +405,14 @@ test('mobile bracket exposes every round through tabs with standard horizontal i
expect(identity.nameLeft).toBeGreaterThanOrEqual(identity.iconRight - 1);
expect(identity.nameTop).toBeLessThan(identity.iconBottom);
expect(identity.nameBottom).toBeGreaterThan(identity.iconTop);
const firstMobileSlot = bracket.locator('.mobile-bracket-name').first();
const mobileOddsContainment = await firstMobileSlot.evaluate((slot) => {
const card = slot.getBoundingClientRect();
const odds = slot.querySelector<HTMLElement>('.bracket-odds')!.getBoundingClientRect();
return { cardBottom: card.bottom, oddsBottom: odds.bottom, cardHeight: card.height };
});
expect(mobileOddsContainment.cardHeight).toBeGreaterThanOrEqual(82);
expect(mobileOddsContainment.oddsBottom).toBeLessThanOrEqual(mobileOddsContainment.cardBottom);
await expect(page.getByRole('tablist', { name: '본선 조 선택' })).toBeVisible();
await page.getByRole('tab', { name: '二조' }).first().click();
await expect(page.getByRole('tab', { name: '二조' }).first()).toHaveAttribute('aria-selected', 'true');