Исправление точности вещественных чисел при ставках, казино, переводах и вкладах. Добавлена детальная валидация депозитов и юнит-тесты.

This commit is contained in:
Danil 2026-05-20 23:13:50 +03:00
parent 89a14f2ebf
commit d5698ab76e
6 changed files with 148 additions and 30 deletions

View file

@ -286,26 +286,26 @@ async def get_match_by_index(sport_alias: str, index: int) -> dict | None:
return None return None
def place_bet(user_id: int, match: dict, sport: str, chosen_team: str, amount: float) -> str: def place_bet(user_id: int, match: dict, sport: str, chosen_team: str, amount: float) -> tuple[bool, str]:
current = get_user_length(user_id) current = get_user_length(user_id)
if current is None: if current is None:
return "Сначала заведи счёт через /penis, братуха." return False, "Сначала заведи счёт через /penis, братуха."
if current <= 0: if current <= 0:
return "🚫 С кредитом ставки не принимаем." return False, "🚫 С кредитом ставки не принимаем."
if amount <= 0: if amount <= 0:
return "Ставка должна быть больше нуля." return False, "Ставка должна быть больше нуля."
if amount > config.BET_MAX_AMOUNT: if amount > config.BET_MAX_AMOUNT:
return f"Максимальная ставка — {config.BET_MAX_AMOUNT} см." return False, f"Максимальная ставка — {config.BET_MAX_AMOUNT} см."
if amount > current: if round(amount, 2) > round(current, 2):
return f"У тебя {current:.1f} см, а ставишь {amount:.1f}. Не хватает, фраер." return False, f"У тебя {current:.1f} см, а ставишь {amount:.1f}. Не хватает, фраер."
team_odds = match["odds"].get(chosen_team) team_odds = match["odds"].get(chosen_team)
if team_odds is None: if team_odds is None:
return f"Исход не найден. Доступные: {describe_match_outcomes(match)}" return False, f"Исход не найден. Доступные: {describe_match_outcomes(match)}"
new_length = update_user_length(user_id, -amount) new_length = update_user_length(user_id, -amount)
if new_length is None: if new_length is None:
return "Ошибка БД." return False, "Ошибка БД."
now_ts = int(_time.time()) now_ts = int(_time.time())
try: try:
@ -323,12 +323,11 @@ def place_bet(user_id: int, match: dict, sport: str, chosen_team: str, amount: f
except psycopg2.Error: except psycopg2.Error:
logger.exception("Failed to place bet") logger.exception("Failed to place bet")
update_user_length(user_id, amount) update_user_length(user_id, amount)
return "Ошибка БД." return False, "Ошибка БД."
potential = round(amount * team_odds, 1) potential = round(amount * team_odds, 1)
return ( return True, (
f"✅ Ставка принята!\n" f"✅ Ставка принята!\n"
f"🏟 {match['home']} vs {match['away']}\n"
f"📌 {chosen_team} (x{team_odds:.2f})\n" f"📌 {chosen_team} (x{team_odds:.2f})\n"
f"💰 Ставка: {amount:.1f} см\n" f"💰 Ставка: {amount:.1f} см\n"
f"🎯 Возможный выигрыш: {potential:.1f} см\n" f"🎯 Возможный выигрыш: {potential:.1f} см\n"

View file

@ -75,7 +75,7 @@ def play_casino(user_id: int, bet: float) -> str:
return f"Максимальная ставка — {config.CASINO_MAX_BET} см, не жадничай." return f"Максимальная ставка — {config.CASINO_MAX_BET} см, не жадничай."
if bet <= 0: if bet <= 0:
return "Ставка должна быть больше нуля, фраер." return "Ставка должна быть больше нуля, фраер."
if bet > current: if round(bet, 2) > round(current, 2):
return f"У тебя {current:.1f} см, а ставишь {bet:.1f}. Столько нет, фраер." return f"У тебя {current:.1f} см, а ставишь {bet:.1f}. Столько нет, фраер."
reels, matches = spin_slots() reels, matches = spin_slots()

View file

@ -179,7 +179,8 @@ class EconomyManager:
return False return False
from_balance = self.get_user_balance(from_user_id) from_balance = self.get_user_balance(from_user_id)
if amount > from_balance * 0.2 or from_balance < amount: limit = round(from_balance * 0.2, 2)
if round(amount, 2) > limit or round(from_balance, 2) < round(amount, 2):
return False return False
with get_conn() as conn: with get_conn() as conn:
@ -201,15 +202,17 @@ class EconomyManager:
logger.info(f"Transfer: {amount} from user {from_user_id} to user {to_user_id}") logger.info(f"Transfer: {amount} from user {from_user_id} to user {to_user_id}")
return True return True
def create_deposit(self, user_id: int, amount: float, days: int) -> bool: def create_deposit(self, user_id: int, amount: float, days: int) -> tuple[bool, str]:
if amount <= 0: if amount <= 0:
return False return False, "Сумма вклада должна быть больше нуля."
balance = self.get_user_balance(user_id) balance = self.get_user_balance(user_id)
min_deposit = float(self.get_setting('deposit_min_amount')) min_deposit = float(self.get_setting('deposit_min_amount'))
if amount < min_deposit or balance < amount: if round(amount, 2) < round(min_deposit, 2):
return False return False, f"Минимальная сумма вклада — {min_deposit:.1f} см. Твой баланс: {balance:.1f} см."
if round(balance, 2) < round(amount, 2):
return False, f"Недостаточно средств. Твой баланс: {balance:.1f} см, а сумма вклада: {amount:.1f} см."
central_bank_rate = float(self.get_setting('central_bank_rate')) central_bank_rate = float(self.get_setting('central_bank_rate'))
matures_at = datetime.now() + timedelta(days=days) matures_at = datetime.now() + timedelta(days=days)
@ -230,7 +233,7 @@ class EconomyManager:
''', (user_id, amount, central_bank_rate, matures_at)) ''', (user_id, amount, central_bank_rate, matures_at))
logger.info(f"Deposit created: user {user_id}, amount {amount}, rate {central_bank_rate}%") logger.info(f"Deposit created: user {user_id}, amount {amount}, rate {central_bank_rate}%")
return True return True, "Вклад успешно открыт!"
def create_loan(self, user_id: int, amount: float, days: int) -> bool: def create_loan(self, user_id: int, amount: float, days: int) -> bool:
if amount <= 0: if amount <= 0:
@ -239,7 +242,7 @@ class EconomyManager:
max_loan = float(self.get_setting('loan_max_amount')) max_loan = float(self.get_setting('loan_max_amount'))
loan_rate = float(self.get_setting('loan_interest_rate')) loan_rate = float(self.get_setting('loan_interest_rate'))
if amount > max_loan: if round(amount, 2) > round(max_loan, 2):
return False return False
with get_conn() as conn: with get_conn() as conn:
@ -251,13 +254,13 @@ class EconomyManager:
(user_id,), (user_id,),
) )
outstanding = float(cur.fetchone()['total']) outstanding = float(cur.fetchone()['total'])
if outstanding + amount > max_loan: if round(outstanding + amount, 2) > round(max_loan, 2):
return False return False
# ЦБ должен иметь достаточно капитала # ЦБ должен иметь достаточно капитала
cur.execute("SELECT capital FROM central_bank WHERE id=1") cur.execute("SELECT capital FROM central_bank WHERE id=1")
cb_capital = float(cur.fetchone()['capital']) cb_capital = float(cur.fetchone()['capital'])
if cb_capital < amount: if round(cb_capital, 2) < round(amount, 2):
return False return False
due_at = datetime.now() + timedelta(days=days) due_at = datetime.now() + timedelta(days=days)
@ -293,10 +296,10 @@ class EconomyManager:
if not loan or loan['is_repaid']: if not loan or loan['is_repaid']:
return False return False
total_repayment = loan['amount'] * (1 + loan['interest_rate'] / 100) total_repayment = round(loan['amount'] * (1 + loan['interest_rate'] / 100), 2)
balance = self.get_user_balance(user_id) balance = self.get_user_balance(user_id)
if balance < total_repayment: if round(balance, 2) < total_repayment:
return False return False
wcur = conn.cursor() wcur = conn.cursor()
@ -479,7 +482,7 @@ class EconomyManager:
logger.info(f"Central bank updated: {capital_change} ({transaction_type})") logger.info(f"Central bank updated: {capital_change} ({transaction_type})")
def emit_money(self, amount: float, reason: str = "") -> bool: def emit_money(self, amount: float, reason: str = "") -> bool:
if self.get_central_bank_stats()['capital'] < amount: if round(self.get_central_bank_stats()['capital'], 2) < round(amount, 2):
return False return False
self.update_central_bank(-amount, 'emission', reason) self.update_central_bank(-amount, 'emission', reason)
logger.info(f"Money emitted: {amount} cm ({reason})") logger.info(f"Money emitted: {amount} cm ({reason})")

