From 75c5a933e09f5302975903e2faed46c38643aef6 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 21 Aug 2026 15:57:49 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=86=A0=EB=84=88=EB=A8=BC=ED=8A=B8=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=EC=97=90=EC=84=9C=20=EC=A7=81=EC=A0=91=20?= =?UTF-8?q?=EB=B2=A0=ED=8C=85=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../e2e/tournamentBracket.spec.ts | 97 +++++- .../tournament/TournamentBracket.vue | 68 +++- app/game-frontend/src/views/BettingView.vue | 307 +++++++++++------- 3 files changed, 333 insertions(+), 139 deletions(-) diff --git a/app/game-frontend/e2e/tournamentBracket.spec.ts b/app/game-frontend/e2e/tournamentBracket.spec.ts index 84782d31..4cab47b0 100644 --- a/app/game-frontend/e2e/tournamentBracket.spec.ts +++ b/app/game-frontend/e2e/tournamentBracket.spec.ts @@ -90,6 +90,20 @@ const matches = [ ]; const response = (data: unknown) => ({ result: { data } }); +const asRecord = (value: unknown): Record | null => + typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) : 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,12 @@ 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 } = {} +) => { 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); @@ -150,7 +168,7 @@ const installFixture = async (page: Page, options: { applicationOpen?: boolean; if (operation === 'tournament.getSnapshot') { return response({ state: { - stage: options.applicationOpen ? 1 : 0, + stage: options.tournamentStage ?? (options.applicationOpen ? 1 : 0), phase: 0, type: options.tournamentType ?? 0, auto: false, @@ -196,6 +214,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 +253,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) => { @@ -480,12 +505,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('.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 +571,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 +584,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('.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')); }); diff --git a/app/game-frontend/src/components/tournament/TournamentBracket.vue b/app/game-frontend/src/components/tournament/TournamentBracket.vue index 6146404b..3d494fe3 100644 --- a/app/game-frontend/src/components/tournament/TournamentBracket.vue +++ b/app/game-frontend/src/components/tournament/TournamentBracket.vue @@ -6,6 +6,7 @@ import { resolveTournamentCoreStat, type TournamentBracketMatch, type TournamentBracketParticipant, + type TournamentBracketSlot, } from '../../utils/tournamentBracket'; const props = defineProps<{ @@ -17,6 +18,11 @@ const props = defineProps<{ totalBet: number; tournamentType?: number; showLegend?: boolean; + bettingOpen?: boolean; +}>(); + +const emit = defineEmits<{ + requestBet: [slot: TournamentBracketSlot]; }>(); const bracket = computed(() => buildTournamentBracket(props.participants, props.matches, props.winnerId)); @@ -72,6 +78,10 @@ const odds = (id: number | null) => { const myBet = (id: number | null) => (id === null ? 0 : (props.myBetTotals?.[id] ?? 0)); const coreStat = (slot: (typeof bracket.value.top16.slots)[number]) => resolveTournamentCoreStat(slot, props.tournamentType ?? 0); +const requestBet = (slot: TournamentBracketSlot) => { + if (!props.bettingOpen || slot.id === null) return; + emit('requestBet', slot); +}; const mobilePairs = computed(() => { const column = roundColumns.value[activeMobileRound.value] ?? []; if (activeMobileRound.value === roundColumns.value.length - 1) return column.map((slot) => [slot]); @@ -114,7 +124,10 @@ const mobilePairs = computed(() => { v-for="(slot, slotIndex) in column" :key="`${columnIndex}-${slot.id ?? 'empty'}-${slotIndex}`" class="desktop-bracket-name" - :class="{ advanced: slot.advanced }" + :class="{ + advanced: slot.advanced, + 'betting-target': columnIndex === 0 && bettingOpen && slot.id !== null, + }" :data-general-id="slot.id ?? undefined" :style="{ left: `${(desktopX[columnIndex]! / 1200) * 100}%`, @@ -122,6 +135,15 @@ const mobilePairs = computed(() => { }" > +
{{ coreStat(slot)?.label }} {{ coreStat(slot)?.value }} @@ -154,10 +176,22 @@ const mobilePairs = computed(() => { v-for="(slot, slotIndex) in pair" :key="`${slot.id ?? 'empty'}-${slotIndex}`" class="mobile-bracket-name" - :class="{ advanced: slot.advanced }" + :class="{ + advanced: slot.advanced, + 'betting-target': activeMobileRound === 0 && bettingOpen && slot.id !== null, + }" :data-general-id="slot.id ?? undefined" > +
{{ coreStat(slot)?.label }} {{ coreStat(slot)?.value }} @@ -228,6 +262,36 @@ const mobilePairs = computed(() => { color: #fff; padding: 1px 3px; } +.betting-target { + position: absolute; +} +.mobile-bracket-name.betting-target { + position: relative; +} +.bracket-bet-button { + position: absolute; + z-index: 2; + top: 3px; + right: 3px; + min-width: 58px; + height: 24px; + margin: 0; + padding: 2px 5px; + border: 1px solid #9a7632; + border-radius: 3px; + color: #fff3cd; + background: #59400e; + font: 700 11px/1 var(--sammo-font-sans); + cursor: pointer; +} +.bracket-bet-button:hover, +.bracket-bet-button:focus { + filter: brightness(1.25); +} +.bracket-bet-button:focus-visible { + outline: 2px solid #f39c12; + outline-offset: 1px; +} .desktop-bracket-name.advanced, .mobile-bracket-name.advanced { border-color: #ff4b4b; diff --git a/app/game-frontend/src/views/BettingView.vue b/app/game-frontend/src/views/BettingView.vue index 96605bd4..0d9626ab 100644 --- a/app/game-frontend/src/views/BettingView.vue +++ b/app/game-frontend/src/views/BettingView.vue @@ -1,9 +1,10 @@ @@ -139,47 +155,57 @@ const placeBet = async (targetId: number) => { :total-bet="totalAmount" :tournament-type="snapshot?.state?.type ?? 0" :show-legend="false" + :betting-open="bettingOpen" + @request-bet="openBetDialog" /> -
-
-
- -
- {{ ratio(candidate.id) }} - - {{ amounts[candidate.id] ?? 10 }} - - {{ expected(candidate.id) }} -
-
- - -
-
-
-

- 배당률 × 베팅금 = - 적중시 환수금
- ( 베팅후 500원 이하일땐 베팅이 불가능합니다. ) -

-
+ +
+
+

베팅하기

+ +
+ + + + 배당 {{ selectedRatio }} + + 금{{ selectedAmount }} + + 예상 환수금 {{ selectedExpectedReturn.toLocaleString('ko-KR') }} + +

현재 배당 기준 예상값이며, 베팅 상황에 따라 최종 배당은 달라질 수 있습니다.

+ +
+ + +
+ +