diff --git a/app/game-frontend/e2e/tournamentBracket.spec.ts b/app/game-frontend/e2e/tournamentBracket.spec.ts index f39a9949..672607b2 100644 --- a/app/game-frontend/e2e/tournamentBracket.spec.ts +++ b/app/game-frontend/e2e/tournamentBracket.spec.ts @@ -980,12 +980,12 @@ test('betting bracket shows intelligence for debate tournament candidates', asyn expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); }); -for (const width of [1365, 1101, 800, 390, 320]) { +for (const width of [1365, 1101, 940, 800, 500, 390, 320]) { test(`inline individual betting needs exactly 16 submissions at ${width}px`, async ({ page }, testInfo) => { await page.setViewportSize({ width, height: 900 }); const { placedBets } = await installFixture(page, { tournamentStage: 6 }); await page.goto('betting'); - const bracket = page.locator(width > 1100 ? '.desktop-bracket' : '.mobile-bracket'); + const bracket = page.locator('.inline-betting-bracket .mobile-bracket'); const cards = bracket.locator('.betting-candidate'); await expect(cards).toHaveCount(16); await expect(page.locator('dialog')).toHaveCount(0); @@ -1022,20 +1022,12 @@ for (const width of [1365, 1101, 800, 390, 320]) { ); for (const card of geometry) { const iconRect = card.fields.find((field) => field.selector === '.general-identity-icon')!.rect; - expect(iconRect.width).toBe(width > 1100 ? 64 : 40); + expect(iconRect.width).toBe(64); expect(iconRect.height).toBe(iconRect.width); for (const field of card.fields) { - if ( - [ - '.general-identity-name', - '.bracket-core-stat', - '.bracket-odds', - '.bracket-my-bet', - '.bracket-return', - ].includes(field.selector) - ) { + if (['.general-identity-name', '.bracket-core-stat'].includes(field.selector)) { expect(field.rect.left).toBeGreaterThanOrEqual(iconRect.right + 5); - expect(field.fontSize).toBeGreaterThanOrEqual(width > 1100 ? 14 : 13); + expect(field.fontSize).toBe(field.selector === '.general-identity-name' ? 16 : 14); } expect(field.rect.left).toBeGreaterThanOrEqual(card.rect.left - 1); expect(field.rect.right).toBeLessThanOrEqual(card.rect.right + 1); @@ -1056,18 +1048,13 @@ for (const width of [1365, 1101, 800, 390, 320]) { } } } - if (width > 1100) { - const containment = await bracket.evaluate((element) => { - const bounds = element.getBoundingClientRect(); - return [...element.querySelectorAll('.desktop-bracket-name')].every((card) => { - const rect = card.getBoundingClientRect(); - return rect.left >= bounds.left - 1 && rect.right <= bounds.right + 1; - }); - }); - expect(containment).toBe(true); - expect(geometry[0].rect.width).toBeGreaterThan(300); - for (let index = 1; index < geometry.length; index++) - expect(geometry[index].rect.top).toBeGreaterThanOrEqual(geometry[index - 1].rect.bottom); + const rows = new Set(geometry.map((card) => card.rect.top)); + expect(rows.size).toBe(width >= 940 ? 4 : 8); + for (const card of geometry) { + const controls = card.fields.filter((field) => + ['input', 'select', '.bracket-bet-button'].includes(field.selector) + ); + expect(new Set(controls.map((field) => field.rect.top)).size).toBe(1); } expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width); await writeFile(testInfo.outputPath('inline-geometry.json'), JSON.stringify(geometry, null, 2)); @@ -1116,13 +1103,15 @@ for (const width of [1365, 390]) { const betFailure = { message: null as string | null }; const { placedBets } = await installFixture(page, { tournamentStage: 6, betFailure, betDelayMs: 200 }); await page.goto('betting'); - const bracket = page.locator(width > 1100 ? '.desktop-bracket' : '.mobile-bracket'); + const bracket = page.locator('.inline-betting-bracket .mobile-bracket'); const first = bracket.locator('.betting-candidate').first(); const second = bracket.locator('.betting-candidate').nth(1); const input = first.getByRole('spinbutton'); await expect(input).toHaveValue('10'); await expect(page.locator('input[type=number]:visible')).toHaveCount(16); await expect(page.getByRole('region', { name: '베팅 한도' }).getByRole('spinbutton')).toHaveCount(0); + await first.getByRole('combobox').click(); + await page.keyboard.press('Escape'); await first.getByRole('combobox').selectOption('50'); await expect(input).toHaveValue('50'); await expect(second.getByRole('spinbutton')).toHaveValue('10'); @@ -1164,9 +1153,27 @@ for (const width of [1365, 390]) { }); } -test('closed betting keeps investment and return visible without submit controls', async ({ page }) => { +test('closed betting keeps investment and return visible without submit controls', async ({ page }, testInfo) => { await installFixture(page, { tournamentStage: 7 }); await page.goto('betting'); + await page.waitForLoadState('networkidle'); + await page.evaluate(() => document.fonts.ready); await expect(page.locator('.inline-bet')).toHaveCount(0); - await expect(page.locator('.desktop-bracket .bracket-return').first()).toHaveText('예상 환수 금3,360'); + await expect(page.locator('.mobile-bracket .bracket-return').first()).toHaveText('예상 환수 금3,360'); + for (const width of [1365, 500, 390]) { + await page.setViewportSize({ width, height: 900 }); + const height = await page + .locator('.betting-candidate') + .first() + .evaluate((el) => el.getBoundingClientRect().height); + expect(height).toBeLessThan(width >= 940 ? 180 : 260); + await writeFile( + testInfo.outputPath(`closed-${width}-geometry.json`), + JSON.stringify({ width, cardHeight: height }) + ); + await page.screenshot({ path: testInfo.outputPath(`closed-${width}.png`), fullPage: true }); + await page.getByRole('tab', { name: '8강', exact: true }).click(); + await expect(page.getByRole('tabpanel', { name: '8강 대진' })).toBeVisible(); + await page.getByRole('tab', { name: '16강', exact: true }).click(); + } }); diff --git a/app/game-frontend/src/components/tournament/TournamentBracket.vue b/app/game-frontend/src/components/tournament/TournamentBracket.vue index 1751234b..a55296c7 100644 --- a/app/game-frontend/src/components/tournament/TournamentBracket.vue +++ b/app/game-frontend/src/components/tournament/TournamentBracket.vue @@ -46,18 +46,18 @@ const roundColumns = computed(() => [ bracket.value.final.slots, [bracket.value.champion], ]); -const desktopX = computed(() => (props.bettingMode ? [180, 480, 690, 895, 1100] : [110, 355, 600, 845, 1090])); +const desktopX = [110, 355, 600, 845, 1090]; const cardWidth = 190; -const desktopSlotHeight = computed(() => (props.bettingMode ? 184 : 88)); -const desktopCanvasHeight = computed(() => desktopSlotHeight.value * 16); +const desktopSlotHeight = 88; +const desktopCanvasHeight = computed(() => desktopSlotHeight * 16); const slotY = (columnIndex: number, slotIndex: number) => { - const slotHeight = desktopSlotHeight.value * 2 ** columnIndex; + const slotHeight = desktopSlotHeight * 2 ** columnIndex; return slotHeight / 2 + slotIndex * slotHeight; }; const connections = computed(() => roundColumns.value.slice(0, -1).flatMap((column, columnIndex) => { - const sourceX = desktopX.value[columnIndex]! + (props.bettingMode && columnIndex === 0 ? 170 : cardWidth / 2); - const targetX = desktopX.value[columnIndex + 1]! - cardWidth / 2; + const sourceX = desktopX[columnIndex]! + cardWidth / 2; + const targetX = desktopX[columnIndex + 1]! - cardWidth / 2; const jointX = (sourceX + targetX) / 2; return Array.from({ length: column.length / 2 }, (_, pairIndex) => { const upper = column[pairIndex * 2]!; @@ -114,7 +114,7 @@ const mobilePairs = computed(() => { aria-label="토너먼트 대진표" tabindex="0" > -
+
@@ -150,7 +150,6 @@ const mobilePairs = computed(() => { :class="{ advanced: slot.advanced, 'betting-target': columnIndex === 0 && bettingOpen && slot.id !== null, - 'betting-candidate': columnIndex === 0 && bettingMode, }" :data-general-id="slot.id ?? undefined" :style="{ @@ -159,29 +158,11 @@ const mobilePairs = computed(() => { }" > - - + /> { :npc-state="slot.npcState" > +
+ 현재 베팅 금{{ (betTotals?.[slot.id ?? 0] ?? 0).toLocaleString('ko-KR') }} + 배당 {{ odds(slot.id) }} + 내 투자 금{{ myBet(slot.id).toLocaleString('ko-KR') }} + 예상 환수 금{{ (myExpectedReturn(slot.id) ?? 0).toLocaleString('ko-KR') }} +
{ color: #fff; padding: 1px 3px; } -.desktop-bracket-name.betting-candidate { - width: 28.3333%; - min-height: 176px; - align-content: center; - gap: 5px; - padding: 5px 6px; -} .betting-target { position: absolute; } @@ -376,106 +349,6 @@ const mobilePairs = computed(() => { width: 100%; justify-content: flex-start; } -.bracket-candidate-identity { - --sammo-general-icon-size: 64px; - display: grid; - grid-template-columns: 64px minmax(0, 1fr); - min-width: 0; - gap: 8px; - align-items: start; - text-align: left; -} -.bracket-candidate-identity :deep(.general-identity-copy) { - gap: 3px; -} -.bracket-candidate-identity :deep(.general-identity-name) { - overflow: visible; - text-overflow: clip; - white-space: normal; - overflow-wrap: anywhere; - font-size: 16px; - line-height: 20px; - font-weight: 700; -} -.bracket-bet-summary { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - min-width: 0; - align-items: center; - gap: 2px 7px; - white-space: normal; - overflow-wrap: anywhere; -} -.bracket-core-stat, -.bracket-odds, -.bracket-my-bet { - display: block; - min-width: 0; - color: skyblue; - font-size: 14px; - line-height: 18px; -} -.bracket-core-stat { - color: #fff; - text-align: left; -} -.bracket-odds { - text-align: right; -} -.bracket-my-bet { - grid-column: 1 / -1; - text-align: left; - color: orange; - font-variant-numeric: tabular-nums; -} -.bracket-return { - grid-column: 1 / -1; - text-align: left; - color: cyan; - font-size: 14px; - line-height: 18px; - white-space: normal; - overflow-wrap: anywhere; -} -.inline-betting-bracket .desktop-round-labels { - grid-template-columns: 30% 20% 20% 16.6667% 13.3333%; -} -.inline-betting-bracket .mobile-bracket-name.betting-candidate { - display: flex; - flex-direction: column; - gap: 5px; -} -.inline-betting-bracket .mobile-bracket-name.betting-candidate > :last-child { - margin-top: auto; -} -@media (max-width: 1100px) { - .bracket-candidate-identity { - --sammo-general-icon-size: 40px; - grid-template-columns: 40px minmax(0, 1fr); - gap: 6px; - } - .bracket-candidate-identity :deep(.general-identity-name) { - font-size: 14px; - line-height: 18px; - } - .bracket-bet-summary { - grid-template-columns: minmax(0, 1fr); - } - .bracket-core-stat, - .bracket-odds, - .bracket-my-bet, - .bracket-return { - font-size: 13px; - line-height: 18px; - text-align: left; - } - .inline-betting-bracket .desktop-bracket { - display: none; - } - .inline-betting-bracket .mobile-bracket { - display: block; - } -} .mobile-bracket { display: none; } @@ -531,7 +404,7 @@ const mobilePairs = computed(() => { left: 50%; transform: translate(-50%, -50%); color: #ffd25e; - font-size: 11px; + font-size: 12px; } .tournament-bracket > p { margin: 8px 0 0; @@ -550,4 +423,77 @@ const mobilePairs = computed(() => { font-size: 13px; } } + +/* Betting uses the same paired cards at every viewport; rounds remain selectable. */ +.inline-betting-bracket { + font-size: var(--bet-font-normal, 14px); +} +.inline-betting-bracket .mobile-bracket { + display: block; +} +.inline-betting-bracket .mobile-round-list { + padding: 6px; +} +.inline-betting-bracket .mobile-round-list > article { + gap: 18px; + align-items: stretch; +} +.inline-betting-bracket .mobile-bracket-name { + overflow: visible; +} +.inline-betting-bracket .mobile-bracket-name.betting-candidate { + position: relative; + display: flex; + flex-direction: column; + min-height: 0; + gap: 6px; + padding: 6px; +} +.bracket-candidate-identity { + display: grid; + grid-template-columns: var(--sammo-general-icon-size) minmax(0, 1fr); + gap: 6px; + min-width: 0; + text-align: left; +} +.bracket-candidate-identity :deep(.general-identity-name) { + font-size: var(--bet-font-large, 16px); + font-weight: 700; + white-space: normal; + overflow-wrap: anywhere; +} +.bracket-core-stat { + color: #fff; + font-size: var(--bet-font-normal, 14px); +} +.bracket-bet-summary { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 4px 6px; + text-align: left; + overflow-wrap: anywhere; + font-variant-numeric: tabular-nums; +} +.bracket-total, +.bracket-odds { + color: skyblue; +} +.bracket-my-bet { + color: orange; +} +.bracket-return { + color: cyan; +} +.inline-betting-bracket .betting-candidate > :last-child { + margin-top: auto; +} +.inline-betting-bracket .mobile-round-tabs button { + font-size: inherit; +} +@media (min-width: 940px) { + .inline-betting-bracket .mobile-round-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 18px; + } +} diff --git a/app/game-frontend/src/views/BettingView.vue b/app/game-frontend/src/views/BettingView.vue index d640a1df..5d8ee651 100644 --- a/app/game-frontend/src/views/BettingView.vue +++ b/app/game-frontend/src/views/BettingView.vue @@ -143,22 +143,25 @@ const placeBet = async (target: TournamentBracketSlot) => { : Number(($event.target as HTMLInputElement).value) " /> - - {{ amount }}금 - - + + + +