View file

@ -1263,12 +1263,12 @@ async def handle_deposit_cmd(message: Message):
await message.reply("Сумма и дни должны быть положительными.", parse_mode=None) await message.reply("Сумма и дни должны быть положительными.", parse_mode=None)
return return
success = await asyncio.to_thread(economy.create_deposit, message.from_user.id, amount, days) success, error_msg = await asyncio.to_thread(economy.create_deposit, message.from_user.id, amount, days)
if success: if success:
rate = economy.get_setting('central_bank_rate') rate = economy.get_setting('central_bank_rate')
await message.reply(f"✅ Вклад открыт!\n💰 Сумма: {amount:.1f} см\n📅 Срок: {days} дней\n📈 Ставка: {rate}% годовых", parse_mode=None) await message.reply(f"✅ Вклад открыт!\n💰 Сумма: {amount:.1f} см\n📅 Срок: {days} дней\n📈 Ставка: {rate}% годовых", parse_mode=None)
else: else:
await message.reply("Не удалось открыть вклад. Проверь баланс и минимальную сумму.", parse_mode=None) await message.reply(f"Не удалось открыть вклад. {error_msg}", parse_mode=None)
async def handle_loans_cmd(message: Message): async def handle_loans_cmd(message: Message):
"""Показать кредиты""" """Показать кредиты"""
@ -1562,7 +1562,7 @@ async def handle_bet_cmd(message: Message):
) )
return return
result = await asyncio.to_thread(place_bet, message.from_user.id, match, sport_alias, team, amount) success, result = await asyncio.to_thread(place_bet, message.from_user.id, match, sport_alias, team, amount)
await message.reply(result, parse_mode=None) await message.reply(result, parse_mode=None)
async def handle_mybets_cmd(message: Message): async def handle_mybets_cmd(message: Message):

