diff --git a/app/game-api/src/router/tournament/index.ts b/app/game-api/src/router/tournament/index.ts index 181e2cfc..9aa986e9 100644 --- a/app/game-api/src/router/tournament/index.ts +++ b/app/game-api/src/router/tournament/index.ts @@ -71,6 +71,13 @@ const zParticipant = z.object({ gl: z.number().int().optional(), seedRank: z.number().int().optional(), finalRank: z.number().int().optional(), + preliminaryGroupId: z.number().int().min(0).max(7).optional(), + preliminaryGroupNo: z.number().int().min(0).max(7).optional(), + preliminaryRank: z.number().int().min(1).max(8).optional(), + preliminaryWin: z.number().int().min(0).optional(), + preliminaryDraw: z.number().int().min(0).optional(), + preliminaryLose: z.number().int().min(0).optional(), + preliminaryGl: z.number().int().optional(), }); const zMatch = z.object({ diff --git a/app/game-api/src/tournament/types.ts b/app/game-api/src/tournament/types.ts index e05e478e..c0a4ab9c 100644 --- a/app/game-api/src/tournament/types.ts +++ b/app/game-api/src/tournament/types.ts @@ -34,6 +34,13 @@ export interface TournamentParticipantEntry { gl?: number; seedRank?: number; finalRank?: number; + preliminaryGroupId?: number; + preliminaryGroupNo?: number; + preliminaryRank?: number; + preliminaryWin?: number; + preliminaryDraw?: number; + preliminaryLose?: number; + preliminaryGl?: number; } export interface TournamentMatchEntry { diff --git a/app/game-api/src/tournament/worker.ts b/app/game-api/src/tournament/worker.ts index 7fb6c2fd..5ee85766 100644 --- a/app/game-api/src/tournament/worker.ts +++ b/app/game-api/src/tournament/worker.ts @@ -186,6 +186,8 @@ export const applyPreBattleStage = async ( gl: 0, seedRank: 0, finalRank: 0, + preliminaryGroupId: entry.groupId, + preliminaryGroupNo: entry.groupNo, })); await store.setParticipants(grouped); const nextState: TournamentState = { @@ -244,10 +246,17 @@ export const applyPreBattleStage = async ( for (let groupId = 0; groupId < 8; groupId += 1) { const groupEntries = ranked.filter((entry) => entry.groupId === groupId); const ordered = sortByRanking(groupEntries); - ordered.slice(0, 4).forEach((entry, idx) => { + ordered.forEach((entry, idx) => { const target = ranked.find((item) => item.id === entry.id); if (target) { - target.seedRank = idx + 1; + target.preliminaryGroupId = groupId; + target.preliminaryGroupNo = entry.groupNo; + target.preliminaryRank = idx + 1; + target.preliminaryWin = entry.win ?? 0; + target.preliminaryDraw = entry.draw ?? 0; + target.preliminaryLose = entry.lose ?? 0; + target.preliminaryGl = entry.gl ?? 0; + target.seedRank = idx < 4 ? idx + 1 : 0; } }); } diff --git a/app/game-api/src/tournament/workerHelpers.ts b/app/game-api/src/tournament/workerHelpers.ts index a10400cc..7d71272a 100644 --- a/app/game-api/src/tournament/workerHelpers.ts +++ b/app/game-api/src/tournament/workerHelpers.ts @@ -195,17 +195,20 @@ export const assignManualApplicantGroup = (options: { extraSeed: `manual-group:${options.current.map((entry) => entry.id).join('-')}:${openGroupIds.join('-')}`, }); const groupId = rng.choice(openGroupIds); + const groupNo = groupCounts[groupId] ?? 0; return { ...options.applicant, groupId, - groupNo: groupCounts[groupId] ?? 0, + groupNo, win: 0, draw: 0, lose: 0, gl: 0, seedRank: 0, finalRank: 0, + preliminaryGroupId: groupId, + preliminaryGroupNo: groupNo, }; }; diff --git a/app/game-api/test/tournamentRouter.test.ts b/app/game-api/test/tournamentRouter.test.ts index 3b7a8148..b3bd1b4b 100644 --- a/app/game-api/test/tournamentRouter.test.ts +++ b/app/game-api/test/tournamentRouter.test.ts @@ -249,6 +249,7 @@ describe('tournament router permissions and mutations', () => { expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1); const summary = await caller.tournament.getBettingSummary(); expect(summary.myAmount).toBe(600); + expect(Object.values(summary.myTotals)).toEqual([600]); expect(summary.totalAmount).toBe(600); expect(transport.gold.get(general.id)).toBe(2_400); }); diff --git a/app/game-api/test/tournamentWorker.test.ts b/app/game-api/test/tournamentWorker.test.ts index a0ce15b5..1b779111 100644 --- a/app/game-api/test/tournamentWorker.test.ts +++ b/app/game-api/test/tournamentWorker.test.ts @@ -246,8 +246,9 @@ describe('tournament worker schedule compatibility', () => { groupNo, }; }); - const groupCounts = Array.from({ length: 8 }, (_, groupId) => - current.filter((entry) => entry.groupId === groupId).length + const groupCounts = Array.from( + { length: 8 }, + (_, groupId) => current.filter((entry) => entry.groupId === groupId).length ); const openGroupId = groupCounts.findIndex((count) => count === 7); expect(openGroupId).toBeGreaterThanOrEqual(0); @@ -267,7 +268,16 @@ describe('tournament worker schedule compatibility', () => { }, }); - expect(applicant).toMatchObject({ groupId: openGroupId, groupNo: 7, win: 0, draw: 0, lose: 0, gl: 0 }); + expect(applicant).toMatchObject({ + groupId: openGroupId, + groupNo: 7, + preliminaryGroupId: openGroupId, + preliminaryGroupNo: 7, + win: 0, + draw: 0, + lose: 0, + gl: 0, + }); }); it('catches up from the stored schedule instead of discarding elapsed legacy phases', () => { @@ -334,6 +344,7 @@ describe('tournament worker (in-memory)', () => { expect(entries.map((entry) => entry.groupNo).sort((a, b) => Number(a) - Number(b))).toEqual([ 0, 1, 2, 3, 4, 5, 6, 7, ]); + expect(entries.every((entry) => entry.preliminaryGroupId === groupId)).toBe(true); } }); @@ -648,14 +659,30 @@ describe('tournament worker (in-memory)', () => { expect(participants.find((entry) => entry.id === 1)).toMatchObject({ groupId: expect.any(Number) }); expect(participants.find((entry) => entry.id === 1001)).toMatchObject({ groupId: expect.any(Number) }); expect( - Array.from({ length: 8 }, (_, groupId) => - participants.filter((entry) => entry.groupId === groupId).length - ) + Array.from({ length: 8 }, (_, groupId) => participants.filter((entry) => entry.groupId === groupId).length) ).toEqual(Array.from({ length: 8 }, () => 8)); await store.setState(afterJoin); const finalState = await runTournamentToCompletion({ store, prisma, baseSeed: 'seed' }); + const finalParticipants = await store.getParticipants(); expect(finalState.stage).toBe(0); expect(finalState.winnerId).toBeDefined(); + for (let groupId = 0; groupId < 8; groupId += 1) { + const preliminaryEntries = finalParticipants + .filter((entry) => entry.preliminaryGroupId === groupId) + .sort((lhs, rhs) => (lhs.preliminaryRank ?? 99) - (rhs.preliminaryRank ?? 99)); + expect(preliminaryEntries).toHaveLength(8); + expect(preliminaryEntries.map((entry) => entry.preliminaryRank)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + expect( + preliminaryEntries.every( + (entry) => + entry.preliminaryGroupNo !== undefined && + entry.preliminaryWin !== undefined && + entry.preliminaryDraw !== undefined && + entry.preliminaryLose !== undefined && + entry.preliminaryGl !== undefined + ) + ).toBe(true); + } }); }); diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index b1b03475..9f5037fd 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -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 }); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 57383be0..afb2a0ad 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -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'); diff --git a/app/game-frontend/e2e/tournamentBracket.spec.ts b/app/game-frontend/e2e/tournamentBracket.spec.ts index 3a9e7452..aafff128 100644 --- a/app/game-frontend/e2e/tournamentBracket.spec.ts +++ b/app/game-frontend/e2e/tournamentBracket.spec.ts @@ -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(); diff --git a/app/game-frontend/src/assets/styles/legacy-controls.css b/app/game-frontend/src/assets/styles/legacy-controls.css index be50525f..6a2f54fb 100644 --- a/app/game-frontend/src/assets/styles/legacy-controls.css +++ b/app/game-frontend/src/assets/styles/legacy-controls.css @@ -153,3 +153,18 @@ border-color: var(--legacy-button-border); background: var(--legacy-button-bg); } + +/* + * Split controls share one outer silhouette. Keep the main control's right + * border as the subtle divider while removing every inner corner and the + * toggle's overlapping left border. These rules intentionally follow the + * Lumen family so its border shorthand cannot restore the inner rounding. + */ +.legacy-split-button > .main-menu-link { + border-radius: 5.25px 0 0 5.25px; +} + +.legacy-split-button > .legacy-split-button__toggle { + border-left-width: 0; + border-radius: 0 5.25px 5.25px 0; +} diff --git a/app/game-frontend/src/components/command/RecruitmentCommandForm.vue b/app/game-frontend/src/components/command/RecruitmentCommandForm.vue index fb5b3cc1..ed2f3ae1 100644 --- a/app/game-frontend/src/components/command/RecruitmentCommandForm.vue +++ b/app/game-frontend/src/components/command/RecruitmentCommandForm.vue @@ -69,7 +69,7 @@ const updateAmount = (event: Event) => { }; const submit = async (crewType?: RecruitmentCrewType) => { - if (crewType) selectCrewType(crewType); + if (crewType && crewType.id !== selectedCrewTypeId.value) selectCrewType(crewType); await nextTick(); if (valid.value) emit('submit'); }; diff --git a/app/game-frontend/src/components/main/MainGlobalMenu.vue b/app/game-frontend/src/components/main/MainGlobalMenu.vue index 419fe7e5..5a331a20 100644 --- a/app/game-frontend/src/components/main/MainGlobalMenu.vue +++ b/app/game-frontend/src/components/main/MainGlobalMenu.vue @@ -59,7 +59,7 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props -