From d5698ab76ea68be090e0b94e04419014fdde5ac1 Mon Sep 17 00:00:00 2001 From: Danil Date: Wed, 20 May 2026 23:13:50 +0300 Subject: [PATCH] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=82=D0=BE=D1=87=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B8=20=D0=B2=D0=B5=D1=89=D0=B5=D1=81=D1=82=D0=B2=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D1=85=20=D1=87=D0=B8=D1=81=D0=B5=D0=BB=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D1=81=D1=82=D0=B0=D0=B2=D0=BA=D0=B0=D1=85?= =?UTF-8?q?,=20=D0=BA=D0=B0=D0=B7=D0=B8=D0=BD=D0=BE,=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B2=D0=BE=D0=B4=D0=B0=D1=85=20=D0=B8=20=D0=B2=D0=BA?= =?UTF-8?q?=D0=BB=D0=B0=D0=B4=D0=B0=D1=85.=20=D0=94=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=B4=D0=B5=D1=82=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=B0=D1=8F=20=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20=D0=B4=D0=B5=D0=BF=D0=BE=D0=B7=D0=B8=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=20=D0=B8=20=D1=8E=D0=BD=D0=B8=D1=82-=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D1=8B.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- games/betting.py | 23 +++++----- games/casino.py | 2 +- games/economy.py | 27 ++++++----- main.py | 6 +-- test_precision.py | 114 ++++++++++++++++++++++++++++++++++++++++++++++ webapp/api.py | 6 ++- 6 files changed, 148 insertions(+), 30 deletions(-) create mode 100644 test_precision.py diff --git a/games/betting.py b/games/betting.py index c444cd2..03fb315 100644 --- a/games/betting.py +++ b/games/betting.py @@ -286,26 +286,26 @@ async def get_match_by_index(sport_alias: str, index: int) -> dict | 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) if current is None: - return "Сначала заведи счёт через /penis, братуха." + return False, "Сначала заведи счёт через /penis, братуха." if current <= 0: - return "🚫 С кредитом ставки не принимаем." + return False, "🚫 С кредитом ставки не принимаем." if amount <= 0: - return "Ставка должна быть больше нуля." + return False, "Ставка должна быть больше нуля." if amount > config.BET_MAX_AMOUNT: - return f"Максимальная ставка — {config.BET_MAX_AMOUNT} см." - if amount > current: - return f"У тебя {current:.1f} см, а ставишь {amount:.1f}. Не хватает, фраер." + return False, f"Максимальная ставка — {config.BET_MAX_AMOUNT} см." + if round(amount, 2) > round(current, 2): + return False, f"У тебя {current:.1f} см, а ставишь {amount:.1f}. Не хватает, фраер." team_odds = match["odds"].get(chosen_team) 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) if new_length is None: - return "Ошибка БД." + return False, "Ошибка БД." now_ts = int(_time.time()) try: @@ -323,12 +323,11 @@ def place_bet(user_id: int, match: dict, sport: str, chosen_team: str, amount: f except psycopg2.Error: logger.exception("Failed to place bet") update_user_length(user_id, amount) - return "Ошибка БД." + return False, "Ошибка БД." potential = round(amount * team_odds, 1) - return ( + return True, ( f"✅ Ставка принята!\n" - f"🏟 {match['home']} vs {match['away']}\n" f"📌 {chosen_team} (x{team_odds:.2f})\n" f"💰 Ставка: {amount:.1f} см\n" f"🎯 Возможный выигрыш: {potential:.1f} см\n" diff --git a/games/casino.py b/games/casino.py index 140ba6d..341dcb5 100644 --- a/games/casino.py +++ b/games/casino.py @@ -75,7 +75,7 @@ def play_casino(user_id: int, bet: float) -> str: return f"Максимальная ставка — {config.CASINO_MAX_BET} см, не жадничай." if bet <= 0: return "Ставка должна быть больше нуля, фраер." - if bet > current: + if round(bet, 2) > round(current, 2): return f"У тебя {current:.1f} см, а ставишь {bet:.1f}. Столько нет, фраер." reels, matches = spin_slots() diff --git a/games/economy.py b/games/economy.py index 3a796e8..ef68a6c 100644 --- a/games/economy.py +++ b/games/economy.py @@ -179,7 +179,8 @@ class EconomyManager: return False 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 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}") 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: - return False + return False, "Сумма вклада должна быть больше нуля." balance = self.get_user_balance(user_id) min_deposit = float(self.get_setting('deposit_min_amount')) - if amount < min_deposit or balance < amount: - return False + if round(amount, 2) < round(min_deposit, 2): + 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')) matures_at = datetime.now() + timedelta(days=days) @@ -230,7 +233,7 @@ class EconomyManager: ''', (user_id, amount, central_bank_rate, matures_at)) 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: if amount <= 0: @@ -239,7 +242,7 @@ class EconomyManager: max_loan = float(self.get_setting('loan_max_amount')) loan_rate = float(self.get_setting('loan_interest_rate')) - if amount > max_loan: + if round(amount, 2) > round(max_loan, 2): return False with get_conn() as conn: @@ -251,13 +254,13 @@ class EconomyManager: (user_id,), ) outstanding = float(cur.fetchone()['total']) - if outstanding + amount > max_loan: + if round(outstanding + amount, 2) > round(max_loan, 2): return False # ЦБ должен иметь достаточно капитала cur.execute("SELECT capital FROM central_bank WHERE id=1") cb_capital = float(cur.fetchone()['capital']) - if cb_capital < amount: + if round(cb_capital, 2) < round(amount, 2): return False due_at = datetime.now() + timedelta(days=days) @@ -293,10 +296,10 @@ class EconomyManager: if not loan or loan['is_repaid']: 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) - if balance < total_repayment: + if round(balance, 2) < total_repayment: return False wcur = conn.cursor() @@ -479,7 +482,7 @@ class EconomyManager: logger.info(f"Central bank updated: {capital_change} ({transaction_type})") 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 self.update_central_bank(-amount, 'emission', reason) logger.info(f"Money emitted: {amount} cm ({reason})") diff --git a/main.py b/main.py index 0840ecc..31b3750 100644 --- a/main.py +++ b/main.py @@ -1263,12 +1263,12 @@ async def handle_deposit_cmd(message: Message): await message.reply("Сумма и дни должны быть положительными.", parse_mode=None) 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: rate = economy.get_setting('central_bank_rate') await message.reply(f"✅ Вклад открыт!\n💰 Сумма: {amount:.1f} см\n📅 Срок: {days} дней\n📈 Ставка: {rate}% годовых", parse_mode=None) else: - await message.reply("❌ Не удалось открыть вклад. Проверь баланс и минимальную сумму.", parse_mode=None) + await message.reply(f"❌ Не удалось открыть вклад. {error_msg}", parse_mode=None) async def handle_loans_cmd(message: Message): """Показать кредиты""" @@ -1562,7 +1562,7 @@ async def handle_bet_cmd(message: Message): ) 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) async def handle_mybets_cmd(message: Message): diff --git a/test_precision.py b/test_precision.py new file mode 100644 index 0000000..974da17 --- /dev/null +++ b/test_precision.py @@ -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() diff --git a/webapp/api.py b/webapp/api.py index ee29af3..12ab236 100644 --- a/webapp/api.py +++ b/webapp/api.py @@ -253,7 +253,9 @@ async def create_bet( raise HTTPException(status_code=400, detail="Неверный исход ставки") # 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} @@ -294,7 +296,7 @@ async def play_casino_api( raise HTTPException(status_code=400, detail=f"Макс ставка: {config.CASINO_MAX_BET} см") if req.bet <= 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} см") reels, match_count = await asyncio.to_thread(spin_slots)