베팅장 장수칸에서 금액 입력과 개별 즉시 베팅 지원

This commit is contained in:
2026-09-12 01:20:35 +00:00
parent a31c2711c7
commit 5041fc365e
5 changed files with 445 additions and 463 deletions
+212 -219
View File
@@ -198,6 +198,8 @@ const installFixture = async (
joinedGroupId?: number;
emptyFinalGroups?: boolean;
realtimeState?: { tournamentStage: number; totalAmount: number };
betFailure?: { message: string | null };
betDelayMs?: number;
onOperation?: (operation: string, headers: Record<string, string>) => void;
} = {}
) => {
@@ -216,6 +218,9 @@ const installFixture = async (
await route.fulfill({ status: 200, contentType: 'image/jpeg', body: await readReferenceIcon('default.jpg') });
});
await page.route(gameTrpcRoute, async (route) => {
if (operationNames(route).includes('tournament.placeBet') && options.betDelayMs) {
await new Promise((resolve) => setTimeout(resolve, options.betDelayMs));
}
const results = operationNames(route).map((operation) => {
options.onOperation?.(operation, route.request().headers());
if (operation === 'auth.status') return response({ ok: true });
@@ -277,16 +282,45 @@ const installFixture = async (
if (operation === 'tournament.getBettingSummary') {
return response({
totals: Object.fromEntries(
participants.slice(0, 16).map((participant, index) => [participant.id, 100 + index * 10])
participants
.slice(0, 16)
.map((participant, index) => [
participant.id,
100 +
index * 10 +
placedBets
.filter((bet) => bet.targetId === participant.id)
.reduce((sum, bet) => sum + bet.amount, 0),
])
),
myTotals: { 1: 120, 2: 40 },
totalAmount: options.realtimeState?.totalAmount ?? 2800,
myAmount: 160,
myTotals: Object.fromEntries(
participants
.slice(0, 16)
.map((participant) => [
participant.id,
(participant.id === 1 ? 120 : participant.id === 2 ? 40 : 0) +
placedBets
.filter((bet) => bet.targetId === participant.id)
.reduce((sum, bet) => sum + bet.amount, 0),
])
),
totalAmount:
(options.realtimeState?.totalAmount ?? 2800) +
placedBets.reduce((sum, bet) => sum + bet.amount, 0),
myAmount: 160 + placedBets.reduce((sum, bet) => sum + bet.amount, 0),
});
}
if (operation === 'tournament.placeBet') {
const input = findBetInput(route.request().postDataJSON());
if (!input) throw new Error('베팅 요청에서 targetId와 amount를 찾을 수 없습니다.');
if (options.betFailure?.message)
return {
error: {
message: options.betFailure.message,
code: -32600,
data: { code: 'BAD_REQUEST', httpStatus: 400, path: operation },
},
};
placedBets.push(input);
return response({ ok: true });
}
@@ -809,8 +843,7 @@ test('betting realtime refresh is shared across tabs and preserves local interac
expect(follower.getByRole('tab', { name: '전력전' })).toBeVisible(),
]);
await follower.getByRole('tab', { name: '통솔전' }).click();
await follower.getByRole('button', { name: '관우에게 베팅하기' }).click();
await follower.getByRole('dialog', { name: '베팅하기' }).getByLabel('베팅 금액').selectOption('50');
await follower.locator('.mobile-bracket').getByLabel('관우 베팅 금액', { exact: true }).fill('50');
await expect
.poll(async () => {
@@ -873,8 +906,7 @@ test('betting realtime refresh is shared across tabs and preserves local interac
await expect(page.locator('.section-title small')).toContainText('전체 금액 : 3333');
await expect(follower.locator('.section-title small')).toContainText('전체 금액 : 3333');
await expect(follower.getByRole('tab', { name: '통솔전' })).toHaveAttribute('aria-selected', 'true');
await expect(follower.getByRole('dialog', { name: '베팅하기' })).toBeVisible();
await expect(follower.getByRole('dialog', { name: '베팅하기' }).getByLabel('베팅 금액')).toHaveValue('50');
await expect(follower.locator('.mobile-bracket').getByLabel('관우 베팅 금액', { exact: true })).toHaveValue('50');
expect(operations.filter(({ operation }) => operation === 'tournament.getSnapshot')).toHaveLength(before.snapshot);
expect(operations.filter(({ operation }) => operation === 'tournament.getRankings')).toHaveLength(before.rankings);
expect(
@@ -910,8 +942,7 @@ test('betting realtime refresh is shared across tabs and preserves local interac
expect(operations.filter(({ operation }) => operation === 'tournament.getRankings')).toHaveLength(
recoveryBefore.rankings + 1
);
await expect(follower.getByRole('dialog', { name: '베팅하기' })).toBeVisible();
await expect(follower.getByRole('dialog', { name: '베팅하기' }).getByLabel('베팅 금액')).toHaveValue('50');
await expect(follower.locator('.mobile-bracket').getByLabel('관우 베팅 금액', { exact: true })).toHaveValue('50');
});
test('tournament and betting close only their script-opened popup window', async ({ page }, testInfo) => {
@@ -938,156 +969,6 @@ test('tournament and betting close only their script-opened popup window', async
}
});
test('mobile betting cards prioritize readable values and retain the amount draft', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
const { placedBets } = await installFixture(page, { tournamentStage: 6 });
await page.goto('betting');
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: '관우에게 베팅하기' });
await expect(firstCard.locator('.general-identity-icon:visible')).toHaveCount(0);
const layout = await firstCard.evaluate((card) => {
const own = card.getBoundingClientRect();
const button = card.querySelector<HTMLElement>('.bracket-bet-button')!.getBoundingClientRect();
const fields = [
'.general-identity-name',
'.bracket-core-stat',
'.bracket-odds',
'.bracket-my-bet',
'.bracket-bet-button',
].map((selector) => card.querySelector<HTMLElement>(selector)!.getBoundingClientRect());
const overlaps = fields.flatMap((rect, index) =>
fields
.slice(index + 1)
.map(
(other) =>
Math.min(rect.right, other.right) > Math.max(rect.left, other.left) &&
Math.min(rect.bottom, other.bottom) > Math.max(rect.top, other.top)
)
);
return {
topOffset: button.top - own.top,
rightOffset: own.right - button.right,
contained: button.top >= own.top && button.right <= own.right && button.bottom <= own.bottom,
allFieldsContained: fields.every(
(rect) =>
rect.left >= own.left && rect.right <= own.right && rect.top >= own.top && rect.bottom <= own.bottom
),
overlaps,
cardHeight: own.height,
};
});
expect(layout.topOffset).toBeGreaterThanOrEqual(2);
expect(layout.topOffset).toBeLessThanOrEqual(6);
expect(layout.rightOffset).toBeGreaterThanOrEqual(2);
expect(layout.rightOffset).toBeLessThanOrEqual(6);
expect(layout.contained).toBe(true);
expect(layout.allFieldsContained).toBe(true);
expect(layout.overlaps.every((overlap) => !overlap)).toBe(true);
expect(layout.cardHeight).toBeGreaterThanOrEqual(92);
const longNameMetrics = await page
.locator('[data-rich-tooltip="mobile-candidate-icon-16"] .general-identity-name')
.evaluate((element) => ({
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
whiteSpace: getComputedStyle(element).whiteSpace,
overflow: getComputedStyle(element).overflow,
textOverflow: getComputedStyle(element).textOverflow,
}));
expect(longNameMetrics.scrollWidth).toBeLessThanOrEqual(longNameMetrics.clientWidth);
expect(longNameMetrics).toMatchObject({ whiteSpace: 'normal', overflow: 'visible', textOverflow: 'clip' });
const iconTrigger = firstCard.locator('[data-rich-tooltip="mobile-candidate-icon-1"]');
await iconTrigger.hover();
const iconTooltip = page.locator('.tippy-box[data-state="visible"]');
await expect(iconTooltip).toContainText('관우');
await expect(iconTooltip.locator('.general-identity-icon')).toBeVisible();
await page.keyboard.press('Escape');
const returnTrigger = firstCard.locator('[data-rich-tooltip="mobile-candidate-return-1"]');
await returnTrigger.hover();
await expect(page.locator('.tippy-box[data-state="visible"]')).toContainText(
'현재 배당 28.00 × 내 투자 금120 = 금3,360'
);
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();
const dialogGeometry = await dialog.evaluate((element) => ({
dialog: element.getBoundingClientRect().toJSON(),
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
documentScrollWidth: document.documentElement.scrollWidth,
position: getComputedStyle(element).position,
}));
expect(
Math.abs(dialogGeometry.dialog.left + dialogGeometry.dialog.width / 2 - dialogGeometry.viewportWidth / 2)
).toBeLessThanOrEqual(1);
expect(
Math.abs(dialogGeometry.dialog.top + dialogGeometry.dialog.height / 2 - dialogGeometry.viewportHeight / 2)
).toBeLessThanOrEqual(1);
expect(dialogGeometry.dialog.left).toBeGreaterThanOrEqual(0);
expect(dialogGeometry.dialog.right).toBeLessThanOrEqual(dialogGeometry.viewportWidth);
expect(dialogGeometry.dialog.top).toBeGreaterThanOrEqual(0);
expect(dialogGeometry.dialog.bottom).toBeLessThanOrEqual(dialogGeometry.viewportHeight);
expect(dialogGeometry.documentScrollWidth).toBeLessThanOrEqual(dialogGeometry.viewportWidth);
expect(dialogGeometry.position).toBe('fixed');
await writeFile(testInfo.outputPath('mobile-betting-dialog.json'), `${JSON.stringify(dialogGeometry, null, 2)}\n`);
await dialog.screenshot({ path: testInfo.outputPath('mobile-betting-dialog.png') });
await page.screenshot({ path: testInfo.outputPath('mobile-betting-dialog-viewport.png') });
await expect(dialog.getByText('배당 28.00')).toBeVisible();
await expect(dialog.getByText('예상 환수금 280')).toBeVisible();
await dialog.getByLabel('베팅 금액').selectOption('100');
await expect(dialog.getByText('예상 환수금 2,800')).toBeVisible();
await persistScreenshot(
page,
'tournament-betting-dialog-mobile',
testInfo.outputPath('betting-dialog-mobile.webp')
);
await dialog.getByRole('button', { name: '취소' }).click();
await page.getByRole('button', { name: '장료에게 베팅하기' }).click();
await expect(dialog.getByLabel('베팅 금액')).toHaveValue('100');
await dialog.getByRole('button', { name: '베팅 등록' }).click();
await expect(dialog).not.toBeVisible();
await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="success"]')).toContainText(
'베팅이 등록되었습니다.'
);
expect(placedBets).toEqual([{ targetId: 2, amount: 100 }]);
await page.getByRole('button', { name: '조운에게 베팅하기' }).click();
await expect(dialog.getByLabel('베팅 금액')).toHaveValue('100');
await dialog.getByRole('button', { name: '취소' }).click();
await expect(page.getByRole('tablist', { name: '토너먼트 랭킹 종목 선택' })).toBeVisible();
await expect(page.locator('.ranking-table:visible')).toHaveCount(1);
await page.getByRole('tab', { name: '통솔전' }).click();
await expect(page.getByRole('tab', { name: '통솔전' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.ranking-table:visible thead')).toContainText('통 솔 전');
const identity = await page
.locator('.ranking-table:visible .general-identity')
.first()
.evaluate((element) => {
const icon = element.querySelector('img')!.getBoundingClientRect();
const name = element.querySelector<HTMLElement>('.general-identity-name')!.getBoundingClientRect();
return { iconWidth: icon.width, iconHeight: icon.height, iconRight: icon.right, nameLeft: name.left };
});
expect(identity.iconWidth).toBe(64);
expect(identity.iconHeight).toBe(64);
expect(identity.nameLeft).toBeGreaterThanOrEqual(identity.iconRight - 1);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
await persistScreenshot(page, 'tournament-ranking-mobile', testInfo.outputPath('tournament-ranking-mobile.webp'));
});
test('betting bracket shows intelligence for debate tournament candidates', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await installFixture(page, { tournamentType: 3, tournamentStage: 6 });
@@ -1099,64 +980,176 @@ test('betting bracket shows intelligence for debate tournament candidates', asyn
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
});
test('desktop betting widens icon-free candidate cards while rankings keep dedicated icons', async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 1365, height: 900 });
await installFixture(page, { tournamentStage: 6 });
await page.goto('betting');
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);
const firstCandidate = page.locator('.desktop-bracket-name.betting-candidate[data-general-id="1"]');
await expect(firstCandidate.locator('.general-identity-icon:visible')).toHaveCount(0);
await expect(page.locator('.ranking-table:visible .general-identity-icon').first()).toHaveCSS('width', '64px');
await expect(page.locator('.ranking-table:visible .general-identity-icon').first()).toHaveCSS('height', '64px');
const candidateGeometry = await firstCandidate.evaluate((card) => {
const rect = card.getBoundingClientRect();
return { width: rect.width, height: rect.height };
for (const width of [1365, 1101, 800, 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 cards = bracket.locator('.betting-candidate');
await expect(cards).toHaveCount(16);
await expect(page.locator('dialog')).toHaveCount(0);
await page.evaluate(() => document.fonts.ready);
const geometry = await cards.evaluateAll((elements) =>
elements.map((element) => {
const rect = element.getBoundingClientRect();
const selectors = [
'.general-identity-name',
'.bracket-core-stat',
'.bracket-odds',
'.bracket-my-bet',
'.bracket-return',
'input',
'select',
'.bracket-bet-button',
];
const fields = selectors.map((selector) => {
const el = element.querySelector<HTMLElement>(selector)!;
const style = getComputedStyle(el);
return {
selector,
rect: el.getBoundingClientRect().toJSON(),
font: style.font,
color: style.color,
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth,
};
});
return { rect: rect.toJSON(), fields };
})
);
for (const card of geometry) {
for (const field of card.fields) {
expect(field.rect.left).toBeGreaterThanOrEqual(card.rect.left - 1);
expect(field.rect.right).toBeLessThanOrEqual(card.rect.right + 1);
expect(field.rect.top).toBeGreaterThanOrEqual(card.rect.top - 1);
expect(field.rect.bottom).toBeLessThanOrEqual(card.rect.bottom + 1);
expect(field.scrollWidth).toBeLessThanOrEqual(field.clientWidth + 1);
if (['input', 'select', '.bracket-bet-button'].includes(field.selector))
expect(field.rect.height).toBeGreaterThanOrEqual(44);
}
for (let index = 0; index < card.fields.length; index++) {
const a = card.fields[index].rect;
for (const field of card.fields.slice(index + 1)) {
const b = field.rect;
expect(
Math.min(a.right, b.right) > Math.max(a.left, b.left) + 1 &&
Math.min(a.bottom, b.bottom) > Math.max(a.top, b.top) + 1
).toBe(false);
}
}
}
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);
}
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
await writeFile(testInfo.outputPath('inline-geometry.json'), JSON.stringify(geometry, null, 2));
await writeFile(
testInfo.outputPath('inline-dom.html'),
await page.locator('#tournament-betting-container').evaluate((el) => el.outerHTML)
);
await page.screenshot({ path: testInfo.outputPath('inline-before.png'), fullPage: true });
await page.screenshot({ path: testInfo.outputPath('inline-viewport.png') });
const iconTrigger = cards.first().locator('[data-rich-tooltip]');
await iconTrigger.hover();
const tooltipImage = page.locator('.tippy-box[data-state="visible"] img');
await expect(tooltipImage).toBeVisible();
const icon = await tooltipImage.evaluate((image: HTMLImageElement) => ({
rect: image.getBoundingClientRect().toJSON(),
naturalWidth: image.naturalWidth,
naturalHeight: image.naturalHeight,
objectFit: getComputedStyle(image).objectFit,
}));
expect(icon.naturalWidth).toBeGreaterThan(0);
expect(icon.naturalHeight).toBeGreaterThan(0);
await writeFile(testInfo.outputPath('inline-icon.json'), JSON.stringify(icon, null, 2));
await page.keyboard.press('Escape');
const buttons = bracket.locator('.bracket-bet-button');
const firstButton = buttons.first();
await firstButton.hover();
await expect(firstButton).toHaveCSS('filter', 'brightness(1.25)');
await firstButton.focus();
await expect(firstButton).toBeFocused();
// One click per candidate; no target-selection, dialog, confirmation or batch action.
for (let index = 0; index < 16; index++) {
await buttons.nth(index).click();
await expect.poll(() => placedBets.length).toBe(index + 1);
await expect(cards.nth(index).getByRole('status')).toHaveText('10금 베팅 완료');
}
expect(placedBets).toEqual(geometry.map((_, index) => ({ targetId: index + 1, amount: 10 })));
await expect(cards.first().locator('.bracket-my-bet')).toHaveText('내 투자 금130');
await expect(cards.first().locator('.bracket-odds')).toHaveText('배당 26.91');
await expect(cards.first().locator('.bracket-return')).toHaveText('예상 환수 금3,498');
await expect(page.getByText('남은 한도 680금')).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('inline-after.png'), fullPage: true });
});
expect(candidateGeometry.width).toBeGreaterThanOrEqual(210);
expect(candidateGeometry.height).toBeGreaterThanOrEqual(92);
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);
const firstBetButton = page.getByRole('button', { name: '관우에게 베팅하기' });
await firstBetButton.click();
const dialog = page.getByRole('dialog', { name: '베팅하기' });
await expect(dialog).toBeVisible();
const dialogGeometry = await dialog.evaluate((element) => ({
dialog: element.getBoundingClientRect().toJSON(),
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
position: getComputedStyle(element).position,
}));
expect(
Math.abs(dialogGeometry.dialog.left + dialogGeometry.dialog.width / 2 - dialogGeometry.viewportWidth / 2)
).toBeLessThanOrEqual(1);
expect(
Math.abs(dialogGeometry.dialog.top + dialogGeometry.dialog.height / 2 - dialogGeometry.viewportHeight / 2)
).toBeLessThanOrEqual(1);
expect(dialogGeometry.position).toBe('fixed');
await writeFile(testInfo.outputPath('desktop-betting-dialog.json'), `${JSON.stringify(dialogGeometry, null, 2)}\n`);
await dialog.screenshot({ path: testInfo.outputPath('desktop-betting-dialog.png') });
await page.screenshot({ path: testInfo.outputPath('desktop-betting-dialog-viewport.png') });
await dialog.getByRole('button', { name: '취소' }).click();
await expect(dialog).toBeHidden();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(1365);
await persistScreenshot(page, 'tournament-ranking-desktop', testInfo.outputPath('tournament-ranking-desktop.webp'));
}
for (const width of [1365, 390]) {
test(`inline amount presets, defaults, errors and repeated bets at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 900 });
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 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 page.getByLabel('기본 지정 금액').selectOption('20');
await expect(input).toHaveValue('20');
await first.getByRole('combobox').selectOption('50');
await expect(input).toHaveValue('50');
await expect(second.getByRole('spinbutton')).toHaveValue('20');
await input.fill('37');
await page.getByRole('button', { name: '갱신', exact: true }).click();
await expect(input).toHaveValue('37');
for (const invalid of ['', '9', '10.5', '841']) {
await input.fill(invalid);
await expect(first.getByRole('button', { name: '관우에게 베팅하기' })).toBeDisabled();
}
expect(placedBets).toHaveLength(0);
await input.fill('37');
betFailure.message = '금이 부족합니다.';
await first.getByRole('button').click();
await expect(first.getByRole('status')).toHaveText('금이 부족합니다.');
await expect(input).toHaveValue('37');
expect(placedBets).toHaveLength(0);
await page.screenshot({ path: testInfo.outputPath('inline-error.png') });
betFailure.message = null;
await first.getByRole('button').click();
await expect(first.getByRole('button')).toBeDisabled();
await first.getByRole('button').dispatchEvent('click');
await expect(second.getByRole('button')).toBeEnabled();
await expect(first.getByRole('status')).toHaveText('37금 베팅 완료');
await expect(first.getByRole('button')).toBeEnabled();
await first.getByRole('button').click();
await expect.poll(() => placedBets.length).toBe(2);
expect(placedBets).toEqual([
{ targetId: 1, amount: 37 },
{ targetId: 1, amount: 37 },
]);
await expect(first.locator('.bracket-my-bet')).toHaveText('내 투자 금194');
await second.getByRole('spinbutton').fill('766');
await second.getByRole('button').click();
await expect(page.getByText('남은 한도 0금')).toBeVisible();
await expect(bracket.locator('.bracket-bet-button:enabled')).toHaveCount(0);
});
}
test('closed betting keeps investment and return visible without submit controls', async ({ page }) => {
await installFixture(page, { tournamentStage: 7 });
await page.goto('betting');
await expect(page.locator('.inline-bet')).toHaveCount(0);
await expect(page.locator('.desktop-bracket .bracket-return').first()).toHaveText('예상 환수 금3,360');
});
@@ -47,9 +47,9 @@ const roundColumns = computed(() => [
bracket.value.final.slots,
[bracket.value.champion],
]);
const desktopX = [110, 355, 600, 845, 1090];
const desktopX = computed(() => (props.bettingMode ? [180, 480, 690, 895, 1100] : [110, 355, 600, 845, 1090]));
const cardWidth = 190;
const desktopSlotHeight = computed(() => (props.bettingMode ? 100 : 88));
const desktopSlotHeight = computed(() => (props.bettingMode ? 154 : 88));
const desktopCanvasHeight = computed(() => desktopSlotHeight.value * 16);
const slotY = (columnIndex: number, slotIndex: number) => {
const slotHeight = desktopSlotHeight.value * 2 ** columnIndex;
@@ -57,8 +57,8 @@ const slotY = (columnIndex: number, slotIndex: number) => {
};
const connections = computed(() =>
roundColumns.value.slice(0, -1).flatMap((column, columnIndex) => {
const sourceX = desktopX[columnIndex]! + cardWidth / 2;
const targetX = desktopX[columnIndex + 1]! - cardWidth / 2;
const sourceX = desktopX.value[columnIndex]! + (props.bettingMode && columnIndex === 0 ? 170 : cardWidth / 2);
const targetX = desktopX.value[columnIndex + 1]! - cardWidth / 2;
const jointX = (sourceX + targetX) / 2;
return Array.from({ length: column.length / 2 }, (_, pairIndex) => {
const upper = column[pairIndex * 2]!;
@@ -95,11 +95,6 @@ const myExpectedReturn = (id: number | null): number | null => {
if (invested <= 0 || targetTotal <= 0) return null;
return Math.round((invested * props.totalBet) / targetTotal);
};
const myExpectedReturnDescription = (id: number | null): string | null => {
const expectedReturn = myExpectedReturn(id);
if (id === null || expectedReturn === null) return null;
return `현재 배당 ${odds(id)} × 내 투자 금${myBet(id).toLocaleString('ko-KR')} = 금${expectedReturn.toLocaleString('ko-KR')}`;
};
const coreStat = (slot: (typeof bracket.value.top16.slots)[number]) =>
resolveTournamentCoreStat(slot, props.tournamentType ?? 0);
const requestBet = (slot: TournamentBracketSlot) => {
@@ -114,7 +109,12 @@ const mobilePairs = computed(() => {
</script>
<template>
<section class="tournament-bracket" aria-label="토너먼트 대진표" tabindex="0">
<section
class="tournament-bracket"
:class="{ 'inline-betting-bracket': bettingMode }"
aria-label="토너먼트 대진표"
tabindex="0"
>
<div class="desktop-bracket">
<div class="desktop-round-labels" aria-hidden="true">
<strong v-for="label in roundLabels" :key="label">{{ label }}</strong>
@@ -182,34 +182,30 @@ const mobilePairs = computed(() => {
:image-server="slot.imageServer"
:npc-state="slot.npcState"
/>
<button
v-if="columnIndex === 0 && bettingOpen && slot.id !== null"
type="button"
class="bracket-bet-button"
:aria-label="`${slot.name}에게 베팅하기`"
@click="requestBet(slot)"
>
베팅하기
</button>
<div v-if="columnIndex === 0 && bettingMode" class="bracket-bet-summary">
<small v-if="coreStat(slot)" class="bracket-core-stat">
{{ coreStat(slot)?.label }} {{ coreStat(slot)?.value }}
</small>
<small class="bracket-odds">배당 {{ odds(slot.id) }}</small>
<span class="bracket-my-bet-tooltip">
<RichTooltip
title="현재 배당 기준 예상 환수금"
:description="myExpectedReturnDescription(slot.id)"
placement="bottom"
:max-width="260"
:test-id="slot.id === null ? undefined : `candidate-return-${slot.id}`"
>
<small class="bracket-my-bet"
> 투자 {{ myBet(slot.id).toLocaleString('ko-KR') }}</small
>
</RichTooltip>
</span>
<small class="bracket-my-bet"> 투자 {{ myBet(slot.id).toLocaleString('ko-KR') }}</small>
<small class="bracket-return"
>예상 환수 {{ (myExpectedReturn(slot.id) ?? 0).toLocaleString('ko-KR') }}</small
>
</div>
<slot
v-if="columnIndex === 0 && bettingOpen && slot.id !== null"
name="bet-controls"
:candidate="slot"
>
<button
type="button"
class="bracket-bet-button"
:aria-label="`${slot.name}에게 베팅하기`"
@click="requestBet(slot)"
>
베팅
</button>
</slot>
</article>
</template>
</div>
@@ -269,34 +265,30 @@ const mobilePairs = computed(() => {
:image-server="slot.imageServer"
:npc-state="slot.npcState"
/>
<button
v-if="activeMobileRound === 0 && bettingOpen && slot.id !== null"
type="button"
class="bracket-bet-button"
:aria-label="`${slot.name}에게 베팅하기`"
@click="requestBet(slot)"
>
베팅하기
</button>
<div v-if="activeMobileRound === 0 && bettingMode" class="bracket-bet-summary">
<small v-if="coreStat(slot)" class="bracket-core-stat">
{{ coreStat(slot)?.label }} {{ coreStat(slot)?.value }}
</small>
<small class="bracket-odds">배당 {{ odds(slot.id) }}</small>
<span class="bracket-my-bet-tooltip">
<RichTooltip
title="현재 배당 기준 예상 환수금"
:description="myExpectedReturnDescription(slot.id)"
placement="bottom"
:max-width="260"
:test-id="slot.id === null ? undefined : `mobile-candidate-return-${slot.id}`"
>
<small class="bracket-my-bet"
> 투자 {{ myBet(slot.id).toLocaleString('ko-KR') }}</small
>
</RichTooltip>
</span>
<small class="bracket-my-bet"> 투자 {{ myBet(slot.id).toLocaleString('ko-KR') }}</small>
<small class="bracket-return"
>예상 환수 {{ (myExpectedReturn(slot.id) ?? 0).toLocaleString('ko-KR') }}</small
>
</div>
<slot
v-if="activeMobileRound === 0 && bettingOpen && slot.id !== null"
name="bet-controls"
:candidate="slot"
>
<button
type="button"
class="bracket-bet-button"
:aria-label="`${slot.name}에게 베팅하기`"
@click="requestBet(slot)"
>
베팅
</button>
</slot>
</div>
<strong v-if="pair.length === 2" class="versus" aria-hidden="true">VS</strong>
</article>
@@ -361,8 +353,8 @@ const mobilePairs = computed(() => {
padding: 1px 3px;
}
.desktop-bracket-name.betting-candidate {
width: clamp(150px, 18vw, 216px);
min-height: 92px;
width: 28.3333%;
min-height: 140px;
align-content: center;
gap: 5px;
padding: 5px 6px;
@@ -412,7 +404,7 @@ const mobilePairs = computed(() => {
min-width: 0;
min-height: 24px;
align-items: center;
padding-right: 62px;
padding-right: 0;
text-align: left;
}
.bracket-candidate-identity :deep(.rich-tooltip-trigger) {
@@ -449,16 +441,40 @@ const mobilePairs = computed(() => {
.bracket-odds {
text-align: right;
}
.bracket-my-bet-tooltip {
grid-column: 1 / -1;
min-width: 0;
color: orange;
text-align: left;
}
.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: 11px;
line-height: 14px;
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) {
.inline-betting-bracket .desktop-bracket {
display: none;
}
.inline-betting-bracket .mobile-bracket {
display: block;
}
}
.mobile-bracket {
display: none;
}
@@ -505,7 +521,7 @@ const mobilePairs = computed(() => {
padding: 1px 3px;
}
.mobile-bracket-name.betting-candidate {
min-height: 92px;
min-height: 140px;
padding: 5px 6px;
}
.versus {
+151 -178
View File
@@ -2,25 +2,29 @@
import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatGameTime } = useClockDisplay();
import { storeToRefs } from 'pinia';
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
import TournamentPageHeader from '../components/tournament/TournamentPageHeader.vue';
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
import { useGameFeedback } from '../composables/useGameFeedback';
import { useTournamentPagesStore } from '../stores/tournamentPages';
import type { TournamentBracketSlot } from '../utils/tournamentBracket';
import { trpc } from '../utils/trpc';
const tournamentPages = useTournamentPagesStore();
const { snapshot, betting: summary, rankings, loading, error } = storeToRefs(tournamentPages);
const selectedAmount = ref(10);
const selectedTarget = ref<TournamentBracketSlot | null>(null);
const betDialog = ref<HTMLDialogElement | null>(null);
const betAmountSelect = ref<HTMLSelectElement | null>(null);
const placingBet = ref(false);
const betError = ref<string | null>(null);
const defaultAmount = ref<number | string>(10);
const amounts = ref<Record<number, number | string>>({});
const pendingBets = ref<Record<number, number>>({});
const betMessages = ref<Record<number, { text: string; error: boolean }>>({});
const presetAmounts = [10, 20, 50, 100, 200, 500, 1000];
const activeRankingPrefix = ref('tt');
const { success: showSuccessToast } = useGameFeedback();
const amountFor = (id: number): number | string => amounts.value[id] ?? defaultAmount.value;
const remainingAmount = computed(() => Math.max(0, 1000 - myAmount.value));
const availableAmount = computed(() =>
Math.max(0, remainingAmount.value - Object.values(pendingBets.value).reduce((sum, amount) => sum + amount, 0))
);
const validAmount = (amount: number | string): boolean =>
typeof amount === 'number' && Number.isInteger(amount) && amount >= 10 && amount <= availableAmount.value;
const typeNames = ['전력전', '통솔전', '일기토', '설전'];
const stageNames = [
'경기 없음',
@@ -48,22 +52,9 @@ const totalAmount = computed(() => summary.value?.totalAmount ?? 0);
const myAmount = computed(() => summary.value?.myAmount ?? 0);
const betTotals = computed(() => summary.value?.totals as Record<number, number> | undefined);
const myBetTotals = computed(() => summary.value?.myTotals as Record<number, number> | undefined);
const ratio = (id: number) => {
const totals = summary.value?.totals as Record<number, number> | undefined;
const amount = totals?.[id] ?? 0;
return amount ? (totalAmount.value / amount).toFixed(2) : '0';
};
const openingTime = computed(() =>
formatGameTime(snapshot.value?.state?.nextAt, { format: 'hourMinute', fallback: '--:--' })
);
const selectedRatio = computed(() => {
const targetId = selectedTarget.value?.id;
return targetId === null || targetId === undefined ? '0' : ratio(targetId);
});
const selectedExpectedReturn = computed(() => {
const numericRatio = Number(selectedRatio.value);
return Number.isFinite(numericRatio) ? Math.round(selectedAmount.value * numericRatio) : 0;
});
const bettingOpen = computed(() => {
const state = snapshot.value?.state;
if (!state || state.stage !== 6) return false;
@@ -71,32 +62,21 @@ const bettingOpen = computed(() => {
return new Date(state.bettingCloseAt).getTime() > Date.now();
});
const openBetDialog = async (target: TournamentBracketSlot) => {
if (target.id === null || !bettingOpen.value) return;
selectedTarget.value = target;
betError.value = null;
await nextTick();
betDialog.value?.showModal();
betAmountSelect.value?.focus();
};
const closeBetDialog = () => {
betDialog.value?.close();
};
const placeBet = async () => {
const targetId = selectedTarget.value?.id;
if (targetId === null || targetId === undefined || placingBet.value) return;
const amount = selectedAmount.value;
betError.value = null;
placingBet.value = true;
const placeBet = async (target: TournamentBracketSlot) => {
const targetId = target.id;
if (targetId === null || pendingBets.value[targetId] || !bettingOpen.value) return;
const amount = amountFor(targetId);
if (!validAmount(amount) || typeof amount !== 'number') return;
delete betMessages.value[targetId];
pendingBets.value[targetId] = amount;
try {
await trpc.tournament.placeBet.mutate({ targetId, amount });
showSuccessToast('베팅이 등록되었습니다.');
betMessages.value[targetId] = { text: `${amount.toLocaleString('ko-KR')}금 베팅 완료`, error: false };
await load();
closeBetDialog();
} catch (value) {
betError.value = errorText(value);
betMessages.value[targetId] = { text: errorText(value), error: true };
} finally {
placingBet.value = false;
delete pendingBets.value[targetId];
}
};
</script>
@@ -125,6 +105,22 @@ const placeBet = async () => {
<small>(전체 금액 : {{ totalAmount }} / 투자 금액 : {{ myAmount }})</small>
</section>
<section class="bet-settings bg0" aria-label="베팅 금액 설정">
<label
>기본 금액
<input v-model.number="defaultAmount" type="number" min="10" max="1000" step="1" inputmode="numeric" />
</label>
<select v-model.number="defaultAmount" aria-label="기본 지정 금액">
<option v-if="!presetAmounts.includes(Number(defaultAmount))" :value="defaultAmount">직접 입력</option>
<option v-for="amount in presetAmounts" :key="amount" :value="amount">{{ amount }}</option>
</select>
<span>남은 한도 {{ remainingAmount.toLocaleString('ko-KR') }}</span>
<small
> 장수에게 추가할 금액입니다. 예상 환수금은 해당 장수 우승 금액이며, 최종 배당에 따라
달라집니다.</small
>
</section>
<TournamentBracket
class="bg0 betting-bracket"
:participants="snapshot?.participants ?? []"
@@ -137,61 +133,62 @@ const placeBet = async () => {
:show-legend="false"
:betting-open="bettingOpen"
:betting-mode="true"
@request-bet="openBetDialog"
/>
<dialog
ref="betDialog"
class="bet-dialog viewport-centered-dialog"
aria-labelledby="bet-dialog-title"
@close="selectedTarget = null"
>
<form v-if="selectedTarget" class="bet-dialog-content" @submit.prevent="placeBet">
<header>
<h2 id="bet-dialog-title">베팅하기</h2>
<button type="button" aria-label="베팅 닫기" :disabled="placingBet" @click="closeBetDialog">
×
</button>
</header>
<GeneralIdentity
:name="selectedTarget.name"
:picture="selectedTarget.picture"
:image-server="selectedTarget.imageServer"
:npc-state="selectedTarget.npcState"
/>
<label class="bet-amount-field">
<span>베팅 금액</span>
<select ref="betAmountSelect" v-model.number="selectedAmount" :disabled="placingBet">
<option :value="10">금10</option>
<option :value="20">금20</option>
<option :value="50">금50</option>
<option :value="100">금100</option>
<option :value="200">금200</option>
<option :value="500">금500</option>
<option :value="1000">최대 금1000</option>
</select>
</label>
<output class="bet-return-preview" aria-live="polite">
<span class="ratio-color">배당 {{ selectedRatio }}</span>
<span aria-hidden="true">×</span>
<span class="gold-color">{{ selectedAmount }}</span>
<span aria-hidden="true">=</span>
<strong class="return-color"
>예상 환수금 {{ selectedExpectedReturn.toLocaleString('ko-KR') }}</strong
<template #bet-controls="{ candidate: slot }">
<form v-if="slot.id !== null" class="inline-bet" @submit.prevent="placeBet(slot)">
<input
:value="amountFor(slot.id)"
type="number"
min="10"
:max="remainingAmount"
step="1"
inputmode="numeric"
:aria-label="`${slot.name} 베팅 금액`"
:disabled="Boolean(pendingBets[slot.id])"
@input="
amounts[slot.id] =
($event.target as HTMLInputElement).value === ''
? ''
: Number(($event.target as HTMLInputElement).value)
"
/>
<select
:value="presetAmounts.includes(Number(amountFor(slot.id))) ? amountFor(slot.id) : ''"
:aria-label="`${slot.name} 지정 금액`"
:disabled="Boolean(pendingBets[slot.id])"
@change="amounts[slot.id] = Number(($event.target as HTMLSelectElement).value)"
>
</output>
<p class="bet-preview-note">
현재 배당 기준 예상값이며, 베팅 상황에 따라 최종 배당은 달라질 있습니다.
</p>
<p v-if="betError" class="bet-dialog-error" role="alert">{{ betError }}</p>
<footer>
<button type="button" :disabled="placingBet" @click="closeBetDialog">취소</button>
<button type="submit" class="bet-submit" :disabled="placingBet">
{{ placingBet ? '등록 중...' : '베팅 등록' }}
<option value="" disabled>직접 입력</option>
<option
v-for="amount in presetAmounts"
:key="amount"
:value="amount"
:disabled="amount > availableAmount"
>
{{ amount }}
</option>
</select>
<button
type="submit"
class="bracket-bet-button"
:aria-label="`${slot.name}에게 베팅하기`"
:disabled="Boolean(pendingBets[slot.id]) || !validAmount(amountFor(slot.id))"
>
{{ pendingBets[slot.id] ? '등록 중' : '베팅' }}
</button>
</footer>
</form>
</dialog>
<small class="bet-message" :class="{ 'bet-error': betMessages[slot.id]?.error }" role="status">
{{
betMessages[slot.id]?.text ??
(!validAmount(amountFor(slot.id)) && !pendingBets[slot.id]
? availableAmount < 10
? '베팅 한도 부족'
: `10~${availableAmount}금 입력`
: '')
}}
</small>
</form>
</template>
</TournamentBracket>
<div class="legacy-table-signature" hidden>
<table v-for="tableIndex in 6" :key="tableIndex">
@@ -351,16 +348,6 @@ const placeBet = async () => {
color: orange;
font-size: 14px;
}
.ratio-color {
color: skyblue;
}
.expected,
.return-color {
color: cyan;
}
.gold-color {
color: orange;
}
select {
width: 100%;
min-height: 27px;
@@ -391,84 +378,76 @@ select:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.bet-dialog {
width: min(420px, calc(100vw - 24px));
max-width: none;
padding: 0;
border: 1px solid #8d713d;
border-radius: 8px;
color: #fff;
background: #3a2118 var(--sammo-texture-walnut);
box-shadow: 0 18px 56px rgb(0 0 0 / 75%);
.bet-settings {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 8px;
text-align: left;
}
.bet-dialog::backdrop {
background: rgb(0 0 0 / 72%);
}
.bet-dialog-content {
display: grid;
gap: 14px;
padding: 16px;
}
.bet-dialog-content header,
.bet-dialog-content footer {
.bet-settings label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.bet-dialog-content h2 {
margin: 0;
color: #ffd25e;
font-size: 20px;
.bet-settings input,
.bet-settings select {
width: 90px;
}
.bet-dialog-content header button {
width: 36px;
height: 36px;
padding: 0;
font-size: 22px;
}
.bet-dialog-content :deep(.general-identity) {
justify-content: flex-start;
text-align: left;
}
.bet-amount-field {
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
align-items: center;
gap: 10px;
text-align: left;
}
.bet-return-preview {
display: grid;
grid-template-columns: auto auto auto auto minmax(0, 1fr);
align-items: center;
gap: 7px;
padding: 12px;
border: 1px solid #66563c;
background: rgb(0 0 0 / 28%);
font-variant-numeric: tabular-nums;
}
.bet-preview-note,
.bet-dialog-error {
margin: 0;
text-align: left;
font-size: 12px;
}
.bet-preview-note {
.bet-settings small {
flex-basis: 100%;
color: #c9c1b2;
}
.bet-dialog-error {
color: #ff8080;
.inline-bet {
display: grid;
grid-template-columns: minmax(0, 1fr) 80px 64px;
gap: 4px;
width: 100%;
}
.bet-dialog-content footer {
justify-content: flex-end;
.inline-bet input,
.inline-bet select,
.inline-bet button,
.bet-settings input,
.bet-settings select {
box-sizing: border-box;
min-width: 0;
height: 44px;
margin: 0;
padding: 4px;
border: 1px solid #8d713d;
border-radius: 3px;
color: #fff;
background: #201610;
font-size: 16px;
}
.bet-dialog-content footer button {
min-width: 80px;
}
.bet-dialog-content .bet-submit {
border-color: #9a7632;
.inline-bet .bracket-bet-button {
background: #59400e;
font-weight: 700;
}
.inline-bet input:focus-visible,
.bet-settings input:focus-visible {
outline: 2px solid #f39c12;
outline-offset: 1px;
}
.bet-message {
grid-column: 1 / -1;
min-height: 16px;
font-size: 11px;
line-height: 16px;
color: #b8e6ac;
overflow-wrap: anywhere;
}
.bet-message.bet-error {
color: #ff9e9e;
}
@media (max-width: 1100px) {
.inline-bet {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.inline-bet .bracket-bet-button {
grid-column: 1 / -1;
}
}
.ranking-title {
min-height: 50px;
@@ -547,12 +526,6 @@ select:disabled {
.ranking-title {
font-size: 20px;
}
.bet-return-preview {
grid-template-columns: auto auto auto;
}
.bet-return-preview .return-color {
grid-column: 1 / -1;
}
.ranking-placeholder {
display: none;
}
@@ -5,7 +5,7 @@ import { describe, it } from 'node:test';
const migratedViewNames = [
'AuctionView.vue',
'BettingView.vue',
// Tournament betting reports each result in its candidate card; Chromium covers that contract.
'InheritView.vue',
'NationBettingView.vue',
'NationStratFinanView.vue',