diff options
Diffstat (limited to 'tests/models/thl/test_contest')
| -rw-r--r-- | tests/models/thl/test_contest/__init__.py | 0 | ||||
| -rw-r--r-- | tests/models/thl/test_contest/test_contest.py | 23 | ||||
| -rw-r--r-- | tests/models/thl/test_contest/test_leaderboard_contest.py | 213 | ||||
| -rw-r--r-- | tests/models/thl/test_contest/test_raffle_contest.py | 300 |
4 files changed, 536 insertions, 0 deletions
diff --git a/tests/models/thl/test_contest/__init__.py b/tests/models/thl/test_contest/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/models/thl/test_contest/__init__.py diff --git a/tests/models/thl/test_contest/test_contest.py b/tests/models/thl/test_contest/test_contest.py new file mode 100644 index 0000000..d53eee5 --- /dev/null +++ b/tests/models/thl/test_contest/test_contest.py @@ -0,0 +1,23 @@ +import pytest +from generalresearch.models.thl.user import User + + +class TestContest: + """In many of the Contest related tests, we often want a consistent + Product throughout, and multiple different users that may be + involved in the Contest... so redefine the product fixture along with + some users in here that are scoped="class" so they stay around for + each of the test functions + """ + + @pytest.fixture(scope="function") + def user_1(self, user_factory, product) -> User: + return user_factory(product=product) + + @pytest.fixture(scope="function") + def user_2(self, user_factory, product) -> User: + return user_factory(product=product) + + @pytest.fixture(scope="function") + def user_3(self, user_factory, product) -> User: + return user_factory(product=product) diff --git a/tests/models/thl/test_contest/test_leaderboard_contest.py b/tests/models/thl/test_contest/test_leaderboard_contest.py new file mode 100644 index 0000000..98f3215 --- /dev/null +++ b/tests/models/thl/test_contest/test_leaderboard_contest.py @@ -0,0 +1,213 @@ +from datetime import timezone +from uuid import uuid4 + +import pytest + +from generalresearch.currency import USDCent +from generalresearch.managers.leaderboard.manager import LeaderboardManager +from generalresearch.models.thl.contest import ContestPrize +from generalresearch.models.thl.contest.definitions import ( + ContestType, + ContestPrizeKind, +) +from generalresearch.models.thl.contest.leaderboard import ( + LeaderboardContest, +) +from generalresearch.models.thl.contest.utils import ( + distribute_leaderboard_prizes, +) +from generalresearch.models.thl.leaderboard import LeaderboardRow +from tests.models.thl.test_contest.test_contest import TestContest + + +class TestLeaderboardContest(TestContest): + + @pytest.fixture + def leaderboard_contest( + self, product, thl_redis, user_manager + ) -> "LeaderboardContest": + board_key = f"leaderboard:{product.uuid}:us:weekly:2025-05-26:complete_count" + + c = LeaderboardContest( + uuid=uuid4().hex, + product_id=product.uuid, + contest_type=ContestType.LEADERBOARD, + leaderboard_key=board_key, + name="$15 1st place, $10 2nd, $5 3rd place US weekly", + prizes=[ + ContestPrize( + name="$15 Cash", + estimated_cash_value=USDCent(15_00), + cash_amount=USDCent(15_00), + kind=ContestPrizeKind.CASH, + leaderboard_rank=1, + ), + ContestPrize( + name="$10 Cash", + estimated_cash_value=USDCent(10_00), + cash_amount=USDCent(10_00), + kind=ContestPrizeKind.CASH, + leaderboard_rank=2, + ), + ContestPrize( + name="$5 Cash", + estimated_cash_value=USDCent(5_00), + cash_amount=USDCent(5_00), + kind=ContestPrizeKind.CASH, + leaderboard_rank=3, + ), + ], + ) + c._redis_client = thl_redis + c._user_manager = user_manager + return c + + def test_init(self, leaderboard_contest, thl_redis, user_1, user_2): + model = leaderboard_contest.leaderboard_model + assert leaderboard_contest.end_condition.ends_at is not None + + lbm = LeaderboardManager( + redis_client=thl_redis, + board_code=model.board_code, + country_iso=model.country_iso, + freq=model.freq, + product_id=leaderboard_contest.product_id, + within_time=model.period_start_local, + ) + + lbm.hit_complete_count(product_user_id=user_1.product_user_id) + lbm.hit_complete_count(product_user_id=user_2.product_user_id) + lbm.hit_complete_count(product_user_id=user_2.product_user_id) + + lb = leaderboard_contest.get_leaderboard() + print(lb) + + def test_win(self, leaderboard_contest, thl_redis, user_1, user_2, user_3): + model = leaderboard_contest.leaderboard_model + lbm = LeaderboardManager( + redis_client=thl_redis, + board_code=model.board_code, + country_iso=model.country_iso, + freq=model.freq, + product_id=leaderboard_contest.product_id, + within_time=model.period_start_local.astimezone(tz=timezone.utc), + ) + + lbm.hit_complete_count(product_user_id=user_1.product_user_id) + lbm.hit_complete_count(product_user_id=user_1.product_user_id) + + lbm.hit_complete_count(product_user_id=user_2.product_user_id) + + lbm.hit_complete_count(product_user_id=user_3.product_user_id) + + leaderboard_contest.end_contest() + assert len(leaderboard_contest.all_winners) == 3 + + # Prizes are $15, $10, $5. user 2 and 3 ties for 2nd place, so they split (10 + 5) + assert leaderboard_contest.all_winners[0].awarded_cash_amount == USDCent(15_00) + assert ( + leaderboard_contest.all_winners[0].user.product_user_id + == user_1.product_user_id + ) + assert leaderboard_contest.all_winners[0].prize == leaderboard_contest.prizes[0] + assert leaderboard_contest.all_winners[1].awarded_cash_amount == USDCent( + 15_00 / 2 + ) + assert leaderboard_contest.all_winners[2].awarded_cash_amount == USDCent( + 15_00 / 2 + ) + + +class TestLeaderboardContestPrizes: + + def test_distribute_prizes_1(self): + prizes = [USDCent(15_00)] + leaderboard_rows = [ + LeaderboardRow(bpuid="a", value=20, rank=1), + LeaderboardRow(bpuid="b", value=10, rank=2), + ] + result = distribute_leaderboard_prizes(prizes, leaderboard_rows) + + # a gets first prize, b gets nothing. + assert result == { + "a": USDCent(15_00), + } + + def test_distribute_prizes_2(self): + prizes = [USDCent(15_00), USDCent(10_00)] + leaderboard_rows = [ + LeaderboardRow(bpuid="a", value=20, rank=1), + LeaderboardRow(bpuid="b", value=10, rank=2), + ] + result = distribute_leaderboard_prizes(prizes, leaderboard_rows) + + # a gets first prize, b gets 2nd prize + assert result == { + "a": USDCent(15_00), + "b": USDCent(10_00), + } + + def test_distribute_prizes_3(self): + prizes = [USDCent(15_00), USDCent(10_00)] + leaderboard_rows = [ + LeaderboardRow(bpuid="a", value=20, rank=1), + ] + result = distribute_leaderboard_prizes(prizes, leaderboard_rows) + + # A gets first prize, no-one gets $10 + assert result == { + "a": USDCent(15_00), + } + + def test_distribute_prizes_4(self): + prizes = [USDCent(15_00)] + leaderboard_rows = [ + LeaderboardRow(bpuid="a", value=20, rank=1), + LeaderboardRow(bpuid="b", value=20, rank=1), + LeaderboardRow(bpuid="c", value=20, rank=1), + LeaderboardRow(bpuid="d", value=20, rank=1), + ] + result = distribute_leaderboard_prizes(prizes, leaderboard_rows) + + # 4-way tie for the $15 prize; it gets split + assert result == { + "a": USDCent(3_75), + "b": USDCent(3_75), + "c": USDCent(3_75), + "d": USDCent(3_75), + } + + def test_distribute_prizes_5(self): + prizes = [USDCent(15_00), USDCent(10_00)] + leaderboard_rows = [ + LeaderboardRow(bpuid="a", value=20, rank=1), + LeaderboardRow(bpuid="b", value=20, rank=1), + LeaderboardRow(bpuid="c", value=10, rank=3), + ] + result = distribute_leaderboard_prizes(prizes, leaderboard_rows) + + # 2-way tie for the $15 prize; the top two prizes get split. Rank 3 + # and below get nothing + assert result == { + "a": USDCent(12_50), + "b": USDCent(12_50), + } + + def test_distribute_prizes_6(self): + prizes = [USDCent(15_00), USDCent(10_00), USDCent(5_00)] + leaderboard_rows = [ + LeaderboardRow(bpuid="a", value=20, rank=1), + LeaderboardRow(bpuid="b", value=10, rank=2), + LeaderboardRow(bpuid="c", value=10, rank=2), + LeaderboardRow(bpuid="d", value=10, rank=2), + ] + result = distribute_leaderboard_prizes(prizes, leaderboard_rows) + + # A gets first prize, 3 way tie for 2nd rank: they split the 2nd and + # 3rd place prizes (10 + 5)/3 + assert result == { + "a": USDCent(15_00), + "b": USDCent(5_00), + "c": USDCent(5_00), + "d": USDCent(5_00), + } diff --git a/tests/models/thl/test_contest/test_raffle_contest.py b/tests/models/thl/test_contest/test_raffle_contest.py new file mode 100644 index 0000000..e1c0a15 --- /dev/null +++ b/tests/models/thl/test_contest/test_raffle_contest.py @@ -0,0 +1,300 @@ +from collections import Counter +from uuid import uuid4 + +import pytest +from pytest import approx + +from generalresearch.currency import USDCent +from generalresearch.models.thl.contest import ( + ContestPrize, + ContestEndCondition, +) +from generalresearch.models.thl.contest.contest_entry import ContestEntry +from generalresearch.models.thl.contest.definitions import ( + ContestEntryType, + ContestPrizeKind, + ContestType, + ContestStatus, + ContestEndReason, +) +from generalresearch.models.thl.contest.raffle import RaffleContest + +from tests.models.thl.test_contest.test_contest import TestContest + + +class TestRaffleContest(TestContest): + + @pytest.fixture(scope="function") + def raffle_contest(self, product) -> RaffleContest: + return RaffleContest( + product_id=product.uuid, + name=f"Raffle Contest {uuid4().hex}", + contest_type=ContestType.RAFFLE, + entry_type=ContestEntryType.CASH, + prizes=[ + ContestPrize( + name="iPod 64GB White", + kind=ContestPrizeKind.PHYSICAL, + estimated_cash_value=USDCent(100_00), + ) + ], + end_condition=ContestEndCondition(target_entry_amount=100), + ) + + @pytest.fixture(scope="function") + def ended_raffle_contest(self, raffle_contest, utc_now) -> RaffleContest: + # Fake ending the contest + raffle_contest = raffle_contest.model_copy() + raffle_contest.update( + status=ContestStatus.COMPLETED, + ended_at=utc_now, + end_reason=ContestEndReason.ENDS_AT, + ) + return raffle_contest + + +class TestRaffleContestUserView(TestRaffleContest): + + def test_user_view(self, raffle_contest, user): + from generalresearch.models.thl.contest.raffle import RaffleUserView + + data = { + "current_amount": USDCent(1_00), + "product_user_id": user.product_user_id, + "user_amount": USDCent(1), + "user_amount_today": USDCent(1), + } + r = RaffleUserView.model_validate(raffle_contest.model_dump() | data) + res = r.model_dump(mode="json") + + assert res["product_user_id"] == user.product_user_id + assert res["user_amount_today"] == 1 + assert res["current_win_probability"] == approx(0.01, rel=0.000001) + assert res["projected_win_probability"] == approx(0.01, rel=0.000001) + + # Now change the amount + r.current_amount = USDCent(1_01) + res = r.model_dump(mode="json") + assert res["current_win_probability"] == approx(0.0099, rel=0.001) + assert res["projected_win_probability"] == approx(0.0099, rel=0.001) + + def test_win_pct(self, raffle_contest, user): + from generalresearch.models.thl.contest.raffle import RaffleUserView + + data = { + "current_amount": USDCent(10), + "product_user_id": user.product_user_id, + "user_amount": USDCent(1), + "user_amount_today": USDCent(1), + } + r = RaffleUserView.model_validate(raffle_contest.model_dump() | data) + r.prizes = [ + ContestPrize( + name="iPod 64GB White", + kind=ContestPrizeKind.PHYSICAL, + estimated_cash_value=USDCent(100_00), + ), + ContestPrize( + name="iPod 64GB White", + kind=ContestPrizeKind.PHYSICAL, + estimated_cash_value=USDCent(100_00), + ), + ] + # Raffle has 10 entries, user has 1 entry. + # There are 2 prizes. + assert r.current_win_probability == approx(expected=0.2, rel=0.01) + # He can only possibly win 1 prize + assert r.current_prize_count_probability[1] == approx(expected=0.2, rel=0.01) + # He has a 0 prob of winning 2 prizes + assert r.current_prize_count_probability[2] == 0 + # Contest end when there are 100 entries, so 1/100 * 2 prizes + assert r.projected_win_probability == approx(expected=0.02, rel=0.01) + + # Change to user having 2 entries (out of 10) + # Still with 2 prizes + r.user_amount = USDCent(2) + assert r.current_win_probability == approx(expected=0.3777, rel=0.01) + # 2/10 chance of winning 1st, 8/9 change of not winning 2nd, plus the + # same in the other order + p = (2 / 10) * (8 / 9) * 2 # 0.355555 + assert r.current_prize_count_probability[1] == approx(p, rel=0.01) + p = (2 / 10) * (1 / 9) # 0.02222 + assert r.current_prize_count_probability[2] == approx(p, rel=0.01) + + +class TestRaffleContestWinners(TestRaffleContest): + + def test_winners_1_prize(self, ended_raffle_contest, user_1, user_2, user_3): + ended_raffle_contest.entries = [ + ContestEntry( + user=user_1, + amount=USDCent(1), + entry_type=ContestEntryType.CASH, + ), + ContestEntry( + user=user_2, + amount=USDCent(2), + entry_type=ContestEntryType.CASH, + ), + ContestEntry( + user=user_3, + amount=USDCent(3), + entry_type=ContestEntryType.CASH, + ), + ] + + # There is 1 prize. If we select a winner 1000 times, we'd expect user 1 + # to win ~ 1/6th of the time, user 2 ~2/6th and 3 3/6th. + winners = ended_raffle_contest.select_winners() + assert len(winners) == 1 + + c = Counter( + [ + ended_raffle_contest.select_winners()[0].user.user_id + for _ in range(10000) + ] + ) + assert c[user_1.user_id] == approx( + 10000 * 1 / 6, rel=0.1 + ) # 10% relative tolerance + assert c[user_2.user_id] == approx(10000 * 2 / 6, rel=0.1) + assert c[user_3.user_id] == approx(10000 * 3 / 6, rel=0.1) + + def test_winners_2_prizes(self, ended_raffle_contest, user_1, user_2, user_3): + ended_raffle_contest.prizes.append( + ContestPrize( + name="iPod 64GB Black", + kind=ContestPrizeKind.PHYSICAL, + estimated_cash_value=USDCent(100_00), + ) + ) + ended_raffle_contest.entries = [ + ContestEntry( + user=user_3, + amount=USDCent(1), + entry_type=ContestEntryType.CASH, + ), + ContestEntry( + user=user_1, + amount=USDCent(9999999), + entry_type=ContestEntryType.CASH, + ), + ContestEntry( + user=user_2, + amount=USDCent(1), + entry_type=ContestEntryType.CASH, + ), + ] + # In this scenario, user 1 should win both prizes + winners = ended_raffle_contest.select_winners() + assert len(winners) == 2 + # Two different prizes + assert len({w.prize.name for w in winners}) == 2 + # Same user + assert all(w.user.user_id == user_1.user_id for w in winners) + + def test_winners_2_prizes_1_entry(self, ended_raffle_contest, user_3): + ended_raffle_contest.prizes = [ + ContestPrize( + name="iPod 64GB White", + kind=ContestPrizeKind.PHYSICAL, + estimated_cash_value=USDCent(100_00), + ), + ContestPrize( + name="iPod 64GB Black", + kind=ContestPrizeKind.PHYSICAL, + estimated_cash_value=USDCent(100_00), + ), + ] + ended_raffle_contest.entries = [ + ContestEntry( + user=user_3, + amount=USDCent(1), + entry_type=ContestEntryType.CASH, + ), + ] + + # One prize goes unclaimed + winners = ended_raffle_contest.select_winners() + assert len(winners) == 1 + + def test_winners_2_prizes_1_entry_2_pennies(self, ended_raffle_contest, user_3): + ended_raffle_contest.prizes = [ + ContestPrize( + name="iPod 64GB White", + kind=ContestPrizeKind.PHYSICAL, + estimated_cash_value=USDCent(100_00), + ), + ContestPrize( + name="iPod 64GB Black", + kind=ContestPrizeKind.PHYSICAL, + estimated_cash_value=USDCent(100_00), + ), + ] + ended_raffle_contest.entries = [ + ContestEntry( + user=user_3, + amount=USDCent(2), + entry_type=ContestEntryType.CASH, + ), + ] + # User wins both prizes + winners = ended_raffle_contest.select_winners() + assert len(winners) == 2 + + def test_winners_3_prizes_3_entries( + self, ended_raffle_contest, product, user_1, user_2, user_3 + ): + ended_raffle_contest.prizes = [ + ContestPrize( + name="iPod 64GB White", + kind=ContestPrizeKind.PHYSICAL, + estimated_cash_value=USDCent(100_00), + ), + ContestPrize( + name="iPod 64GB Black", + kind=ContestPrizeKind.PHYSICAL, + estimated_cash_value=USDCent(100_00), + ), + ContestPrize( + name="iPod 64GB Red", + kind=ContestPrizeKind.PHYSICAL, + estimated_cash_value=USDCent(100_00), + ), + ] + ended_raffle_contest.entries = [ + ContestEntry( + user=user_1, + amount=USDCent(1), + entry_type=ContestEntryType.CASH, + ), + ContestEntry( + user=user_2, + amount=USDCent(2), + entry_type=ContestEntryType.CASH, + ), + ContestEntry( + user=user_3, + amount=USDCent(3), + entry_type=ContestEntryType.CASH, + ), + ] + + winners = ended_raffle_contest.select_winners() + assert len(winners) == 3 + + winners = [ended_raffle_contest.select_winners() for _ in range(10000)] + + # There's 3 winners, the 1st should follow the same percentages + c = Counter([w[0].user.user_id for w in winners]) + + assert c[user_1.user_id] == approx(10000 * 1 / 6, rel=0.1) + assert c[user_2.user_id] == approx(10000 * 2 / 6, rel=0.1) + assert c[user_3.user_id] == approx(10000 * 3 / 6, rel=0.1) + + # Assume the 1st user won + ended_raffle_contest.entries.pop(0) + winners = [ended_raffle_contest.select_winners() for _ in range(10000)] + c = Counter([w[0].user.user_id for w in winners]) + assert c[user_2.user_id] == approx(10000 * 2 / 5, rel=0.1) + assert c[user_3.user_id] == approx(10000 * 3 / 5, rel=0.1) |
