베팅장 장수칸에서 금액 입력과 개별 즉시 베팅 지원
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user