merge: 최신 main을 레거시 DB 재이관에 통합

# Conflicts:
#	app/gateway-api/test/releaseManifest.test.ts
#	release-manifest.json
This commit is contained in:
2026-08-17 15:58:39 +00:00
28 changed files with 1228 additions and 128 deletions
+153 -4
View File
@@ -265,6 +265,71 @@ const commandTable = {
],
inputOptions,
};
const basicRecruitmentCrewTypes = [
{
id: 1100,
armType: 1,
name: '보병',
attack: 100,
defence: 150,
speed: 7,
avoid: 10,
baseCost: 9,
baseRice: 9,
info: ['표준적인 보병입니다.'],
},
{
id: 1200,
armType: 2,
name: '궁병',
attack: 100,
defence: 100,
speed: 7,
avoid: 20,
baseCost: 10,
baseRice: 10,
info: ['표준적인 궁병입니다.'],
},
{
id: 1300,
armType: 3,
name: '기병',
attack: 150,
defence: 100,
speed: 7,
avoid: 5,
baseCost: 11,
baseRice: 11,
info: ['표준적인 기병입니다.'],
},
{
id: 1400,
armType: 4,
name: '귀병',
attack: 80,
defence: 80,
speed: 7,
avoid: 5,
baseCost: 9,
baseRice: 9,
info: ['계략을 사용하는 병종입니다.'],
},
].map((crewType) => ({ ...crewType, available: true, special: false }));
const fourArmRecruitmentCommandTable = {
...commandTable,
inputOptions: {
...inputOptions,
crewTypes: basicRecruitmentCrewTypes.map((crewType) => ({ value: crewType.id, label: crewType.name })),
recruitment: {
...inputOptions.recruitment,
groups: basicRecruitmentCrewTypes.map((crewType) => ({
armType: crewType.armType,
armName: crewType.name,
values: [crewType],
})),
},
},
};
const buildSimpleCommand = (key: string, name: string) => ({
key,
name,
@@ -379,14 +444,25 @@ const generalContext = {
name: '아국',
color: '#008000',
level: 1,
levelName: '호족',
gold: 5000,
rice: 6000,
tech: 100,
typeCode: 'che_중립',
typeName: '중립',
capitalCityId: 1,
capitalCityName: '',
typePros: '',
typeCons: '',
population: { cityCount: 1, current: 1000, max: 2000 },
crew: { generalCount: 2, current: 500, max: 7000 },
power: 1234,
bill: 100,
taxRate: 20,
strategicCommandLimit: 0,
diplomaticLimit: 0,
prohibitScout: false,
prohibitWar: false,
techLevel: 1,
techLimited: false,
topChiefs: {},
impossibleStrategicCommands: [],
},
settings: {},
penalties: {},
@@ -1210,6 +1286,79 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).overflow)).not.toBe('hidden');
});
test('keeps arbitrary direct recruitment and mercenary amounts for all four arms after turn refresh', async ({
page,
}, testInfo) => {
const requests = await install(page, false, fourArmRecruitmentCommandTable);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('/');
const entries = [
{ turn: 1, command: '징병', crewTypeId: 1100, name: '보병', inputAmount: 13, savedAmount: 1300 },
{ turn: 2, command: '징병', crewTypeId: 1200, name: '궁병', inputAmount: 27, savedAmount: 2700 },
{ turn: 3, command: '징병', crewTypeId: 1300, name: '기병', inputAmount: 41, savedAmount: 4100 },
{ turn: 4, command: '징병', crewTypeId: 1400, name: '귀병', inputAmount: 59, savedAmount: 5900 },
{ turn: 5, command: '모병', crewTypeId: 1100, name: '보병', inputAmount: 17, savedAmount: 1700 },
{ turn: 6, command: '모병', crewTypeId: 1200, name: '궁병', inputAmount: 31, savedAmount: 3100 },
{ turn: 7, command: '모병', crewTypeId: 1300, name: '기병', inputAmount: 43, savedAmount: 4300 },
{ turn: 8, command: '모병', crewTypeId: 1400, name: '귀병', inputAmount: 61, savedAmount: 6100 },
];
for (const entry of entries) {
await page.getByRole('button', { name: `${entry.turn}턴 명령 입력`, exact: true }).click();
const picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: '내정', exact: true }).click();
await picker.getByRole('button', { name: entry.command, exact: true }).click();
const row = picker.getByRole('button', { name: `${entry.name} 선택 가능`, exact: true });
const amountInput = row.locator('input[type=number]');
await amountInput.fill(String(entry.inputAmount));
await expect(amountInput).toHaveValue(String(entry.inputAmount));
if (entry.turn === 1) {
const inputGeometry = await amountInput.evaluate((element) => {
if (!(element instanceof HTMLInputElement)) throw new Error('Expected recruitment amount input');
const rect = element.getBoundingClientRect();
return {
width: rect.width,
height: rect.height,
textAlign: getComputedStyle(element).textAlign,
value: element.value,
};
});
expect(inputGeometry).toMatchObject({ height: 28, textAlign: 'right', value: '13' });
expect(inputGeometry.width).toBeGreaterThan(0);
await picker.screenshot({ path: testInfo.outputPath('recruitment-direct-amount-desktop.png') });
}
await row.getByRole('button', { name: entry.command, exact: true }).click();
await expect(picker).toHaveCount(0);
await expect(page.locator('[data-command-scope="general"] .action-column > div').nth(entry.turn - 1)).toHaveText(
`${entry.name}${entry.savedAmount}${entry.command}`
);
}
const refreshResponse = page.waitForResponse((apiResponse) =>
decodeURIComponent(apiResponse.url()).includes('turns.reserved.getGeneral')
);
await page.getByRole('button', { name: '갱 신', exact: true }).click();
await refreshResponse;
for (const entry of entries) {
await expect(page.locator('[data-command-scope="general"] .action-column > div').nth(entry.turn - 1)).toHaveText(
`${entry.name}${entry.savedAmount}${entry.command}`
);
}
await page
.locator('[data-command-scope="general"]')
.screenshot({ path: testInfo.outputPath('recruitment-arbitrary-amounts-after-refresh.png') });
const serializedRequests = JSON.stringify(requests);
for (const entry of entries) {
expect(serializedRequests).toContain(`"crewType":${entry.crewTypeId}`);
expect(serializedRequests).toContain(`"amount":${entry.savedAmount}`);
}
});
test('uses the map to choose a nation target in the chief command window', async ({ page }) => {
await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
+141 -2
View File
@@ -995,6 +995,123 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
});
test('split buttons keep square inner corners and a single divider in every interaction state', async ({
page,
}, testInfo) => {
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
nationLevel: 3,
stage: 1,
npcMode: 1,
scenarioTitle: '분할 버튼 이음새 검증 시나리오',
generalMeCalls: 0,
operations: [],
nationColor: '#663399',
};
await installFixture(page, state);
if (artifactRoot) await mkdir(resolve(artifactRoot), { recursive: true });
const measure = (main: Locator, toggle: Locator) =>
Promise.all([
main.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
rect: rect.toJSON(),
borderRightWidth: style.borderRightWidth,
borderTopLeftRadius: style.borderTopLeftRadius,
borderTopRightRadius: style.borderTopRightRadius,
borderBottomRightRadius: style.borderBottomRightRadius,
borderBottomLeftRadius: style.borderBottomLeftRadius,
};
}),
toggle.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
rect: rect.toJSON(),
borderLeftWidth: style.borderLeftWidth,
borderTopLeftRadius: style.borderTopLeftRadius,
borderTopRightRadius: style.borderTopRightRadius,
borderBottomRightRadius: style.borderBottomRightRadius,
borderBottomLeftRadius: style.borderBottomLeftRadius,
};
}),
]);
const expectAttached = async (main: Locator, toggle: Locator) => {
const [mainStyle, toggleStyle] = await measure(main, toggle);
expect(mainStyle).toMatchObject({
borderRightWidth: '1px',
borderTopLeftRadius: '5.25px',
borderTopRightRadius: '0px',
borderBottomRightRadius: '0px',
borderBottomLeftRadius: '5.25px',
});
expect(toggleStyle).toMatchObject({
borderLeftWidth: '0px',
borderTopLeftRadius: '0px',
borderTopRightRadius: '5.25px',
borderBottomRightRadius: '5.25px',
borderBottomLeftRadius: '0px',
});
expect(toggleStyle.rect.left).toBeCloseTo(mainStyle.rect.right, 2);
};
for (const width of [1200, 500]) {
await page.setViewportSize({ width, height: 900 });
await waitForMain(page);
const globalSplit = page.locator('.main-global-menu:visible .main-menu-split').first();
const nationSplit = page.locator('.main-nation-menu:visible .nation-menu-split').first();
const pairs: Array<[string, Locator, Locator]> = [
[
'global',
globalSplit.locator('[data-navigation-id="board-community"]'),
globalSplit.locator('[data-menu-id="boards"]'),
],
[
'nation',
nationSplit.locator('[data-navigation-id="auction-resource"]'),
nationSplit.locator('[data-menu-id="auction"]'),
],
];
for (const [label, main, toggle] of pairs) {
await expect(main).toBeVisible();
await expect(toggle).toBeVisible();
await page.mouse.move(width - 1, 899);
await expectAttached(main, toggle);
await toggle.focus();
await expect(toggle).toBeFocused();
await expectAttached(main, toggle);
await toggle.hover();
await expectAttached(main, toggle);
const box = await toggle.boundingBox();
if (!box) throw new Error(`${label} split toggle is not measurable`);
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await expectAttached(main, toggle);
await page.mouse.move(width - 1, 899);
await page.mouse.up();
await toggle.click();
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await expectAttached(main, toggle);
await page.keyboard.press('Escape');
await toggle.locator('..').screenshot({
path: artifactRoot
? resolve(artifactRoot, `${basePath.slice(1)}-${width}-${label}-split-button.png`)
: testInfo.outputPath(`${width}-${label}-split-button.png`),
});
}
}
});
test('the repeated bottom global menu opens upward on the mobile document', async ({ page }, testInfo) => {
const state: NavigationFixture = {
officerLevel: 5,
@@ -2107,16 +2224,24 @@ test('mobile bottom controls share Ref pressed geometry while their color bases
await expect
.poll(() => page.locator(manualRefreshSelector).evaluate((element) => getComputedStyle(element).boxShadow))
.not.toBe('none');
const callsBeforeManualRefresh = state.generalMeCalls;
await page.locator(manualRefreshSelector).click();
await expect(page.locator(manualRefreshSelector)).toBeDisabled();
await expect(page.locator(manualRefreshSelector)).toBeEnabled();
await expect(page.locator(manualRefreshSelector)).toHaveAttribute('aria-busy', 'true');
await page.locator(manualRefreshSelector).click();
await expect(page.getByTestId('game-toast')).toContainText('이미 정보를 갱신하고 있습니다.');
await page.mouse.move(1, 1);
expect(await buttonStyle(manualRefreshSelector)).toMatchObject({
backgroundColor: 'rgb(33, 37, 41)',
borderBottomWidth: '4px',
marginTop: '0px',
height: 45,
});
await expect(page.locator(manualRefreshSelector)).toHaveCSS('opacity', '0.55');
await expect(page.locator(manualRefreshSelector)).toHaveCSS('opacity', '1');
await page.screenshot({ path: testInfo.outputPath('mobile-refresh-busy-toast.png'), fullPage: true });
await expect(page.locator(manualRefreshSelector)).toHaveAttribute('aria-busy', 'false');
await expect(page.locator(manualRefreshSelector)).toBeEnabled();
expect(state.generalMeCalls).toBe(callsBeforeManualRefresh + 1);
await persistArtifact(page, `${basePath.slice(1)}-mobile-bottom-ref-buttons`);
});
@@ -2685,6 +2810,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
const operationsBeforeChangedBurst = state.operations.length;
state.generalName = '부드럽게갱신된장수';
state.refreshDelayMs = 1_000;
await page.evaluate(() => {
const emit = (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void })
.__emitMainRealtime;
@@ -2706,7 +2832,20 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
}
});
const manualRefresh = page.getByRole('button', { name: '갱 신' });
await expect(manualRefresh).toHaveAttribute('aria-busy', 'true');
await expect(manualRefresh).toBeEnabled();
await manualRefresh.click();
await expect(page.getByTestId('game-toast')).toContainText('이미 정보를 갱신하고 있습니다.');
if (autoRefreshArtifactRoot) {
await page.screenshot({
path: resolve(autoRefreshArtifactRoot, 'auto-refresh-busy-feedback.png'),
fullPage: true,
});
}
await expect.poll(() => state.generalMeCalls, { timeout: 3_000 }).toBe(callsBeforeRefresh + 1);
state.refreshDelayMs = 300;
await expect(page.locator('[data-main-target="general"] .skeleton-line')).toHaveCount(0);
await expect(page.locator('[data-main-target="city"] .skeleton-line')).toHaveCount(0);
await expect(page.getByRole('button', { name: '갱 신' })).toHaveAttribute('aria-busy', 'false');
@@ -35,6 +35,7 @@ const names = [
'허저',
'주태',
longGeneralName,
...Array.from({ length: 48 }, (_, index) => `예선장수${index + 17}`),
];
const participants = names.map((name, index) => ({
id: index + 1,
@@ -45,13 +46,20 @@ const participants = names.map((name, index) => ({
level: 10,
picture: 'default.jpg',
imageServer: 0,
groupId: 10 + (index % 8),
groupId: Math.floor(index / 8) < 4 ? 10 + (index % 8) : index % 8,
groupNo: Math.floor(index / 8),
win: 3 - (index % 2),
win: Math.floor(index / 8) < 4 ? 3 - (index % 2) : 7 - Math.floor(index / 8),
draw: index % 2,
lose: 0,
gl: 12 - index,
lose: Math.floor(index / 8) < 4 ? 0 : Math.floor(index / 8),
gl: 64 - index,
finalRank: Math.floor(index / 8) + 1,
preliminaryGroupId: index % 8,
preliminaryGroupNo: Math.floor(index / 8),
preliminaryRank: Math.floor(index / 8) + 1,
preliminaryWin: 7 - Math.floor(index / 8),
preliminaryDraw: index % 2,
preliminaryLose: Math.floor(index / 8),
preliminaryGl: 64 - index,
}));
const matches = [
...Array.from({ length: 8 }, (_, index) => ({
@@ -181,11 +189,11 @@ const installFixture = async (page: Page, options: { applicationOpen?: boolean }
if (operation === 'tournament.getBettingSummary') {
return response({
totals: Object.fromEntries(
participants.map((participant, index) => [participant.id, 100 + index * 10])
participants.slice(0, 16).map((participant, index) => [participant.id, 100 + index * 10])
),
myTotals: {},
myTotals: { 1: 120, 2: 40 },
totalAmount: 2800,
myAmount: 0,
myAmount: 160,
});
}
if (operation === 'tournament.getRankings') {
@@ -297,6 +305,14 @@ test('desktop bracket connects every real general slot to the next round', async
expect(oddsContainment.cardHeight).toBeGreaterThanOrEqual(82);
expect(oddsContainment.oddsTop).toBeGreaterThanOrEqual(oddsContainment.cardTop);
expect(oddsContainment.oddsBottom).toBeLessThanOrEqual(oddsContainment.cardBottom);
await expect(firstSlot.locator('.bracket-my-bet')).toHaveText('내 투자 금120');
const preliminaryTables = page.locator('.preliminary-grid table');
await expect(preliminaryTables).toHaveCount(8);
for (let groupIndex = 0; groupIndex < 8; groupIndex += 1) {
await expect(preliminaryTables.nth(groupIndex).locator('tbody tr')).toHaveCount(8);
await expect(preliminaryTables.nth(groupIndex).locator('.general-identity')).toHaveCount(8);
}
await persistScreenshot(page, 'tournament-desktop', testInfo.outputPath('tournament-bracket-desktop.webp'));
});
@@ -413,6 +429,7 @@ test('mobile bracket exposes every round through tabs with standard horizontal i
});
expect(mobileOddsContainment.cardHeight).toBeGreaterThanOrEqual(82);
expect(mobileOddsContainment.oddsBottom).toBeLessThanOrEqual(mobileOddsContainment.cardBottom);
await expect(firstMobileSlot.locator('.bracket-my-bet')).toHaveText('내 투자 금120');
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');
@@ -420,12 +437,39 @@ test('mobile bracket exposes every round through tabs with standard horizontal i
await persistScreenshot(page, 'tournament-mobile', testInfo.outputPath('tournament-bracket-mobile.webp'));
});
test('tournament and betting pages expose same-row navigation tabs beside close', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await openTournament(page);
const navigation = page.getByRole('tablist', { name: '토너먼트와 베팅장 이동' });
const tournamentTab = navigation.getByRole('tab', { name: '토너먼트' });
const bettingTab = navigation.getByRole('tab', { name: '베팅장' });
const close = page.getByRole('button', { name: '창 닫기' }).first();
await expect(tournamentTab).toHaveAttribute('aria-selected', 'true');
const headerCenters = await Promise.all(
[tournamentTab, bettingTab, close].map(async (control) => {
const box = await control.boundingBox();
return box ? box.y + box.height / 2 : -1;
})
);
expect(Math.max(...headerCenters) - Math.min(...headerCenters)).toBeLessThan(1);
await bettingTab.click();
await expect(page).toHaveURL(/\/betting$/);
await expect(page.getByRole('tab', { name: '베팅장' })).toHaveAttribute('aria-selected', 'true');
await page.getByRole('tab', { name: '토너먼트' }).click();
await expect(page).toHaveURL(/\/tournament$/);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
});
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);
await page.goto('betting');
await expect(page.locator('.candidate-card')).toHaveCount(16);
await expect(page.locator('.betting-bracket .bracket-my-bet').first()).toHaveText('내 투자 금120');
await expect(page.getByRole('tablist', { name: '토너먼트 랭킹 종목 선택' })).toBeVisible();
await expect(page.locator('.ranking-table:visible')).toHaveCount(1);
await page.getByRole('tab', { name: '통솔전' }).click();