114
test_precision.py Normal file
View file

@ -0,0 +1,114 @@
import sys
import os
import unittest
from unittest.mock import MagicMock, patch
# Вставляем путь к проекту в sys.path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# 1. Глобальный мок для psycopg2, пула соединений и функции get_conn,
# чтобы избежать реального подключения к БД во время импорта и выполнения тестов.
import psycopg2
psycopg2.connect = MagicMock()
import psycopg2.pool
psycopg2.pool.ThreadedConnectionPool = MagicMock()
import db
# Мокаем get_conn как контекстный менеджер
mock_conn = MagicMock()
mock_cur = MagicMock()
mock_conn.cursor.return_value = mock_cur
# Для RealDictCursor
mock_cur_dict = MagicMock()
mock_conn.cursor.return_value = mock_cur_dict
mock_get_conn = MagicMock()
mock_get_conn.return_value.__enter__.return_value = mock_conn
db.get_conn = mock_get_conn
# 2. Импортируем тестируемые модули после настройки моков
from games.betting import place_bet
from games.casino import play_casino
from games.economy import economy
class TestPrecision(unittest.TestCase):
@patch('games.betting.get_user_length')
@patch('games.betting.update_user_length')
def test_place_bet_exact_balance(self, mock_update_user_length, mock_get_user_length):
# Баланс 0.199999988 (32-bit float представление 0.2)
mock_get_user_length.return_value = 0.199999988
mock_update_user_length.return_value = 0.0
match = {
"id": "match_123",
"home": "Team A",
"away": "Team B",
"odds": {"Team A": 2.0, "Team B": 1.8}
}
# Ставка ровно 0.2 должна быть успешной благодаря округлению round(amount, 2) <= round(current, 2)
success, message = place_bet(12345, match, "dota2", "Team A", 0.2)
self.assertTrue(success)
self.assertIn("Ставка принята!", message)
# Ставка 0.21 при балансе 0.199999988 должна быть отклонена
success_fail, message_fail = place_bet(12345, match, "dota2", "Team A", 0.21)
self.assertFalse(success_fail)
self.assertIn("Не хватает, фраер", message_fail)
@patch('games.casino.economy.get_user_balance')
@patch('games.casino.spin_slots')
@patch('games.casino.economy.update_balance')
def test_casino_exact_balance(self, mock_update, mock_spin, mock_get_user_balance):
# Баланс 0.199999988
mock_get_user_balance.return_value = 0.199999988
mock_spin.return_value = (["🍒", "🍒", "🍇"], 2)
mock_update.return_value = 0.0
# Ставка ровно 0.2 в казино должна быть успешной
result = play_casino(12345, 0.2)
self.assertNotIn("Столько нет, фраер", result)
# Ставка 0.21 при балансе 0.199999988 должна быть отклонена
result_fail = play_casino(12345, 0.21)
self.assertIn("Столько нет, фраер", result_fail)
@patch('games.economy.economy.get_user_balance')
@patch('games.economy.economy.get_setting')
def test_economy_deposit_exact_balance(self, mock_get_setting, mock_get_user_balance):
# Баланс 9.999999 (32-bit float представление 10.0), минимальный вклад 10.0
mock_get_user_balance.return_value = 9.999999
mock_get_setting.side_effect = lambda key: "10.0" if key == "deposit_min_amount" else "7.5"
# Вклад на 10.0 при балансе 9.999999 (эквивалентен 10.0) должен пройти успешно благодаря округлению
success, msg = economy.create_deposit(12345, 10.0, 30)
self.assertTrue(success)
self.assertEqual(msg, "Вклад успешно открыт!")
# Вклад на 10.01 должен быть отклонен из-за нехватки баланса
success_fail, msg_fail = economy.create_deposit(12345, 10.01, 30)
self.assertFalse(success_fail)
self.assertIn("Недостаточно средств", msg_fail)
# Вклад на 9.99 должен быть отклонен из-за минимальной суммы
success_fail2, msg_fail2 = economy.create_deposit(12345, 9.99, 30)
self.assertFalse(success_fail2)
self.assertIn("Минимальная сумма вклада", msg_fail2)
@patch('games.economy.economy.get_user_balance')
def test_economy_transfer_exact_balance(self, mock_get_user_balance):
# При балансе 9.999999 (эквивалентен 10.0) лимит перевода (20%) составляет ровно 2.0.
mock_get_user_balance.return_value = 9.999999
# Перевод ровно 2.0 должен пройти успешно
success = economy.transfer_money(12345, 67890, 2.0, "Test transfer")
self.assertTrue(success)
# Перевод 2.01 должен превысить 20% лимит (2.0) и отклониться
success_fail = economy.transfer_money(12345, 67890, 2.01, "Test transfer")
self.assertFalse(success_fail)
if __name__ == '__main__':
unittest.main()

