merge: 최신 main을 사령부 고급모드 제어행 수정에 통합한다

This commit is contained in:
2026-08-21 16:06:59 +00:00
16 changed files with 1328 additions and 319 deletions
+134 -3
View File
@@ -64,6 +64,7 @@ type FixtureState = {
joinConfig?: Record<string, unknown>;
createGeneralInputs?: Array<Record<string, unknown>>;
mainTraits?: { personal: string; specialDomestic: string; specialWar: string };
richMyInfo?: boolean;
hiddenSeedLogText?: string;
recentRecords?: {
global: Array<{ id: number; text: string; createdAt?: string }>;
@@ -103,7 +104,22 @@ const myGeneral = (state: FixtureState) => ({
turnTime: '2026-01-01 00:10:00',
crewTypeId: 1,
crewTypeName: '보병',
crewTypeInfo: state.richMyInfo
? {
name: '보병',
info: ['표준적인 보병입니다.', '보병은 방어특화이며,', '상대가 회피하기 어렵습니다.'],
requirements: ['기술력 1000 이상 필요'],
stats: { attack: 100, defence: 150, speed: 7, avoid: 10, magicCoef: 0, cost: 9, rice: 9 },
}
: null,
traits: state.mainTraits ?? { personal: '-', specialDomestic: '-', specialWar: '-' },
traitInfo: state.richMyInfo
? {
personal: '부상당할 확률이 감소합니다.',
specialDomestic: '상업 내정 효율이 증가합니다.',
specialWar: '계략 성공률이 증가합니다.<br>발동 순서는 레거시와 같습니다.',
}
: { personal: '', specialDomestic: '', specialWar: '' },
progression: {
experienceLevel: 1,
dedicationLevel: 2,
@@ -121,8 +137,20 @@ const myGeneral = (state: FixtureState) => ({
killedCrew: 12_345,
lostCrew: 6_789,
},
items: { horse: 'che_명마', weapon: null, book: null, item: null },
itemNames: { horse: '명마', weapon: null, book: null, item: null },
items: state.richMyInfo
? { horse: 'che_명마', weapon: 'che_단도', book: 'che_효경전', item: 'che_납금박산로' }
: { horse: 'che_명마', weapon: null, book: null, item: null },
itemNames: state.richMyInfo
? { horse: '명마', weapon: '단도', book: '효경전', item: '납금박산로' }
: { horse: '명마', weapon: null, book: null, item: null },
itemInfo: state.richMyInfo
? {
horse: '통솔 +3',
weapon: '무력 +1',
book: '지력 +1',
item: '내정 실행 시 성공률이 증가합니다.<br>소모되지 않습니다.',
}
: { horse: null, weapon: null, book: null, item: null },
},
city: {
id: 1,
@@ -201,6 +229,7 @@ const myGeneral = (state: FixtureState) => ({
capitalCityName: '업',
typePros: '금수입↑ 치안↑',
typeCons: '인구↓ 민심↓',
typeInfo: state.richMyInfo ? '법과 질서를 중시하여 국가 운영을 안정시킵니다.' : '',
population: { cityCount: 1, current: 1_000, max: 2_000 },
crew: { generalCount: 2, current: 500, max: 7_000 },
power: 1_234,
@@ -1349,6 +1378,106 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
await persistParityArtifact(page, 'core-my-page-mobile', mobile);
});
test('내 정보 항목과 국가 성향은 HTML 리치 툴팁을 마우스와 키보드로 표시한다', async ({ page }) => {
const state: FixtureState = {
permission: 'head',
myset: 3,
richMyInfo: true,
mainTraits: { personal: '안전', specialDomestic: '상재', specialWar: '신산' },
settingMutations: [],
accessPages: [],
};
await install(page, state);
const visibleTooltip = page.locator('.tippy-box[data-theme~="sammo-rich"][data-state="visible"]');
const showWithMouse = async (testId: string, expectedTexts: readonly string[]) => {
const trigger = page.locator(`[data-rich-tooltip="${testId}"]`);
await expect(trigger).toHaveAttribute('tabindex', '0');
await trigger.hover();
await expect(visibleTooltip).toHaveCount(1);
await expect(visibleTooltip).toHaveAttribute('role', 'tooltip');
for (const expectedText of expectedTexts) {
await expect(visibleTooltip).toContainText(expectedText);
}
await expect(trigger).toHaveAttribute('aria-describedby', /tippy-/u);
await page.mouse.move(1, 1);
await expect(visibleTooltip).toHaveCount(0);
};
await page.setViewportSize({ width: 1000, height: 900 });
await page.goto('my-page');
await expect(page.locator('[data-general-basic-card]')).toBeVisible();
const cardBefore = await page.locator('[data-general-basic-card]').boundingBox();
await showWithMouse('horse', ['명마', '통솔 +3']);
await showWithMouse('weapon', ['단도', '무력 +1']);
await showWithMouse('book', ['효경전', '지력 +1']);
await showWithMouse('item', ['납금박산로', '내정 실행 시 성공률이 증가합니다.', '소모되지 않습니다.']);
await showWithMouse('crew-type', [
'보병',
'표준적인 보병입니다.',
'전투 정보',
'공격 100 · 방어 150',
'병사 100명 기준 금 9 · 쌀 9',
'생성 조건',
'기술력 1000 이상 필요',
]);
await showWithMouse('personality', ['안전', '부상당할 확률이 감소합니다.']);
await showWithMouse('special-domestic', ['내정특기 · 상재', '상업 내정 효율이 증가합니다.']);
await showWithMouse('special-war', [
'전투특기 · 신산',
'계략 성공률이 증가합니다.',
'발동 순서는 레거시와 같습니다.',
]);
expect(await page.locator('[data-general-basic-card]').boundingBox()).toEqual(cardBefore);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(1000);
const warTrigger = page.locator('[data-rich-tooltip="special-war"]');
await warTrigger.focus();
await expect(warTrigger).toBeFocused();
await expect(visibleTooltip).toHaveCount(1);
await expect(visibleTooltip.locator('.rich-tooltip-content__line')).toHaveCount(2);
expect(await visibleTooltip.locator('.tippy-content').innerHTML()).not.toContain('&lt;br');
await persistParityArtifact(page, 'core-my-info-rich-tooltip-desktop', {
trigger: await warTrigger.boundingBox(),
tooltip: await visibleTooltip.boundingBox(),
scrollWidth: await page.evaluate(() => document.documentElement.scrollWidth),
});
await page.setViewportSize({ width: 390, height: 844 });
await page.reload();
const crewTrigger = page.locator('[data-rich-tooltip="crew-type"]');
await crewTrigger.focus();
await expect(visibleTooltip).toHaveCount(1);
const mobileTooltip = await visibleTooltip.boundingBox();
expect(mobileTooltip).not.toBeNull();
expect(mobileTooltip!.x).toBeGreaterThanOrEqual(0);
expect(mobileTooltip!.x + mobileTooltip!.width).toBeLessThanOrEqual(390);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(390);
await persistParityArtifact(page, 'core-my-info-rich-tooltip-mobile', {
trigger: await crewTrigger.boundingBox(),
tooltip: mobileTooltip,
scrollWidth: await page.evaluate(() => document.documentElement.scrollWidth),
});
await page.setViewportSize({ width: 1000, height: 900 });
await page.goto('');
const nationType = page.locator('[data-rich-tooltip="nation-type"]');
await nationType.hover();
await expect(visibleTooltip).toHaveCount(1);
await expect(visibleTooltip).toContainText('국가 성향 · 법가');
await expect(visibleTooltip).toContainText('법과 질서를 중시하여 국가 운영을 안정시킵니다.');
await expect(visibleTooltip).toContainText('장점 금수입↑ 치안↑');
await expect(visibleTooltip).toContainText('단점 인구↓ 민심↓');
await nationType.focus();
await expect(nationType).toBeFocused();
await expect(visibleTooltip).toHaveCount(1);
await persistParityArtifact(page, 'core-nation-type-rich-tooltip-desktop', {
trigger: await nationType.boundingBox(),
tooltip: await visibleTooltip.boundingBox(),
});
});
test('내 정보&설정의 지난 플레이는 기본 탐색과 분리되어 오른쪽에 정렬된다', async ({ page }) => {
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
await install(page, state);
@@ -1528,7 +1657,9 @@ test('실제 모바일 터치로 메인 패널 순서를 재정렬한다', async
await dialog.screenshot({ path: testInfo.outputPath('mobile-main-panel-touch-dialog.png') });
await dialog.getByRole('button', { name: '적용', exact: true }).click();
await expect
.poll(() => mobilePage.evaluate(() => JSON.parse(localStorage.getItem('sam.mobileMainPanelOrder.v1') ?? '[]')))
.poll(() =>
mobilePage.evaluate(() => JSON.parse(localStorage.getItem('sam.mobileMainPanelOrder.v1') ?? '[]'))
)
.toEqual([
'nation-menu',
'commands',
+152 -17
View File
@@ -90,6 +90,20 @@ const matches = [
];
const response = (data: unknown) => ({ result: { data } });
const asRecord = (value: unknown): Record<string, unknown> | null =>
typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
const findBetInput = (value: unknown): { targetId: number; amount: number } | null => {
const record = asRecord(value);
if (!record) return null;
if (typeof record.targetId === 'number' && typeof record.amount === 'number') {
return { targetId: record.targetId, amount: record.amount };
}
for (const child of Object.values(record)) {
const result = findBetInput(child);
if (result) return result;
}
return null;
};
const operationNames = (route: Route): string[] => {
const url = new URL(route.request().url());
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
@@ -126,8 +140,17 @@ const persistScreenshot = async (page: Page, name: string, fallbackPath: string)
await page.screenshot({ path: resolve(responsiveArtifactDir, `${name}.webp`), fullPage: true });
};
const installFixture = async (page: Page, options: { applicationOpen?: boolean; tournamentType?: number } = {}) => {
const installFixture = async (
page: Page,
options: {
applicationOpen?: boolean;
tournamentType?: number;
tournamentStage?: number;
joinedGroupId?: number;
} = {}
) => {
let joined = false;
const placedBets: Array<{ targetId: number; amount: number }> = [];
await page.addInitScript((profile) => {
window.localStorage.setItem('sammo-game-token', 'ga_tournament_bracket_playwright');
window.localStorage.setItem('sammo-game-profile', profile);
@@ -148,9 +171,11 @@ const installFixture = async (page: Page, options: { applicationOpen?: boolean;
if (operation === 'general.me') return response({ general: { id: 1, name: names[0] } });
if (operation === 'tournament.getAdminStatus') return response({ ok: false });
if (operation === 'tournament.getSnapshot') {
const tournamentStage = options.tournamentStage ?? (options.applicationOpen ? 1 : 0);
const joinedGroupId = options.joinedGroupId ?? 0;
return response({
state: {
stage: options.applicationOpen ? 1 : 0,
stage: tournamentStage,
phase: 0,
type: options.tournamentType ?? 0,
auto: false,
@@ -158,7 +183,7 @@ const installFixture = async (page: Page, options: { applicationOpen?: boolean;
openMonth: 1,
termSeconds: 60,
nextAt: '2026-08-02T00:00:00.000Z',
winnerId: 1,
winnerId: tournamentStage === 0 ? 1 : undefined,
},
participants:
options.applicationOpen && !joined
@@ -167,8 +192,10 @@ const installFixture = async (page: Page, options: { applicationOpen?: boolean;
? [
{
...participants[0],
groupId: 0,
groupId: joinedGroupId,
groupNo: 0,
preliminaryGroupId: joinedGroupId,
preliminaryGroupNo: 0,
win: 0,
draw: 0,
lose: 0,
@@ -196,6 +223,12 @@ const installFixture = async (page: Page, options: { applicationOpen?: boolean;
myAmount: 160,
});
}
if (operation === 'tournament.placeBet') {
const input = findBetInput(route.request().postDataJSON());
if (!input) throw new Error('베팅 요청에서 targetId와 amount를 찾을 수 없습니다.');
placedBets.push(input);
return response({ ok: true });
}
if (operation === 'tournament.getRankings') {
return response(
[
@@ -229,6 +262,7 @@ const installFixture = async (page: Page, options: { applicationOpen?: boolean;
});
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
});
return { placedBets };
};
const openTournament = async (page: Page) => {
@@ -322,20 +356,36 @@ test('desktop bracket connects every real general slot to the next round', async
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 }) => {
test('join refresh shows the assigned preliminary group immediately with accessible controls', async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
await installFixture(page, { applicationOpen: true });
await installFixture(page, { applicationOpen: true, joinedGroupId: 5 });
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 expect(page.getByText('조별 예선 순위')).toBeVisible();
await expect(page.getByText('조별 본선 순위')).toHaveCount(0);
await expect(page.getByLabel('토너먼트 대진표')).toHaveCount(0);
await join.click();
await expect(page.getByRole('status')).toHaveText('참가 신청이 반영되었습니다.');
await expect(page.getByRole('status')).toHaveText('참가 신청이 반영되었습니다. 六조에 배정되었습니다.');
await expect(join).toBeDisabled();
await expect(page.locator('.preliminary-grid .general-identity', { hasText: names[0] })).toBeVisible();
const preliminaryTabs = page.getByRole('tablist', { name: '예선 조 선택' });
await expect(preliminaryTabs.getByRole('tab').nth(5)).toHaveAttribute('aria-selected', 'true');
const assignedGroup = page.locator('[data-preliminary-group="5"]');
await expect(assignedGroup.locator('.general-identity', { hasText: names[0] })).toBeVisible();
const assignedGroupBounds = await assignedGroup.boundingBox();
expect(assignedGroupBounds?.y).toBeLessThan(844);
expect((assignedGroupBounds?.y ?? 0) + (assignedGroupBounds?.height ?? 0)).toBeGreaterThan(0);
await persistScreenshot(
page,
'tournament-joined-group-mobile',
testInfo.outputPath('tournament-joined-group.webp')
);
for (const control of [refresh, join, close]) {
const box = await control.boundingBox();
@@ -349,6 +399,41 @@ test('join refresh shows the assigned preliminary group immediately with accessi
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
});
test('desktop join scrolls the assigned preliminary group into view without future sections', async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 1365, height: 900 });
await installFixture(page, { applicationOpen: true, joinedGroupId: 7 });
await page.goto('tournament');
await expect(page.getByText('조별 본선 순위')).toHaveCount(0);
await expect(page.getByLabel('토너먼트 대진표')).toHaveCount(0);
await page.getByRole('button', { name: '참가' }).click();
await expect(page.getByRole('status')).toHaveText('참가 신청이 반영되었습니다. 八조에 배정되었습니다.');
const assignedGroup = page.locator('[data-preliminary-group="7"]');
await expect(assignedGroup.locator('.general-identity', { hasText: names[0] })).toBeVisible();
const bounds = await assignedGroup.boundingBox();
expect(bounds?.y).toBeLessThan(900);
expect((bounds?.y ?? 0) + (bounds?.height ?? 0)).toBeGreaterThan(0);
await persistScreenshot(
page,
'tournament-joined-group-desktop',
testInfo.outputPath('tournament-joined-group.webp')
);
});
test('final group section appears before the later knockout section', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
await installFixture(page, { tournamentStage: 3 });
await page.goto('tournament');
await expect(page.getByText('조별 예선 순위')).toBeVisible();
await expect(page.getByText('조별 본선 순위')).toBeVisible();
await expect(page.getByLabel('토너먼트 대진표')).toHaveCount(0);
await persistScreenshot(page, 'tournament-final-stage-mobile', testInfo.outputPath('tournament-final-stage.webp'));
});
test('mobile bracket exposes every round through tabs with standard horizontal identities', async ({
page,
}, testInfo) => {
@@ -480,12 +565,49 @@ test('tournament and betting pages expose same-row navigation tabs beside close'
test('mobile betting rankings use tabs and keep dedicated icons beside general names', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
await installFixture(page);
const { placedBets } = await installFixture(page, { tournamentStage: 6 });
await page.goto('betting');
await expect(page.locator('.candidate-card')).toHaveCount(16);
await expect(page.locator('.candidate-table')).toHaveCount(0);
const betButtons = page.locator('.mobile-bracket .bracket-bet-button:visible');
await expect(betButtons).toHaveCount(16);
await expect(page.locator('.betting-bracket .bracket-core-stat').first()).toHaveText('종합 240');
await expect(page.locator('.betting-bracket .bracket-my-bet').first()).toHaveText('내 투자 금120');
const firstCard = page.locator('.mobile-bracket-name[data-general-id="1"]');
const firstBetButton = page.getByRole('button', { name: '관우에게 베팅하기' });
const corner = await firstCard.evaluate((card) => {
const own = card.getBoundingClientRect();
const button = card.querySelector<HTMLElement>('.bracket-bet-button')!.getBoundingClientRect();
return {
topOffset: button.top - own.top,
rightOffset: own.right - button.right,
contained: button.top >= own.top && button.right <= own.right && button.bottom <= own.bottom,
};
});
expect(corner.topOffset).toBeGreaterThanOrEqual(2);
expect(corner.topOffset).toBeLessThanOrEqual(4);
expect(corner.rightOffset).toBeGreaterThanOrEqual(2);
expect(corner.rightOffset).toBeLessThanOrEqual(4);
expect(corner.contained).toBe(true);
await firstBetButton.hover();
await expect(firstBetButton).toHaveCSS('filter', 'brightness(1.25)');
await firstBetButton.focus();
await expect(firstBetButton).toBeFocused();
await firstBetButton.click();
const dialog = page.getByRole('dialog', { name: '베팅하기' });
await expect(dialog).toBeVisible();
await expect(dialog.getByText('배당 28.00')).toBeVisible();
await expect(dialog.getByText('예상 환수금 280')).toBeVisible();
await dialog.getByLabel('베팅 금액').selectOption('50');
await expect(dialog.getByText('예상 환수금 1,400')).toBeVisible();
await persistScreenshot(page, 'tournament-betting-dialog-mobile', testInfo.outputPath('betting-dialog-mobile.webp'));
await dialog.getByRole('button', { name: '베팅 등록' }).click();
await expect(dialog).not.toBeVisible();
await expect(page.getByRole('status')).toHaveText('베팅이 등록되었습니다.');
expect(placedBets).toEqual([{ targetId: 1, amount: 50 }]);
await expect(page.getByRole('tablist', { name: '토너먼트 랭킹 종목 선택' })).toBeVisible();
await expect(page.locator('.ranking-table:visible')).toHaveCount(1);
await page.getByRole('tab', { name: '통솔전' }).click();
@@ -509,7 +631,7 @@ test('mobile betting rankings use tabs and keep dedicated icons beside general n
test('betting bracket shows intelligence for debate tournament candidates', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await installFixture(page, { tournamentType: 3 });
await installFixture(page, { tournamentType: 3, tournamentStage: 6 });
await page.goto('betting');
await expect(page.locator('.betting-bracket .bracket-core-stat').first()).toHaveText('지력 80');
@@ -522,17 +644,30 @@ test('desktop betting presents icon-and-name cards and all four rankings without
page,
}, testInfo) => {
await page.setViewportSize({ width: 1365, height: 900 });
await installFixture(page);
await installFixture(page, { tournamentStage: 6 });
await page.goto('betting');
await expect(page.locator('.candidate-card')).toHaveCount(16);
await expect(page.locator('.candidate-table')).toHaveCount(0);
await expect(page.locator('.desktop-bracket .bracket-bet-button:visible')).toHaveCount(16);
await expect(page.locator('.ranking-table:visible')).toHaveCount(4);
await expect(page.locator('.general-identity-icon').first()).toHaveCSS('width', '64px');
await expect(page.locator('.general-identity-icon').first()).toHaveCSS('height', '64px');
const columns = await page
.locator('.candidate-grid')
.evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(' ').length);
expect(columns).toBe(4);
const firstCardCorner = await page
.locator('.desktop-bracket-name.betting-target[data-general-id="1"]')
.evaluate((card) => {
const own = card.getBoundingClientRect();
const button = card.querySelector<HTMLElement>('.bracket-bet-button')!.getBoundingClientRect();
return {
topOffset: button.top - own.top,
rightOffset: own.right - button.right,
contained: button.top >= own.top && button.right <= own.right && button.bottom <= own.bottom,
};
});
expect(firstCardCorner.topOffset).toBeGreaterThanOrEqual(2);
expect(firstCardCorner.topOffset).toBeLessThanOrEqual(4);
expect(firstCardCorner.rightOffset).toBeGreaterThanOrEqual(2);
expect(firstCardCorner.rightOffset).toBeLessThanOrEqual(4);
expect(firstCardCorner.contained).toBe(true);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(1365);
await persistScreenshot(page, 'tournament-ranking-desktop', testInfo.outputPath('tournament-ranking-desktop.webp'));
});