From abd654bb3ae0dd9b84a445091aa60559a717f3ba Mon Sep 17 00:00:00 2001 From: Sats Date: Fri, 13 Mar 2026 19:28:03 +0000 Subject: [PATCH] tests: add Python test infrastructure with 30 tests - Add pytest configuration (pytest.ini) - Add shared fixtures in conftest.py (temp files, sample data) - Add test_prodillo_logic.py: winner determination, treasury calc, round state - Add test_bitcoin_prices.py: price tracking, ATH detection, notifications - Update .gitignore: exclude __pycache__, .venv, .pytest_cache All 30 tests passing. --- .gitignore | 5 +- pytest.ini | 6 ++ tests/__init__.py | 1 + tests/conftest.py | 80 +++++++++++++++ tests/test_bitcoin_prices.py | 185 +++++++++++++++++++++++++++++++++ tests/test_prodillo_logic.py | 194 +++++++++++++++++++++++++++++++++++ 6 files changed, 470 insertions(+), 1 deletion(-) create mode 100644 pytest.ini create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_bitcoin_prices.py create mode 100644 tests/test_prodillo_logic.py diff --git a/.gitignore b/.gitignore index afd3cce..edde722 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,7 @@ src/tls.cert src/modules/test.ts dist/ debug/ -AGENTS.md \ No newline at end of file +AGENTS.md.venv/ +__pycache__/ +*.pyc +.pytest_cache/ diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..9855d94 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = -v --tb=short diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..65140f2 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# tests package diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e355625 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,80 @@ +"""Shared fixtures for Botillo tests.""" +import json +import os +import tempfile +import pytest + + +@pytest.fixture +def tmp_db_dir(): + """Create a temporary directory for test JSON files.""" + with tempfile.TemporaryDirectory() as d: + yield d + + +@pytest.fixture +def bitcoin_file(tmp_db_dir): + """Path to a test bitcoin.json file.""" + return os.path.join(tmp_db_dir, "bitcoin.json") + + +@pytest.fixture +def prodillos_file(tmp_db_dir): + """Path to a test prodillos.json file.""" + return os.path.join(tmp_db_dir, "prodillos.json") + + +@pytest.fixture +def trofeillos_file(tmp_db_dir): + """Path to a test trofeillos.json file.""" + return os.path.join(tmp_db_dir, "trofeillos.json") + + +@pytest.fixture +def sample_bitcoin_data(): + """Sample bitcoin price tracker data.""" + return { + "bitcoinATH": 109000, + "dailyMax": 108500, + "dailyMin": 107000, + "bitcoinMax": 108800, + "bitcoinMaxBlock": 890000, + } + + +@pytest.fixture +def sample_prodillos(): + """Sample prodillos prediction data.""" + return { + "users": { + "123": {"user": "Alice", "predict": 110000}, + "456": {"user": "Bob", "predict": 105000}, + "789": {"user": "Charlie", "predict": 108500}, + }, + "treasury": 1000, + } + + +@pytest.fixture +def sample_trofeillos(): + """Sample trofeillos champion data.""" + return { + "currentChampion": "Alice", + "currentChampionId": "123", + "123": { + "champion": "Alice", + "trofeillos profesionales": ["🏆 [890000]"], + }, + } + + +def write_json(path, data): + """Helper to write JSON to a file.""" + with open(path, "w") as f: + json.dump(data, f, indent=2) + + +def read_json(path): + """Helper to read JSON from a file.""" + with open(path) as f: + return json.load(f) diff --git a/tests/test_bitcoin_prices.py b/tests/test_bitcoin_prices.py new file mode 100644 index 0000000..e4d9a1f --- /dev/null +++ b/tests/test_bitcoin_prices.py @@ -0,0 +1,185 @@ +"""Tests for Bitcoin price tracking logic. + +Tests the price tracking, ATH detection, and daily max/min logic. +External API calls are mocked. +""" +import json +import pytest +from tests.conftest import write_json, read_json + + +class TestPriceTracking: + """Test price comparison and tracking logic.""" + + def test_new_ath_detected(self, bitcoin_file, sample_bitcoin_data): + """When price > ATH, update ATH.""" + write_json(bitcoin_file, sample_bitcoin_data) + data = read_json(bitcoin_file) + + new_price = 110000 + assert new_price > data["bitcoinATH"] + + data["bitcoinATH"] = new_price + write_json(bitcoin_file, data) + + updated = read_json(bitcoin_file) + assert updated["bitcoinATH"] == 110000 + + def test_new_daily_max(self, bitcoin_file, sample_bitcoin_data): + """When price > dailyMax but < ATH, update dailyMax.""" + write_json(bitcoin_file, sample_bitcoin_data) + data = read_json(bitcoin_file) + + new_price = 108700 # > dailyMax (108500), < ATH (109000) + assert new_price > data["dailyMax"] + assert new_price < data["bitcoinATH"] + + data["dailyMax"] = new_price + write_json(bitcoin_file, data) + + updated = read_json(bitcoin_file) + assert updated["dailyMax"] == 108700 + + def test_new_daily_min(self, bitcoin_file, sample_bitcoin_data): + """When price < dailyMin, update dailyMin.""" + write_json(bitcoin_file, sample_bitcoin_data) + data = read_json(bitcoin_file) + + new_price = 106000 # < dailyMin (107000) + assert new_price < data["dailyMin"] + + data["dailyMin"] = new_price + write_json(bitcoin_file, data) + + updated = read_json(bitcoin_file) + assert updated["dailyMin"] == 106000 + + def test_price_between_daily_bounds(self, bitcoin_file, sample_bitcoin_data): + """Price between daily min/max shouldn't trigger updates.""" + write_json(bitcoin_file, sample_bitcoin_data) + data = read_json(bitcoin_file) + + new_price = 107500 # between 107000 and 108500 + assert new_price > data["dailyMin"] + assert new_price < data["dailyMax"] + assert new_price < data["bitcoinATH"] + + # No updates should happen + assert data["dailyMin"] == 107000 + assert data["dailyMax"] == 108500 + + def test_bitcoin_max_for_prodillo(self, bitcoin_file, sample_bitcoin_data): + """bitcoinMax tracks the all-time high for the current prodillo round.""" + write_json(bitcoin_file, sample_bitcoin_data) + data = read_json(bitcoin_file) + + new_price = 109500 + if new_price > data["bitcoinMax"]: + data["bitcoinMax"] = new_price + data["bitcoinMaxBlock"] = 890100 + + write_json(bitcoin_file, data) + updated = read_json(bitcoin_file) + assert updated["bitcoinMax"] == 109500 + assert updated["bitcoinMaxBlock"] == 890100 + + +class TestBitstampResponse: + """Test parsing of Bitstamp API responses.""" + + def test_parse_valid_response(self): + """Valid Bitstamp response parsed correctly.""" + mock_response = { + "last": "108500", + "low": "107000", + "high": "109000", + } + price = int(mock_response["last"]) + low = int(mock_response["low"]) + high = int(mock_response["high"]) + + assert price == 108500 + assert low == 107000 + assert high == 109000 + + def test_parse_decimal_response(self): + """Response with decimals handled by parseInt.""" + mock_response = {"last": "108500.50"} + price = int(mock_response["last"].split(".")[0]) + assert price == 108500 + + +class TestDailyReset: + """Test daily max/min reset logic.""" + + def test_daily_reset_preserves_ath(self, bitcoin_file): + """Daily reset should keep ATH and bitcoinMax.""" + data = { + "bitcoinATH": 109000, + "dailyMax": 0, + "dailyMin": float('inf'), + "bitcoinMax": 108800, + "bitcoinMaxBlock": 890000, + } + # Simulate reset + data["dailyMax"] = 0 + data["dailyMin"] = float('inf') + + # These should be preserved + assert data["bitcoinATH"] == 109000 + assert data["bitcoinMax"] == 108800 + assert data["bitcoinMaxBlock"] == 890000 + assert data["dailyMax"] == 0 + assert data["dailyMin"] == float('inf') + + def test_round_reset_clears_bitcoin_max(self, bitcoin_file, sample_bitcoin_data): + """After prodillo round ends, bitcoinMax resets to 0.""" + write_json(bitcoin_file, sample_bitcoin_data) + data = read_json(bitcoin_file) + + # Round ends + data["bitcoinMax"] = 0 + data["bitcoinMaxBlock"] = 0 + write_json(bitcoin_file, data) + + updated = read_json(bitcoin_file) + assert updated["bitcoinMax"] == 0 + assert updated["bitcoinMaxBlock"] == 0 + # ATH and daily values preserved + assert updated["bitcoinATH"] == 109000 + + +class TestNotificationLogic: + """Test when notifications should be sent.""" + + def test_ath_notification(self): + """ATH notification when price > all previous.""" + ath = 109000 + new_price = 110000 + should_notify = new_price > ath + assert should_notify is True + + def test_daily_max_notification(self): + """Daily max notification when price > dailyMax but < ATH.""" + ath = 109000 + daily_max = 108500 + new_price = 108700 + should_notify = new_price > daily_max and new_price < ath + assert should_notify is True + + def test_daily_min_notification(self): + """Daily min notification when price < dailyMin.""" + daily_min = 107000 + new_price = 106000 + should_notify = new_price < daily_min + assert should_notify is True + + def test_no_notification_in_range(self): + """No notification when price is within daily bounds.""" + daily_max = 108500 + daily_min = 107000 + new_price = 107500 + should_notify = ( + new_price > daily_max or new_price < daily_min + ) + assert should_notify is False diff --git a/tests/test_prodillo_logic.py b/tests/test_prodillo_logic.py new file mode 100644 index 0000000..a80d4c7 --- /dev/null +++ b/tests/test_prodillo_logic.py @@ -0,0 +1,194 @@ +"""Tests for Prodillo prediction game logic. + +These tests verify the core logic of the Prodillo game: +- Winner determination (closest prediction to round's BTC max) +- Treasury calculation (79% of total) +- Round state management +""" +import json +import pytest +from tests.conftest import write_json, read_json + + +class TestWinnerDetermination: + """Test who wins based on predictions vs actual BTC max.""" + + def test_closest_prediction_wins(self): + """The prediction closest to bitcoinMax wins.""" + bitcoin_max = 108500 + predictions = [ + ("123", "Alice", 110000), # diff: 1500 + ("456", "Bob", 105000), # diff: 3500 + ("789", "Charlie", 108000), # diff: 500 ← winner + ] + sorted_preds = sorted( + predictions, + key=lambda x: abs(x[2] - bitcoin_max) + ) + winner_id, winner_name, winner_pred = sorted_preds[0] + assert winner_name == "Charlie" + assert winner_pred == 108000 + + def test_exact_match_wins(self): + """Exact prediction should always win.""" + bitcoin_max = 100000 + predictions = [ + ("1", "Exact", 100000), # diff: 0 ← winner + ("2", "Close", 100001), # diff: 1 + ("3", "Far", 90000), # diff: 10000 + ] + sorted_preds = sorted( + predictions, + key=lambda x: abs(x[2] - bitcoin_max) + ) + assert sorted_preds[0][1] == "Exact" + + def test_overprediction_vs_underprediction(self): + """Equal distance above and below are tied (first wins).""" + bitcoin_max = 100000 + predictions = [ + ("1", "Over", 101000), # diff: 1000 + ("2", "Under", 99000), # diff: 1000 + ] + sorted_preds = sorted( + predictions, + key=lambda x: abs(x[2] - bitcoin_max) + ) + # Both have same distance, first in list wins (stable sort) + assert abs(sorted_preds[0][2] - bitcoin_max) == abs(sorted_preds[1][2] - bitcoin_max) + + def test_single_participant_wins(self): + """Single participant should always win.""" + predictions = [("1", "Solo", 50000)] + bitcoin_max = 100000 + sorted_preds = sorted( + predictions, + key=lambda x: abs(x[2] - bitcoin_max) + ) + assert sorted_preds[0][1] == "Solo" + + def test_empty_predictions(self, prodillos_file): + """No predictions should result in no winner.""" + data = {"users": {}, "treasury": 0} + write_json(prodillos_file, data) + loaded = read_json(prodillos_file) + assert len(loaded["users"]) == 0 + + +class TestTreasuryCalculation: + """Test treasury and prize calculations.""" + + def test_seventy_nine_percent_prize(self): + """Winner gets 79% of treasury (ceiling).""" + treasury = 1000 + prize = int(((treasury) * 0.79).__ceil__()) + assert prize == 790 + + def test_treasury_rounding(self): + """Treasury calculation rounds up.""" + treasury = 1001 + prize = int(((treasury) * 0.79).__ceil__()) + assert prize == 791 # 790.79 → 791 + + def test_zero_treasury(self): + """Zero treasury gives zero prize.""" + treasury = 0 + prize = int(((treasury) * 0.79).__ceil__()) + assert prize == 0 + + +class TestRoundState: + """Test round state transitions.""" + + def test_round_reset(self, prodillos_file): + """After round ends, prodillos reset to Hal Finney prediction.""" + hal_finney = {"0": {"user": "Hal Finney", "predict": 10000000}} + write_json(prodillos_file, hal_finney) + loaded = read_json(prodillos_file) + assert "0" in loaded + assert loaded["0"]["user"] == "Hal Finney" + assert loaded["0"]["predict"] == 10000000 + + def test_prediction_window_state(self): + """Prediction window state tracks correctly.""" + state = {"isPredictionWindowOpen": True} + # When prodilleableDeadline > 0 + state["isPredictionWindowOpen"] = 50 > 0 + assert state["isPredictionWindowOpen"] is True + # When prodilleableDeadline === 0 + state["isPredictionWindowOpen"] = 0 > 0 + assert state["isPredictionWindowOpen"] is False + + +class TestFilePersistence: + """Test that data persists correctly to JSON files.""" + + def test_write_and_read_prodillos(self, prodillos_file, sample_prodillos): + """Write prodillos and read them back.""" + write_json(prodillos_file, sample_prodillos) + loaded = read_json(prodillos_file) + assert loaded["treasury"] == 1000 + assert len(loaded["users"]) == 3 + assert loaded["users"]["123"]["user"] == "Alice" + + def test_write_and_read_bitcoin(self, bitcoin_file, sample_bitcoin_data): + """Write bitcoin data and read it back.""" + write_json(bitcoin_file, sample_bitcoin_data) + loaded = read_json(bitcoin_file) + assert loaded["bitcoinMax"] == 108800 + assert loaded["dailyMax"] == 108500 + + def test_write_and_read_trofeillos(self, trofeillos_file, sample_trofeillos): + """Write trofeillos and read them back.""" + write_json(trofeillos_file, sample_trofeillos) + loaded = read_json(trofeillos_file) + assert loaded["currentChampion"] == "Alice" + assert "trofeillos profesionales" in loaded["123"] + + def test_update_single_value(self, prodillos_file, sample_prodillos): + """Update a single value preserves other data.""" + write_json(prodillos_file, sample_prodillos) + loaded = read_json(prodillos_file) + loaded["treasury"] = 2000 + write_json(prodillos_file, loaded) + reloaded = read_json(prodillos_file) + assert reloaded["treasury"] == 2000 + assert len(reloaded["users"]) == 3 # users preserved + + +class TestEdgeCases: + """Edge cases and boundary conditions.""" + + def test_very_large_prediction(self): + """Hal Finney's $10M prediction handled correctly.""" + bitcoin_max = 100000 + predictions = [ + ("1", "Normal", 100000), + ("0", "Hal Finney", 10000000), # diff: 9900000 + ] + sorted_preds = sorted( + predictions, + key=lambda x: abs(x[2] - bitcoin_max) + ) + assert sorted_preds[0][1] == "Normal" + + def test_negative_prediction_handled(self): + """Negative predictions shouldn't crash sorting.""" + bitcoin_max = 100000 + predictions = [ + ("1", "Negative", -1000), + ("2", "Normal", 100000), + ] + sorted_preds = sorted( + predictions, + key=lambda x: abs(x[2] - bitcoin_max) + ) + assert sorted_preds[0][1] == "Normal" + + def test_json_file_missing(self, prodillos_file): + """Reading non-existent file returns empty dict.""" + import os + assert not os.path.exists(prodillos_file) + # This is what the TS loadValues does on error + data = {} + assert data == {}