View file

@ -253,7 +253,9 @@ async def create_bet(
raise HTTPException(status_code=400, detail="Неверный исход ставки") raise HTTPException(status_code=400, detail="Неверный исход ставки")
# 3. Разместить ставку (синхронная функция) # 3. Разместить ставку (синхронная функция)
result = await asyncio.to_thread(place_bet, user_id, match, req.league, team, req.amount) success, result = await asyncio.to_thread(place_bet, user_id, match, req.league, team, req.amount)
if not success:
raise HTTPException(status_code=400, detail=result)
return {"message": result} return {"message": result}
@ -294,7 +296,7 @@ async def play_casino_api(
raise HTTPException(status_code=400, detail=f"Макс ставка: {config.CASINO_MAX_BET} см") raise HTTPException(status_code=400, detail=f"Макс ставка: {config.CASINO_MAX_BET} см")
if req.bet <= 0: if req.bet <= 0:
raise HTTPException(status_code=400, detail="Ставка > 0") raise HTTPException(status_code=400, detail="Ставка > 0")
if req.bet > current: if round(req.bet, 2) > round(current, 2):
raise HTTPException(status_code=400, detail=f"У тебя {current:.1f} см") raise HTTPException(status_code=400, detail=f"У тебя {current:.1f} см")
reels, match_count = await asyncio.to_thread(spin_slots) reels, match_count = await asyncio.to_thread(spin_slots)