MIL-008: smarter purchase rule #49
@@ -102,6 +102,7 @@ The bot plays for 5 minutes, then prints one line on standard output, for exampl
|
||||
| `--duration SECONDS` | How long to play | 300 |
|
||||
| `--interval SECONDS` | Time between upgrade checks (greater than 0) | 5 |
|
||||
| `--buy-all` | Buy every affordable upgrade, most expensive first, instead of one | off |
|
||||
| `--best-value` | Buy the affordable upgrade with the lowest price per cookie per second (not together with `--buy-all`) | off |
|
||||
| `--headless` | Run Chromium without a window | off (on in Docker) |
|
||||
| `--screenshot PATH` | Save a screenshot when the run ends | none |
|
||||
| `--screenshot-start PATH` | Save a screenshot before the first click | none |
|
||||
|
||||
@@ -1,96 +1,116 @@
|
||||
"""@file game_loop.py
|
||||
@brief The game loop of the bot, written against the game port.
|
||||
"""
|
||||
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from cookie_clicker_bot.application.ports import GamePort
|
||||
from cookie_clicker_bot.domain.buy_strategy import BuyStrategy
|
||||
from cookie_clicker_bot.domain.upgrades import (
|
||||
Upgrade,
|
||||
choose_affordable_upgrades,
|
||||
choose_most_expensive_affordable,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PlayResult:
|
||||
"""@brief What a run did."""
|
||||
|
||||
## Number of clicks on the big cookie.
|
||||
clicks: int
|
||||
## Names of the upgrades bought, in the order they were bought.
|
||||
purchases: tuple[str, ...]
|
||||
## Seconds the run lasted, measured with the clock given to `click_and_buy`.
|
||||
elapsed_seconds: float
|
||||
|
||||
|
||||
def click_cookie(game: GamePort) -> None:
|
||||
"""@brief Click the big cookie once (lecture step 2).
|
||||
|
||||
@param game The game to play.
|
||||
"""
|
||||
game.click_cookie()
|
||||
|
||||
|
||||
def buy_most_expensive_upgrade(game: GamePort) -> Upgrade | None:
|
||||
"""@brief Buy the most expensive upgrade the player can afford (lecture step 3).
|
||||
|
||||
@param game The game to play.
|
||||
@return The upgrade that was bought, or None when nothing was affordable or
|
||||
the click did not reach the product.
|
||||
"""
|
||||
chosen = choose_most_expensive_affordable(game.upgrades(), game.cookie_count())
|
||||
if chosen is not None and game.buy(chosen):
|
||||
return chosen
|
||||
return None
|
||||
|
||||
|
||||
def buy_upgrades(game: GamePort, strategy: BuyStrategy) -> list[Upgrade]:
|
||||
"""@brief Do one purchase round with the given strategy (lecture step 5).
|
||||
|
||||
@param game The game to play.
|
||||
@param strategy BUY_ONE buys the most expensive affordable upgrade, BUY_ALL buys
|
||||
every upgrade the cookies pay for.
|
||||
@return The upgrades really bought, in order; a click that missed is left out.
|
||||
"""
|
||||
if strategy is BuyStrategy.BUY_ONE:
|
||||
bought = buy_most_expensive_upgrade(game)
|
||||
return [] if bought is None else [bought]
|
||||
plan = choose_affordable_upgrades(game.upgrades(), game.cookie_count())
|
||||
return [upgrade for upgrade in plan if game.buy(upgrade)]
|
||||
|
||||
|
||||
def click_and_buy(
|
||||
game: GamePort,
|
||||
duration_seconds: float,
|
||||
check_interval_seconds: float,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
strategy: BuyStrategy = BuyStrategy.BUY_ONE,
|
||||
) -> PlayResult:
|
||||
"""@brief Click the cookie nonstop and check the upgrades at every interval.
|
||||
|
||||
@param game The game to play.
|
||||
@param duration_seconds How long to play.
|
||||
@param check_interval_seconds Time between two upgrade checks; the first check
|
||||
comes one interval after the start.
|
||||
@param clock Monotonic clock in seconds; a test passes a fake one.
|
||||
@param strategy What to buy at each check.
|
||||
@return The clicks, the names of the upgrades bought in order, and the time spent.
|
||||
"""
|
||||
start = clock()
|
||||
deadline = start + duration_seconds
|
||||
next_check = start + check_interval_seconds
|
||||
clicks = 0
|
||||
purchases: list[str] = []
|
||||
while (now := clock()) < deadline:
|
||||
click_cookie(game)
|
||||
clicks += 1
|
||||
if now >= next_check:
|
||||
next_check = now + check_interval_seconds
|
||||
purchases.extend(upgrade.name for upgrade in buy_upgrades(game, strategy))
|
||||
return PlayResult(
|
||||
clicks=clicks, purchases=tuple(purchases), elapsed_seconds=clock() - start
|
||||
)
|
||||
"""@file game_loop.py
|
||||
@brief The game loop of the bot, written against the game port.
|
||||
"""
|
||||
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from cookie_clicker_bot.application.ports import GamePort
|
||||
from cookie_clicker_bot.domain.buy_strategy import BuyStrategy
|
||||
from cookie_clicker_bot.domain.upgrades import (
|
||||
Upgrade,
|
||||
choose_affordable_upgrades,
|
||||
choose_best_value,
|
||||
choose_most_expensive_affordable,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PlayResult:
|
||||
"""@brief What a run did."""
|
||||
|
||||
## Number of clicks on the big cookie.
|
||||
clicks: int
|
||||
## Names of the upgrades bought, in the order they were bought.
|
||||
purchases: tuple[str, ...]
|
||||
## Seconds the run lasted, measured with the clock given to `click_and_buy`.
|
||||
elapsed_seconds: float
|
||||
|
||||
|
||||
def click_cookie(game: GamePort) -> None:
|
||||
"""@brief Click the big cookie once (lecture step 2).
|
||||
|
||||
@param game The game to play.
|
||||
"""
|
||||
game.click_cookie()
|
||||
|
||||
|
||||
def buy_most_expensive_upgrade(game: GamePort) -> Upgrade | None:
|
||||
"""@brief Buy the most expensive upgrade the player can afford (lecture step 3).
|
||||
|
||||
@param game The game to play.
|
||||
@return The upgrade that was bought, or None when nothing was affordable or
|
||||
the click did not reach the product.
|
||||
"""
|
||||
chosen = choose_most_expensive_affordable(game.upgrades(), game.cookie_count())
|
||||
if chosen is not None and game.buy(chosen):
|
||||
return chosen
|
||||
return None
|
||||
|
||||
|
||||
def buy_best_value_upgrade(game: GamePort) -> Upgrade | None:
|
||||
"""@brief Buy the affordable upgrade with the lowest price per cookie per second.
|
||||
|
||||
@param game The game to play.
|
||||
@return The upgrade that was bought, or None when nothing was affordable or
|
||||
the click did not reach the product.
|
||||
"""
|
||||
chosen = choose_best_value(game.upgrades(), game.cookie_count())
|
||||
if chosen is not None and game.buy(chosen):
|
||||
return chosen
|
||||
return None
|
||||
|
||||
|
||||
def buy_upgrades(game: GamePort, strategy: BuyStrategy) -> list[Upgrade]:
|
||||
"""@brief Do one purchase round with the given strategy (lecture step 5).
|
||||
|
||||
@param game The game to play.
|
||||
@param strategy BUY_ONE buys the most expensive affordable upgrade, BUY_ALL buys
|
||||
every upgrade the cookies pay for, BEST_VALUE buys the one with the lowest
|
||||
price per cookie per second.
|
||||
@return The upgrades really bought, in order; a click that missed is left out.
|
||||
"""
|
||||
if strategy is not BuyStrategy.BUY_ALL:
|
||||
buy_one = (
|
||||
buy_best_value_upgrade
|
||||
if strategy is BuyStrategy.BEST_VALUE
|
||||
else buy_most_expensive_upgrade
|
||||
)
|
||||
bought = buy_one(game)
|
||||
return [] if bought is None else [bought]
|
||||
plan = choose_affordable_upgrades(game.upgrades(), game.cookie_count())
|
||||
return [upgrade for upgrade in plan if game.buy(upgrade)]
|
||||
|
||||
|
||||
def click_and_buy(
|
||||
game: GamePort,
|
||||
duration_seconds: float,
|
||||
check_interval_seconds: float,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
strategy: BuyStrategy = BuyStrategy.BUY_ONE,
|
||||
) -> PlayResult:
|
||||
"""@brief Click the cookie nonstop and check the upgrades at every interval.
|
||||
|
||||
@param game The game to play.
|
||||
@param duration_seconds How long to play.
|
||||
@param check_interval_seconds Time between two upgrade checks; the first check
|
||||
comes one interval after the start.
|
||||
@param clock Monotonic clock in seconds; a test passes a fake one.
|
||||
@param strategy What to buy at each check.
|
||||
@return The clicks, the names of the upgrades bought in order, and the time spent.
|
||||
"""
|
||||
start = clock()
|
||||
deadline = start + duration_seconds
|
||||
next_check = start + check_interval_seconds
|
||||
clicks = 0
|
||||
purchases: list[str] = []
|
||||
while (now := clock()) < deadline:
|
||||
click_cookie(game)
|
||||
clicks += 1
|
||||
if now >= next_check:
|
||||
next_check = now + check_interval_seconds
|
||||
purchases.extend(upgrade.name for upgrade in buy_upgrades(game, strategy))
|
||||
return PlayResult(
|
||||
clicks=clicks, purchases=tuple(purchases), elapsed_seconds=clock() - start
|
||||
)
|
||||
|
||||
+161
-139
@@ -1,139 +1,161 @@
|
||||
"""@file cli.py
|
||||
@brief Command-line entry point (the composition root of the bot).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
from collections.abc import Callable, Sequence
|
||||
from pathlib import Path
|
||||
|
||||
from cookie_clicker_bot import constants
|
||||
from cookie_clicker_bot.application.game_loop import click_and_buy
|
||||
from cookie_clicker_bot.application.ports import GamePort
|
||||
from cookie_clicker_bot.application.report import format_result
|
||||
from cookie_clicker_bot.domain.buy_strategy import BuyStrategy
|
||||
from cookie_clicker_bot.infrastructure.browser import create_driver
|
||||
from cookie_clicker_bot.infrastructure.selenium_game import SeleniumGame
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_game(headless: bool) -> GamePort:
|
||||
"""@brief Build the Selenium game.
|
||||
|
||||
@param headless True to run Chromium without a window.
|
||||
@return The game, ready to open.
|
||||
"""
|
||||
return SeleniumGame(create_driver(headless))
|
||||
|
||||
|
||||
def positive_float(text: str) -> float:
|
||||
"""@brief Read a number greater than zero from the command line.
|
||||
|
||||
@param text The argument text.
|
||||
@return The number.
|
||||
@throws argparse.ArgumentTypeError When the text is not a number above zero.
|
||||
"""
|
||||
message = constants.POSITIVE_NUMBER_ERROR_TEMPLATE.format(value=text)
|
||||
try:
|
||||
value = float(text)
|
||||
except ValueError:
|
||||
raise argparse.ArgumentTypeError(message) from None
|
||||
if not value > 0:
|
||||
raise argparse.ArgumentTypeError(message)
|
||||
return value
|
||||
|
||||
|
||||
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
||||
"""@brief Read the command line.
|
||||
|
||||
@param argv Arguments without the program name; None reads `sys.argv`.
|
||||
@return The parsed options.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="cookie-clicker-bot",
|
||||
description=(
|
||||
"Plays Cookie Clicker: clicks the big cookie as fast as it can "
|
||||
"and buys upgrades."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--duration",
|
||||
type=float,
|
||||
default=constants.RUN_DURATION_SECONDS,
|
||||
help="seconds to play (default: %(default)s)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--interval",
|
||||
type=positive_float,
|
||||
default=constants.UPGRADE_CHECK_INTERVAL_SECONDS,
|
||||
help="seconds between upgrade checks, greater than 0 (default: %(default)s)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--buy-all",
|
||||
action="store_true",
|
||||
help=(
|
||||
"at each check buy every affordable upgrade, most expensive first "
|
||||
"(default: buy only the most expensive affordable one)"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--headless",
|
||||
action="store_true",
|
||||
help="run Chromium without a window (the Docker image does this)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--screenshot-start",
|
||||
type=Path,
|
||||
help="write a screenshot of the game before the first click",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--screenshot",
|
||||
type=Path,
|
||||
help="write a screenshot of the game when the run ends",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(
|
||||
argv: Sequence[str] | None = None,
|
||||
game_factory: Callable[[bool], GamePort] = create_game,
|
||||
) -> int:
|
||||
"""@brief Start the bot.
|
||||
|
||||
@param argv Arguments without the program name; None reads `sys.argv`.
|
||||
@param game_factory Builds the game; a test passes a fake.
|
||||
@return Process exit code, 0 when the run finished.
|
||||
"""
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
args = parse_args(argv)
|
||||
game = game_factory(args.headless)
|
||||
try:
|
||||
game.open_game()
|
||||
game.dismiss_language()
|
||||
if args.screenshot_start is not None:
|
||||
game.save_screenshot(args.screenshot_start)
|
||||
strategy = BuyStrategy.BUY_ALL if args.buy_all else BuyStrategy.BUY_ONE
|
||||
logger.info(
|
||||
"checking upgrades every %s seconds, strategy %s",
|
||||
args.interval,
|
||||
strategy.value,
|
||||
)
|
||||
result = click_and_buy(game, args.duration, args.interval, strategy=strategy)
|
||||
for name in result.purchases:
|
||||
logger.info("bought %s", name)
|
||||
logger.info(
|
||||
"clicked the cookie %d times and bought %d upgrades in %.1f seconds",
|
||||
result.clicks,
|
||||
len(result.purchases),
|
||||
result.elapsed_seconds,
|
||||
)
|
||||
cookies_per_second = game.cookies_per_second()
|
||||
logger.info("%s", format_result(cookies_per_second))
|
||||
if args.screenshot is not None:
|
||||
game.save_screenshot(args.screenshot)
|
||||
finally:
|
||||
game.close()
|
||||
# The program's required result: the only line written to stdout (logs use stderr).
|
||||
print(format_result(cookies_per_second))
|
||||
return 0
|
||||
"""@file cli.py
|
||||
@brief Command-line entry point (the composition root of the bot).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
from collections.abc import Callable, Sequence
|
||||
from pathlib import Path
|
||||
|
||||
from cookie_clicker_bot import constants
|
||||
from cookie_clicker_bot.application.game_loop import click_and_buy
|
||||
from cookie_clicker_bot.application.ports import GamePort
|
||||
from cookie_clicker_bot.application.report import format_result
|
||||
from cookie_clicker_bot.domain.buy_strategy import BuyStrategy
|
||||
from cookie_clicker_bot.infrastructure.browser import create_driver
|
||||
from cookie_clicker_bot.infrastructure.selenium_game import SeleniumGame
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_game(headless: bool) -> GamePort:
|
||||
"""@brief Build the Selenium game.
|
||||
|
||||
@param headless True to run Chromium without a window.
|
||||
@return The game, ready to open.
|
||||
"""
|
||||
return SeleniumGame(create_driver(headless))
|
||||
|
||||
|
||||
def positive_float(text: str) -> float:
|
||||
"""@brief Read a number greater than zero from the command line.
|
||||
|
||||
@param text The argument text.
|
||||
@return The number.
|
||||
@throws argparse.ArgumentTypeError When the text is not a number above zero.
|
||||
"""
|
||||
message = constants.POSITIVE_NUMBER_ERROR_TEMPLATE.format(value=text)
|
||||
try:
|
||||
value = float(text)
|
||||
except ValueError:
|
||||
raise argparse.ArgumentTypeError(message) from None
|
||||
if not value > 0:
|
||||
raise argparse.ArgumentTypeError(message)
|
||||
return value
|
||||
|
||||
|
||||
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
||||
"""@brief Read the command line.
|
||||
|
||||
@param argv Arguments without the program name; None reads `sys.argv`.
|
||||
@return The parsed options.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="cookie-clicker-bot",
|
||||
description=(
|
||||
"Plays Cookie Clicker: clicks the big cookie as fast as it can "
|
||||
"and buys upgrades."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--duration",
|
||||
type=float,
|
||||
default=constants.RUN_DURATION_SECONDS,
|
||||
help="seconds to play (default: %(default)s)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--interval",
|
||||
type=positive_float,
|
||||
default=constants.UPGRADE_CHECK_INTERVAL_SECONDS,
|
||||
help="seconds between upgrade checks, greater than 0 (default: %(default)s)",
|
||||
)
|
||||
choice = parser.add_mutually_exclusive_group()
|
||||
choice.add_argument(
|
||||
"--buy-all",
|
||||
action="store_true",
|
||||
help=(
|
||||
"at each check buy every affordable upgrade, most expensive first "
|
||||
"(default: buy only the most expensive affordable one)"
|
||||
),
|
||||
)
|
||||
choice.add_argument(
|
||||
"--best-value",
|
||||
action="store_true",
|
||||
help=(
|
||||
"at each check buy the affordable upgrade with the lowest price per "
|
||||
"cookie per second"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--headless",
|
||||
action="store_true",
|
||||
help="run Chromium without a window (the Docker image does this)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--screenshot-start",
|
||||
type=Path,
|
||||
help="write a screenshot of the game before the first click",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--screenshot",
|
||||
type=Path,
|
||||
help="write a screenshot of the game when the run ends",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def choose_strategy(args: argparse.Namespace) -> BuyStrategy:
|
||||
"""@brief Turn the purchase options into a strategy.
|
||||
|
||||
@param args The parsed options.
|
||||
@return BUY_ALL, BEST_VALUE or, by default, BUY_ONE.
|
||||
"""
|
||||
if args.buy_all:
|
||||
return BuyStrategy.BUY_ALL
|
||||
if args.best_value:
|
||||
return BuyStrategy.BEST_VALUE
|
||||
return BuyStrategy.BUY_ONE
|
||||
|
||||
|
||||
def main(
|
||||
argv: Sequence[str] | None = None,
|
||||
game_factory: Callable[[bool], GamePort] = create_game,
|
||||
) -> int:
|
||||
"""@brief Start the bot.
|
||||
|
||||
@param argv Arguments without the program name; None reads `sys.argv`.
|
||||
@param game_factory Builds the game; a test passes a fake.
|
||||
@return Process exit code, 0 when the run finished.
|
||||
"""
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
args = parse_args(argv)
|
||||
game = game_factory(args.headless)
|
||||
try:
|
||||
game.open_game()
|
||||
game.dismiss_language()
|
||||
if args.screenshot_start is not None:
|
||||
game.save_screenshot(args.screenshot_start)
|
||||
strategy = choose_strategy(args)
|
||||
logger.info(
|
||||
"checking upgrades every %s seconds, strategy %s",
|
||||
args.interval,
|
||||
strategy.value,
|
||||
)
|
||||
result = click_and_buy(game, args.duration, args.interval, strategy=strategy)
|
||||
for name in result.purchases:
|
||||
logger.info("bought %s", name)
|
||||
logger.info(
|
||||
"clicked the cookie %d times and bought %d upgrades in %.1f seconds",
|
||||
result.clicks,
|
||||
len(result.purchases),
|
||||
result.elapsed_seconds,
|
||||
)
|
||||
cookies_per_second = game.cookies_per_second()
|
||||
logger.info("%s", format_result(cookies_per_second))
|
||||
if args.screenshot is not None:
|
||||
game.save_screenshot(args.screenshot)
|
||||
finally:
|
||||
game.close()
|
||||
# The program's required result: the only line written to stdout (logs use stderr).
|
||||
print(format_result(cookies_per_second))
|
||||
return 0
|
||||
|
||||
@@ -90,6 +90,12 @@ WINDOW_SIZE: Final[str] = "1366,900"
|
||||
## Script that clicks an element from inside the page, past anything that covers it.
|
||||
JS_CLICK_SCRIPT: Final[str] = "arguments[0].click();"
|
||||
|
||||
## Script that reads the cookies per second of one unit of each product, by index.
|
||||
JS_PRODUCTION_SCRIPT: Final[str] = "return Game.ObjectsById.map(o => o.storedCps);"
|
||||
|
||||
## Prefix of a product element id; the rest is the index into the script's list.
|
||||
PRODUCT_ID_PREFIX: Final[str] = "product"
|
||||
|
||||
## Chromium arguments needed to run headless inside a container.
|
||||
HEADLESS_BROWSER_ARGUMENTS: Final[tuple[str, ...]] = (
|
||||
"--headless=new",
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
"""@file buy_strategy.py
|
||||
@brief How many upgrades the bot buys at each check.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class BuyStrategy(Enum):
|
||||
"""@brief The purchase strategy of one upgrade check (lecture step 5)."""
|
||||
|
||||
## Buy only the most expensive affordable upgrade (the assignment).
|
||||
BUY_ONE = "buy-one"
|
||||
## Buy every upgrade the cookies pay for, most expensive first.
|
||||
BUY_ALL = "buy-all"
|
||||
"""@file buy_strategy.py
|
||||
@brief How many upgrades the bot buys at each check.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class BuyStrategy(Enum):
|
||||
"""@brief The purchase strategy of one upgrade check (lecture step 5)."""
|
||||
|
||||
## Buy only the most expensive affordable upgrade (the assignment).
|
||||
BUY_ONE = "buy-one"
|
||||
## Buy every upgrade the cookies pay for, most expensive first.
|
||||
BUY_ALL = "buy-all"
|
||||
## Buy the affordable upgrade with the lowest price per cookie per second.
|
||||
BEST_VALUE = "best-value"
|
||||
|
||||
@@ -1,52 +1,81 @@
|
||||
"""@file upgrades.py
|
||||
@brief The rule for which upgrade to buy.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Upgrade:
|
||||
"""@brief A product of the right-hand pane that can be bought."""
|
||||
|
||||
## Id of the product element on the page (product0 .. product19).
|
||||
key: str
|
||||
## Name shown in the store, for example Cursor.
|
||||
name: str
|
||||
## Price in cookies.
|
||||
price: int
|
||||
|
||||
|
||||
def choose_affordable_upgrades(
|
||||
upgrades: Sequence[Upgrade], cookies: int
|
||||
) -> list[Upgrade]:
|
||||
"""@brief Plan a buy-all round: as many upgrades as the cookies pay for.
|
||||
|
||||
Walks the upgrades from most expensive to cheapest (the list order on a tie) and
|
||||
takes each one whose price still fits in the cookies left.
|
||||
|
||||
@param upgrades The visible upgrades, in store order.
|
||||
@param cookies The cookies the player has.
|
||||
@return The upgrades to buy, most expensive first; empty when none is affordable.
|
||||
"""
|
||||
plan: list[Upgrade] = []
|
||||
remaining = cookies
|
||||
for upgrade in sorted(upgrades, key=lambda candidate: -candidate.price):
|
||||
if upgrade.price <= remaining:
|
||||
plan.append(upgrade)
|
||||
remaining -= upgrade.price
|
||||
return plan
|
||||
|
||||
|
||||
def choose_most_expensive_affordable(
|
||||
upgrades: Sequence[Upgrade], cookies: int
|
||||
) -> Upgrade | None:
|
||||
"""@brief Pick the most expensive upgrade the player can pay for.
|
||||
|
||||
@param upgrades The visible upgrades, in store order.
|
||||
@param cookies The cookies the player has.
|
||||
@return The chosen upgrade (the first on a tie), or None when none is affordable.
|
||||
"""
|
||||
plan = choose_affordable_upgrades(upgrades, cookies)
|
||||
return plan[0] if plan else None
|
||||
"""@file upgrades.py
|
||||
@brief The rule for which upgrade to buy.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Upgrade:
|
||||
"""@brief A product of the right-hand pane that can be bought."""
|
||||
|
||||
## Id of the product element on the page (product0 .. product19).
|
||||
key: str
|
||||
## Name shown in the store, for example Cursor.
|
||||
name: str
|
||||
## Price in cookies.
|
||||
price: int
|
||||
## Cookies per second one unit makes; None when the page did not tell.
|
||||
cookies_per_second: float | None = None
|
||||
|
||||
|
||||
def choose_affordable_upgrades(
|
||||
upgrades: Sequence[Upgrade], cookies: int
|
||||
) -> list[Upgrade]:
|
||||
"""@brief Plan a buy-all round: as many upgrades as the cookies pay for.
|
||||
|
||||
Walks the upgrades from most expensive to cheapest (the list order on a tie) and
|
||||
takes each one whose price still fits in the cookies left.
|
||||
|
||||
@param upgrades The visible upgrades, in store order.
|
||||
@param cookies The cookies the player has.
|
||||
@return The upgrades to buy, most expensive first; empty when none is affordable.
|
||||
"""
|
||||
plan: list[Upgrade] = []
|
||||
remaining = cookies
|
||||
for upgrade in sorted(upgrades, key=lambda candidate: -candidate.price):
|
||||
if upgrade.price <= remaining:
|
||||
plan.append(upgrade)
|
||||
remaining -= upgrade.price
|
||||
return plan
|
||||
|
||||
|
||||
def choose_best_value(upgrades: Sequence[Upgrade], cookies: int) -> Upgrade | None:
|
||||
"""@brief Pick the affordable upgrade that pays for itself fastest.
|
||||
|
||||
The value of an upgrade is its price divided by the cookies per second it makes,
|
||||
so the lowest ratio wins. Upgrades with unknown or zero production are skipped;
|
||||
when none of the affordable ones has a known production the rule falls back to
|
||||
the most expensive affordable upgrade.
|
||||
|
||||
@param upgrades The visible upgrades, in store order.
|
||||
@param cookies The cookies the player has.
|
||||
@return The chosen upgrade (the first on a tie), or None when none is affordable.
|
||||
"""
|
||||
producing = [
|
||||
upgrade
|
||||
for upgrade in upgrades
|
||||
if upgrade.price <= cookies
|
||||
and upgrade.cookies_per_second is not None
|
||||
and upgrade.cookies_per_second > 0
|
||||
]
|
||||
if not producing:
|
||||
return choose_most_expensive_affordable(upgrades, cookies)
|
||||
return min(
|
||||
producing,
|
||||
key=lambda upgrade: upgrade.price / (upgrade.cookies_per_second or 1.0),
|
||||
)
|
||||
|
||||
|
||||
def choose_most_expensive_affordable(
|
||||
upgrades: Sequence[Upgrade], cookies: int
|
||||
) -> Upgrade | None:
|
||||
"""@brief Pick the most expensive upgrade the player can pay for.
|
||||
|
||||
@param upgrades The visible upgrades, in store order.
|
||||
@param cookies The cookies the player has.
|
||||
@return The chosen upgrade (the first on a tie), or None when none is affordable.
|
||||
"""
|
||||
plan = choose_affordable_upgrades(upgrades, cookies)
|
||||
return plan[0] if plan else None
|
||||
|
||||
@@ -1,229 +1,256 @@
|
||||
"""@file selenium_game.py
|
||||
@brief Selenium adapter that implements the game port.
|
||||
"""
|
||||
|
||||
import time
|
||||
from collections.abc import Callable, Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol, TypeVar
|
||||
|
||||
from selenium.common.exceptions import (
|
||||
ElementClickInterceptedException,
|
||||
NoSuchElementException,
|
||||
StaleElementReferenceException,
|
||||
)
|
||||
from selenium.webdriver.common.by import By
|
||||
|
||||
from cookie_clicker_bot import constants
|
||||
from cookie_clicker_bot.domain.parsing import (
|
||||
parse_amount,
|
||||
parse_cookie_count,
|
||||
parse_cookies_per_second,
|
||||
)
|
||||
from cookie_clicker_bot.domain.upgrades import Upgrade
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class ElementLike(Protocol):
|
||||
"""@brief The part of a Selenium element that the adapter uses."""
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
"""@brief Visible text of the element."""
|
||||
...
|
||||
|
||||
def click(self) -> None:
|
||||
"""@brief Click the element."""
|
||||
|
||||
def is_displayed(self) -> bool:
|
||||
"""@brief True when the element is visible."""
|
||||
...
|
||||
|
||||
def get_attribute(self, name: str) -> str | None:
|
||||
"""@brief Read an attribute of the element."""
|
||||
...
|
||||
|
||||
def find_element(self, by: str, value: str) -> "ElementLike":
|
||||
"""@brief Find one element inside this element."""
|
||||
...
|
||||
|
||||
|
||||
class DriverLike(Protocol):
|
||||
"""@brief The part of a Selenium driver that the adapter uses."""
|
||||
|
||||
def get(self, url: str) -> None:
|
||||
"""@brief Load a page."""
|
||||
|
||||
def find_element(self, by: str, value: str) -> ElementLike:
|
||||
"""@brief Find one element."""
|
||||
...
|
||||
|
||||
def find_elements(self, by: str, value: str) -> Sequence[ElementLike]:
|
||||
"""@brief Find every element that matches."""
|
||||
...
|
||||
|
||||
def save_screenshot(self, filename: str) -> bool:
|
||||
"""@brief Write a PNG screenshot."""
|
||||
...
|
||||
|
||||
def execute_script(self, script: str, *args: Any) -> Any: # noqa: ANN401
|
||||
"""@brief Run JavaScript in the page; the arguments are `arguments[n]`."""
|
||||
...
|
||||
|
||||
def quit(self) -> None:
|
||||
"""@brief Close the browser."""
|
||||
|
||||
|
||||
class SeleniumGame:
|
||||
"""@brief Plays the game page through a Selenium driver."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
driver: DriverLike,
|
||||
timeout_seconds: float = constants.WAIT_TIMEOUT_SECONDS,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
sleep: Callable[[float], None] = time.sleep,
|
||||
) -> None:
|
||||
"""@brief Wrap a driver.
|
||||
|
||||
@param driver The Selenium driver (or a fake in tests).
|
||||
@param timeout_seconds Longest wait for an element.
|
||||
@param clock Monotonic clock in seconds.
|
||||
@param sleep Pause function used between polls.
|
||||
"""
|
||||
self._driver = driver
|
||||
self._timeout = timeout_seconds
|
||||
self._clock = clock
|
||||
self._sleep = sleep
|
||||
|
||||
def open_game(self) -> None:
|
||||
"""@brief Load the game page."""
|
||||
self._driver.get(constants.GAME_URL)
|
||||
|
||||
def dismiss_language(self) -> None:
|
||||
"""@brief Pick English, then wait until the big cookie can be clicked."""
|
||||
english = self._wait_for(lambda: self._visible(constants.LANGUAGE_ENGLISH_ID))
|
||||
english.click()
|
||||
self._wait_for(lambda: self._visible(constants.BIG_COOKIE_ID))
|
||||
|
||||
def click_cookie(self) -> None:
|
||||
"""@brief Click the big cookie once."""
|
||||
try:
|
||||
self._driver.find_element(By.ID, constants.BIG_COOKIE_ID).click()
|
||||
except (ElementClickInterceptedException, StaleElementReferenceException):
|
||||
# A banner or a re-render covered the cookie for a moment; try again.
|
||||
return
|
||||
|
||||
def cookie_count(self) -> int:
|
||||
"""@brief Read how many cookies the player has.
|
||||
|
||||
@return The cookie count.
|
||||
"""
|
||||
return self._read_again_if_stale(
|
||||
lambda: parse_cookie_count(
|
||||
self._driver.find_element(By.ID, constants.COOKIE_COUNT_ID).text
|
||||
)
|
||||
)
|
||||
|
||||
def cookies_per_second(self) -> float:
|
||||
"""@brief Read how many cookies the player makes each second.
|
||||
|
||||
@return The cookies per second.
|
||||
"""
|
||||
return self._read_again_if_stale(
|
||||
lambda: parse_cookies_per_second(
|
||||
self._driver.find_element(By.ID, constants.COOKIES_PER_SECOND_ID).text
|
||||
)
|
||||
)
|
||||
|
||||
def upgrades(self) -> list[Upgrade]:
|
||||
"""@brief Read the upgrades the player can see in the right-hand pane.
|
||||
|
||||
@return The visible upgrades, affordable or not, in store order.
|
||||
"""
|
||||
return self._read_again_if_stale(self._read_upgrades)
|
||||
|
||||
def _read_upgrades(self) -> list[Upgrade]:
|
||||
"""@brief Find the visible products and read each one."""
|
||||
products = self._driver.find_elements(
|
||||
By.CSS_SELECTOR, constants.VISIBLE_PRODUCT_SELECTOR
|
||||
)
|
||||
return [self._read_upgrade(product) for product in products]
|
||||
|
||||
def _read_again_if_stale(self, read: Callable[[], T]) -> T:
|
||||
"""@brief Run a read; repeat it while the page re-renders under it.
|
||||
|
||||
@throws StaleElementReferenceException When every attempt went stale.
|
||||
"""
|
||||
for _ in range(constants.STALE_READ_ATTEMPTS - 1):
|
||||
try:
|
||||
return read()
|
||||
except StaleElementReferenceException:
|
||||
self._sleep(constants.POLL_INTERVAL_SECONDS)
|
||||
return read()
|
||||
|
||||
def buy(self, upgrade: Upgrade) -> bool:
|
||||
"""@brief Buy an upgrade by clicking its product.
|
||||
|
||||
@param upgrade The upgrade to buy.
|
||||
@return True only when a click really reached the product.
|
||||
"""
|
||||
try:
|
||||
element = self._driver.find_element(By.ID, upgrade.key)
|
||||
try:
|
||||
element.click()
|
||||
except ElementClickInterceptedException:
|
||||
# The cookie-consent banner covers the lower products. Click from
|
||||
# inside the page instead; the banner itself is never clicked.
|
||||
self._driver.execute_script(constants.JS_CLICK_SCRIPT, element)
|
||||
except StaleElementReferenceException:
|
||||
# The pane re-rendered under us; the next check retries.
|
||||
return False
|
||||
return True
|
||||
|
||||
def save_screenshot(self, path: Path) -> None:
|
||||
"""@brief Write a screenshot of the game to a PNG file.
|
||||
|
||||
@param path Target file; its folder is created when missing.
|
||||
"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._driver.save_screenshot(str(path))
|
||||
|
||||
def close(self) -> None:
|
||||
"""@brief Close the browser."""
|
||||
self._driver.quit()
|
||||
|
||||
@staticmethod
|
||||
def _read_upgrade(product: ElementLike) -> Upgrade:
|
||||
"""@brief Read the id, name and price of one product element."""
|
||||
name = product.find_element(By.CSS_SELECTOR, constants.PRODUCT_NAME_SELECTOR)
|
||||
price = product.find_element(By.CSS_SELECTOR, constants.PRODUCT_PRICE_SELECTOR)
|
||||
key = product.get_attribute(constants.ID_ATTRIBUTE) or ""
|
||||
return Upgrade(key=key, name=name.text.strip(), price=parse_amount(price.text))
|
||||
|
||||
def _visible(self, element_id: str) -> ElementLike | None:
|
||||
"""@brief Return the element with this id when it exists and is displayed."""
|
||||
try:
|
||||
element = self._driver.find_element(By.ID, element_id)
|
||||
return element if element.is_displayed() else None
|
||||
except (NoSuchElementException, StaleElementReferenceException):
|
||||
# Not there yet, or the page re-rendered under us: poll again.
|
||||
return None
|
||||
|
||||
def _wait_for(self, locate: Callable[[], T | None]) -> T:
|
||||
"""@brief Poll until `locate` returns a value.
|
||||
|
||||
@param locate Function that returns the value, or None while not ready.
|
||||
@return The value.
|
||||
@throws TimeoutError When the value is not there within the timeout.
|
||||
"""
|
||||
deadline = self._clock() + self._timeout
|
||||
while True:
|
||||
found = locate()
|
||||
if found is not None:
|
||||
return found
|
||||
if self._clock() >= deadline:
|
||||
message = f"the game page was not ready within {self._timeout} s"
|
||||
raise TimeoutError(message)
|
||||
self._sleep(constants.POLL_INTERVAL_SECONDS)
|
||||
"""@file selenium_game.py
|
||||
@brief Selenium adapter that implements the game port.
|
||||
"""
|
||||
|
||||
import time
|
||||
from collections.abc import Callable, Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol, TypeVar
|
||||
|
||||
from selenium.common.exceptions import (
|
||||
ElementClickInterceptedException,
|
||||
JavascriptException,
|
||||
NoSuchElementException,
|
||||
StaleElementReferenceException,
|
||||
)
|
||||
from selenium.webdriver.common.by import By
|
||||
|
||||
from cookie_clicker_bot import constants
|
||||
from cookie_clicker_bot.domain.parsing import (
|
||||
parse_amount,
|
||||
parse_cookie_count,
|
||||
parse_cookies_per_second,
|
||||
)
|
||||
from cookie_clicker_bot.domain.upgrades import Upgrade
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class ElementLike(Protocol):
|
||||
"""@brief The part of a Selenium element that the adapter uses."""
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
"""@brief Visible text of the element."""
|
||||
...
|
||||
|
||||
def click(self) -> None:
|
||||
"""@brief Click the element."""
|
||||
|
||||
def is_displayed(self) -> bool:
|
||||
"""@brief True when the element is visible."""
|
||||
...
|
||||
|
||||
def get_attribute(self, name: str) -> str | None:
|
||||
"""@brief Read an attribute of the element."""
|
||||
...
|
||||
|
||||
def find_element(self, by: str, value: str) -> "ElementLike":
|
||||
"""@brief Find one element inside this element."""
|
||||
...
|
||||
|
||||
|
||||
class DriverLike(Protocol):
|
||||
"""@brief The part of a Selenium driver that the adapter uses."""
|
||||
|
||||
def get(self, url: str) -> None:
|
||||
"""@brief Load a page."""
|
||||
|
||||
def find_element(self, by: str, value: str) -> ElementLike:
|
||||
"""@brief Find one element."""
|
||||
...
|
||||
|
||||
def find_elements(self, by: str, value: str) -> Sequence[ElementLike]:
|
||||
"""@brief Find every element that matches."""
|
||||
...
|
||||
|
||||
def save_screenshot(self, filename: str) -> bool:
|
||||
"""@brief Write a PNG screenshot."""
|
||||
...
|
||||
|
||||
def execute_script(self, script: str, *args: Any) -> Any: # noqa: ANN401
|
||||
"""@brief Run JavaScript in the page; the arguments are `arguments[n]`."""
|
||||
...
|
||||
|
||||
def quit(self) -> None:
|
||||
"""@brief Close the browser."""
|
||||
|
||||
|
||||
class SeleniumGame:
|
||||
"""@brief Plays the game page through a Selenium driver."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
driver: DriverLike,
|
||||
timeout_seconds: float = constants.WAIT_TIMEOUT_SECONDS,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
sleep: Callable[[float], None] = time.sleep,
|
||||
) -> None:
|
||||
"""@brief Wrap a driver.
|
||||
|
||||
@param driver The Selenium driver (or a fake in tests).
|
||||
@param timeout_seconds Longest wait for an element.
|
||||
@param clock Monotonic clock in seconds.
|
||||
@param sleep Pause function used between polls.
|
||||
"""
|
||||
self._driver = driver
|
||||
self._timeout = timeout_seconds
|
||||
self._clock = clock
|
||||
self._sleep = sleep
|
||||
|
||||
def open_game(self) -> None:
|
||||
"""@brief Load the game page."""
|
||||
self._driver.get(constants.GAME_URL)
|
||||
|
||||
def dismiss_language(self) -> None:
|
||||
"""@brief Pick English, then wait until the big cookie can be clicked."""
|
||||
english = self._wait_for(lambda: self._visible(constants.LANGUAGE_ENGLISH_ID))
|
||||
english.click()
|
||||
self._wait_for(lambda: self._visible(constants.BIG_COOKIE_ID))
|
||||
|
||||
def click_cookie(self) -> None:
|
||||
"""@brief Click the big cookie once."""
|
||||
try:
|
||||
self._driver.find_element(By.ID, constants.BIG_COOKIE_ID).click()
|
||||
except (ElementClickInterceptedException, StaleElementReferenceException):
|
||||
# A banner or a re-render covered the cookie for a moment; try again.
|
||||
return
|
||||
|
||||
def cookie_count(self) -> int:
|
||||
"""@brief Read how many cookies the player has.
|
||||
|
||||
@return The cookie count.
|
||||
"""
|
||||
return self._read_again_if_stale(
|
||||
lambda: parse_cookie_count(
|
||||
self._driver.find_element(By.ID, constants.COOKIE_COUNT_ID).text
|
||||
)
|
||||
)
|
||||
|
||||
def cookies_per_second(self) -> float:
|
||||
"""@brief Read how many cookies the player makes each second.
|
||||
|
||||
@return The cookies per second.
|
||||
"""
|
||||
return self._read_again_if_stale(
|
||||
lambda: parse_cookies_per_second(
|
||||
self._driver.find_element(By.ID, constants.COOKIES_PER_SECOND_ID).text
|
||||
)
|
||||
)
|
||||
|
||||
def upgrades(self) -> list[Upgrade]:
|
||||
"""@brief Read the upgrades the player can see in the right-hand pane.
|
||||
|
||||
@return The visible upgrades, affordable or not, in store order.
|
||||
"""
|
||||
return self._read_again_if_stale(self._read_upgrades)
|
||||
|
||||
def _read_upgrades(self) -> list[Upgrade]:
|
||||
"""@brief Find the visible products and read each one."""
|
||||
products = self._driver.find_elements(
|
||||
By.CSS_SELECTOR, constants.VISIBLE_PRODUCT_SELECTOR
|
||||
)
|
||||
production = self._read_production()
|
||||
return [self._read_upgrade(product, production) for product in products]
|
||||
|
||||
def _read_production(self) -> list[float]:
|
||||
"""@brief Read the cookies per second of one unit of every product.
|
||||
|
||||
@return The numbers by product index; empty when the page does not tell.
|
||||
"""
|
||||
try:
|
||||
production = self._driver.execute_script(constants.JS_PRODUCTION_SCRIPT)
|
||||
except JavascriptException:
|
||||
return []
|
||||
return production if isinstance(production, list) else []
|
||||
|
||||
def _read_again_if_stale(self, read: Callable[[], T]) -> T:
|
||||
"""@brief Run a read; repeat it while the page re-renders under it.
|
||||
|
||||
@throws StaleElementReferenceException When every attempt went stale.
|
||||
"""
|
||||
for _ in range(constants.STALE_READ_ATTEMPTS - 1):
|
||||
try:
|
||||
return read()
|
||||
except StaleElementReferenceException:
|
||||
self._sleep(constants.POLL_INTERVAL_SECONDS)
|
||||
return read()
|
||||
|
||||
def buy(self, upgrade: Upgrade) -> bool:
|
||||
"""@brief Buy an upgrade by clicking its product.
|
||||
|
||||
@param upgrade The upgrade to buy.
|
||||
@return True only when a click really reached the product.
|
||||
"""
|
||||
try:
|
||||
element = self._driver.find_element(By.ID, upgrade.key)
|
||||
try:
|
||||
element.click()
|
||||
except ElementClickInterceptedException:
|
||||
# The cookie-consent banner covers the lower products. Click from
|
||||
# inside the page instead; the banner itself is never clicked.
|
||||
self._driver.execute_script(constants.JS_CLICK_SCRIPT, element)
|
||||
except StaleElementReferenceException:
|
||||
# The pane re-rendered under us; the next check retries.
|
||||
return False
|
||||
return True
|
||||
|
||||
def save_screenshot(self, path: Path) -> None:
|
||||
"""@brief Write a screenshot of the game to a PNG file.
|
||||
|
||||
@param path Target file; its folder is created when missing.
|
||||
"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._driver.save_screenshot(str(path))
|
||||
|
||||
def close(self) -> None:
|
||||
"""@brief Close the browser."""
|
||||
self._driver.quit()
|
||||
|
||||
@staticmethod
|
||||
def _read_upgrade(product: ElementLike, production: Sequence[float]) -> Upgrade:
|
||||
"""@brief Read the id, name, price and production of one product element."""
|
||||
name = product.find_element(By.CSS_SELECTOR, constants.PRODUCT_NAME_SELECTOR)
|
||||
price = product.find_element(By.CSS_SELECTOR, constants.PRODUCT_PRICE_SELECTOR)
|
||||
key = product.get_attribute(constants.ID_ATTRIBUTE) or ""
|
||||
return Upgrade(
|
||||
key=key,
|
||||
name=name.text.strip(),
|
||||
price=parse_amount(price.text),
|
||||
cookies_per_second=SeleniumGame._production_of(key, production),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _production_of(key: str, production: Sequence[float]) -> float | None:
|
||||
"""@brief Look up the production of the product with this element id."""
|
||||
index = key.removeprefix(constants.PRODUCT_ID_PREFIX)
|
||||
if not index.isdigit() or int(index) >= len(production):
|
||||
return None
|
||||
value = production[int(index)]
|
||||
return float(value) if isinstance(value, int | float) else None
|
||||
|
||||
def _visible(self, element_id: str) -> ElementLike | None:
|
||||
"""@brief Return the element with this id when it exists and is displayed."""
|
||||
try:
|
||||
element = self._driver.find_element(By.ID, element_id)
|
||||
return element if element.is_displayed() else None
|
||||
except (NoSuchElementException, StaleElementReferenceException):
|
||||
# Not there yet, or the page re-rendered under us: poll again.
|
||||
return None
|
||||
|
||||
def _wait_for(self, locate: Callable[[], T | None]) -> T:
|
||||
"""@brief Poll until `locate` returns a value.
|
||||
|
||||
@param locate Function that returns the value, or None while not ready.
|
||||
@return The value.
|
||||
@throws TimeoutError When the value is not there within the timeout.
|
||||
"""
|
||||
deadline = self._clock() + self._timeout
|
||||
while True:
|
||||
found = locate()
|
||||
if found is not None:
|
||||
return found
|
||||
if self._clock() >= deadline:
|
||||
message = f"the game page was not ready within {self._timeout} s"
|
||||
raise TimeoutError(message)
|
||||
self._sleep(constants.POLL_INTERVAL_SECONDS)
|
||||
|
||||
@@ -1,429 +1,490 @@
|
||||
"""@file test_selenium_game.py
|
||||
@brief Tests of the Selenium adapter against a fake driver (no browser).
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from selenium.common.exceptions import (
|
||||
ElementClickInterceptedException,
|
||||
NoSuchElementException,
|
||||
StaleElementReferenceException,
|
||||
)
|
||||
|
||||
from cookie_clicker_bot import constants
|
||||
from cookie_clicker_bot.domain.upgrades import Upgrade
|
||||
from cookie_clicker_bot.infrastructure.selenium_game import SeleniumGame
|
||||
|
||||
|
||||
class FakeElement:
|
||||
"""An element that records clicks and may raise on click."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
displayed: Callable[[], bool] = lambda: True,
|
||||
click_error: Exception | None = None,
|
||||
text: str = "",
|
||||
attributes: dict[str, str] | None = None,
|
||||
children: dict[str, "FakeElement"] | None = None,
|
||||
) -> None:
|
||||
self.text = text
|
||||
self.clicks = 0
|
||||
self._displayed = displayed
|
||||
self._click_error = click_error
|
||||
self._attributes = attributes or {}
|
||||
self._children = children or {}
|
||||
|
||||
def get_attribute(self, name: str) -> str | None:
|
||||
return self._attributes.get(name)
|
||||
|
||||
def find_element(self, by: str, value: str) -> "FakeElement":
|
||||
if by != "css selector" or value not in self._children:
|
||||
raise NoSuchElementException(value)
|
||||
return self._children[value]
|
||||
|
||||
def click(self) -> None:
|
||||
if self._click_error is not None:
|
||||
raise self._click_error
|
||||
self.clicks += 1
|
||||
|
||||
def is_displayed(self) -> bool:
|
||||
return self._displayed()
|
||||
|
||||
|
||||
class FakeDriver:
|
||||
"""A driver with elements by id."""
|
||||
|
||||
def __init__(self, elements: dict[str, FakeElement] | None = None) -> None:
|
||||
self.elements = elements or {}
|
||||
self.groups: dict[str, list[FakeElement]] = {}
|
||||
self.loaded: list[str] = []
|
||||
self.screenshots: list[str] = []
|
||||
self.quit_called = False
|
||||
self.scripts: list[tuple[str, FakeElement]] = []
|
||||
|
||||
def execute_script(self, script: str, *args: Any) -> None: # noqa: ANN401
|
||||
self.scripts.append((script, args[0]))
|
||||
|
||||
def get(self, url: str) -> None:
|
||||
self.loaded.append(url)
|
||||
|
||||
def find_element(self, by: str, value: str) -> FakeElement:
|
||||
if by != "id" or value not in self.elements:
|
||||
raise NoSuchElementException(value)
|
||||
return self.elements[value]
|
||||
|
||||
def find_elements(self, by: str, value: str) -> list[FakeElement]:
|
||||
if by != "css selector":
|
||||
return []
|
||||
return self.groups.get(value, [])
|
||||
|
||||
def save_screenshot(self, filename: str) -> bool:
|
||||
self.screenshots.append(filename)
|
||||
return True
|
||||
|
||||
def quit(self) -> None:
|
||||
self.quit_called = True
|
||||
|
||||
|
||||
class FakeTime:
|
||||
"""A clock that only moves when the fake sleep is called."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.now = 0.0
|
||||
self.sleeps: list[float] = []
|
||||
|
||||
def clock(self) -> float:
|
||||
return self.now
|
||||
|
||||
def sleep(self, seconds: float) -> None:
|
||||
self.sleeps.append(seconds)
|
||||
self.now += seconds
|
||||
|
||||
|
||||
def make_game(driver: FakeDriver, fake_time: FakeTime | None = None) -> SeleniumGame:
|
||||
fake_time = fake_time or FakeTime()
|
||||
return SeleniumGame(
|
||||
driver, timeout_seconds=1.0, clock=fake_time.clock, sleep=fake_time.sleep
|
||||
)
|
||||
|
||||
|
||||
def ready_driver() -> FakeDriver:
|
||||
return FakeDriver(
|
||||
{
|
||||
constants.LANGUAGE_ENGLISH_ID: FakeElement(),
|
||||
constants.BIG_COOKIE_ID: FakeElement(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_open_game_loads_the_game_address() -> None:
|
||||
driver = FakeDriver()
|
||||
|
||||
make_game(driver).open_game()
|
||||
|
||||
assert driver.loaded == [constants.GAME_URL]
|
||||
|
||||
|
||||
def test_dismiss_language_clicks_english_and_waits_for_the_cookie() -> None:
|
||||
driver = ready_driver()
|
||||
|
||||
make_game(driver).dismiss_language()
|
||||
|
||||
assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1
|
||||
|
||||
|
||||
def test_dismiss_language_polls_until_the_button_is_visible() -> None:
|
||||
fake_time = FakeTime()
|
||||
driver = ready_driver()
|
||||
driver.elements[constants.LANGUAGE_ENGLISH_ID] = FakeElement(
|
||||
displayed=lambda: fake_time.now >= 0.4
|
||||
)
|
||||
|
||||
make_game(driver, fake_time).dismiss_language()
|
||||
|
||||
assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1
|
||||
assert fake_time.sleeps == [constants.POLL_INTERVAL_SECONDS] * 2
|
||||
|
||||
|
||||
def stale_once() -> Callable[[], bool]:
|
||||
"""Return an `is_displayed` that goes stale on the first call only."""
|
||||
calls: list[int] = []
|
||||
|
||||
def displayed() -> bool:
|
||||
calls.append(1)
|
||||
if len(calls) == 1:
|
||||
raise StaleElementReferenceException
|
||||
return True
|
||||
|
||||
return displayed
|
||||
|
||||
|
||||
def test_dismiss_language_survives_a_cookie_that_goes_stale_once() -> None:
|
||||
driver = ready_driver()
|
||||
driver.elements[constants.BIG_COOKIE_ID] = FakeElement(displayed=stale_once())
|
||||
|
||||
make_game(driver).dismiss_language()
|
||||
|
||||
assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1
|
||||
|
||||
|
||||
def test_dismiss_language_survives_a_button_that_goes_stale_once() -> None:
|
||||
driver = ready_driver()
|
||||
driver.elements[constants.LANGUAGE_ENGLISH_ID] = FakeElement(displayed=stale_once())
|
||||
|
||||
make_game(driver).dismiss_language()
|
||||
|
||||
assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1
|
||||
|
||||
|
||||
def test_dismiss_language_survives_a_lookup_that_goes_stale_once() -> None:
|
||||
driver = ready_driver()
|
||||
real_find = driver.find_element
|
||||
calls: list[int] = []
|
||||
|
||||
def find_element(by: str, value: str) -> FakeElement:
|
||||
calls.append(1)
|
||||
if len(calls) == 1:
|
||||
raise StaleElementReferenceException
|
||||
return real_find(by, value)
|
||||
|
||||
driver.find_element = find_element # type: ignore[method-assign]
|
||||
|
||||
make_game(driver).dismiss_language()
|
||||
|
||||
assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1
|
||||
|
||||
|
||||
def test_dismiss_language_times_out_when_the_button_never_shows() -> None:
|
||||
fake_time = FakeTime()
|
||||
|
||||
with pytest.raises(TimeoutError):
|
||||
make_game(FakeDriver(), fake_time).dismiss_language()
|
||||
|
||||
assert fake_time.now >= 1.0
|
||||
|
||||
|
||||
def test_dismiss_language_times_out_when_the_cookie_never_shows() -> None:
|
||||
driver = FakeDriver({constants.LANGUAGE_ENGLISH_ID: FakeElement()})
|
||||
|
||||
with pytest.raises(TimeoutError):
|
||||
make_game(driver).dismiss_language()
|
||||
|
||||
|
||||
def test_click_cookie_clicks_the_big_cookie() -> None:
|
||||
driver = ready_driver()
|
||||
|
||||
make_game(driver).click_cookie()
|
||||
|
||||
assert driver.elements[constants.BIG_COOKIE_ID].clicks == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[ElementClickInterceptedException(), StaleElementReferenceException()],
|
||||
)
|
||||
def test_click_cookie_ignores_a_covered_or_stale_cookie(error: Exception) -> None:
|
||||
driver = FakeDriver({constants.BIG_COOKIE_ID: FakeElement(click_error=error)})
|
||||
|
||||
make_game(driver).click_cookie()
|
||||
|
||||
|
||||
def make_product(key: str, name: str, price: str) -> FakeElement:
|
||||
return FakeElement(
|
||||
attributes={"id": key},
|
||||
children={
|
||||
constants.PRODUCT_NAME_SELECTOR: FakeElement(text=name),
|
||||
constants.PRODUCT_PRICE_SELECTOR: FakeElement(text=price),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_cookie_count_reads_the_cookie_counter() -> None:
|
||||
driver = FakeDriver(
|
||||
{
|
||||
constants.COOKIE_COUNT_ID: FakeElement(
|
||||
text="1.235\nmillion cookies\nper second: 0"
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
assert make_game(driver).cookie_count() == 1_235_000
|
||||
|
||||
|
||||
def test_cookies_per_second_reads_its_element() -> None:
|
||||
driver = FakeDriver(
|
||||
{constants.COOKIES_PER_SECOND_ID: FakeElement(text="per second: 1,234.5")}
|
||||
)
|
||||
|
||||
assert make_game(driver).cookies_per_second() == 1234.5
|
||||
|
||||
|
||||
def test_upgrades_reads_the_visible_products() -> None:
|
||||
driver = FakeDriver()
|
||||
driver.groups[constants.VISIBLE_PRODUCT_SELECTOR] = [
|
||||
make_product("product0", "Cursor", "15"),
|
||||
make_product("product2", "Farm", "1,100"),
|
||||
]
|
||||
|
||||
assert make_game(driver).upgrades() == [
|
||||
Upgrade("product0", "Cursor", 15),
|
||||
Upgrade("product2", "Farm", 1100),
|
||||
]
|
||||
|
||||
|
||||
class StaleOnceText(FakeElement):
|
||||
"""An element whose text goes stale on the first read."""
|
||||
|
||||
def __init__(self, text: str) -> None:
|
||||
super().__init__()
|
||||
self._text = text
|
||||
self._reads = 0
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
self._reads += 1
|
||||
if self._reads == 1:
|
||||
raise StaleElementReferenceException
|
||||
return self._text
|
||||
|
||||
@text.setter
|
||||
def text(self, value: str) -> None:
|
||||
self._text = value
|
||||
|
||||
|
||||
def test_cookie_count_retries_once_when_the_counter_goes_stale() -> None:
|
||||
driver = FakeDriver({constants.COOKIE_COUNT_ID: StaleOnceText("42 cookies")})
|
||||
|
||||
assert make_game(driver).cookie_count() == 42
|
||||
|
||||
|
||||
def test_cookies_per_second_retries_once_when_the_element_goes_stale() -> None:
|
||||
driver = FakeDriver(
|
||||
{constants.COOKIES_PER_SECOND_ID: StaleOnceText("per second: 3.5")}
|
||||
)
|
||||
|
||||
assert make_game(driver).cookies_per_second() == 3.5
|
||||
|
||||
|
||||
def test_upgrades_retries_once_when_a_product_goes_stale() -> None:
|
||||
product = make_product("product0", "Cursor", "15")
|
||||
product.get_attribute = _stale_once_then(product.get_attribute) # type: ignore[assignment]
|
||||
driver = FakeDriver()
|
||||
driver.groups[constants.VISIBLE_PRODUCT_SELECTOR] = [product]
|
||||
|
||||
assert make_game(driver).upgrades() == [Upgrade("product0", "Cursor", 15)]
|
||||
|
||||
|
||||
def test_cookies_per_second_succeeds_after_four_stale_attempts() -> None:
|
||||
driver = FakeDriver()
|
||||
attempts = _stale_for(4, FakeElement(text="per second: 3.5"))
|
||||
driver.find_element = attempts.find_element # type: ignore[method-assign]
|
||||
fake_time = FakeTime()
|
||||
|
||||
assert make_game(driver, fake_time).cookies_per_second() == 3.5
|
||||
assert attempts.calls == 5
|
||||
assert fake_time.sleeps == [constants.POLL_INTERVAL_SECONDS] * 4
|
||||
|
||||
|
||||
def test_cookie_count_gives_up_when_the_counter_stays_stale() -> None:
|
||||
driver = FakeDriver()
|
||||
attempts = _stale_for(100, FakeElement(text="42 cookies"))
|
||||
driver.find_element = attempts.find_element # type: ignore[method-assign]
|
||||
fake_time = FakeTime()
|
||||
|
||||
with pytest.raises(StaleElementReferenceException):
|
||||
make_game(driver, fake_time).cookie_count()
|
||||
|
||||
assert attempts.calls == constants.STALE_READ_ATTEMPTS
|
||||
assert fake_time.sleeps == [constants.POLL_INTERVAL_SECONDS] * (
|
||||
constants.STALE_READ_ATTEMPTS - 1
|
||||
)
|
||||
|
||||
|
||||
class _StaleFor:
|
||||
"""A `find_element` that goes stale for the first `stale` calls."""
|
||||
|
||||
def __init__(self, stale: int, element: FakeElement) -> None:
|
||||
self._stale = stale
|
||||
self._element = element
|
||||
self.calls = 0
|
||||
|
||||
def find_element(self, by: str, value: str) -> FakeElement:
|
||||
self.calls += 1
|
||||
if self.calls <= self._stale:
|
||||
raise StaleElementReferenceException
|
||||
return self._element
|
||||
|
||||
|
||||
def _stale_for(stale: int, element: FakeElement) -> _StaleFor:
|
||||
return _StaleFor(stale, element)
|
||||
|
||||
|
||||
def _stale_once_then(
|
||||
original: Callable[[str], str | None],
|
||||
) -> Callable[[str], str | None]:
|
||||
calls: list[int] = []
|
||||
|
||||
def get_attribute(name: str) -> str | None:
|
||||
calls.append(1)
|
||||
if len(calls) == 1:
|
||||
raise StaleElementReferenceException
|
||||
return original(name)
|
||||
|
||||
return get_attribute
|
||||
|
||||
|
||||
def test_upgrades_is_empty_when_no_product_is_visible() -> None:
|
||||
assert make_game(FakeDriver()).upgrades() == []
|
||||
|
||||
|
||||
def test_buy_clicks_the_product_with_the_upgrade_key() -> None:
|
||||
driver = FakeDriver({"product1": FakeElement()})
|
||||
|
||||
bought = make_game(driver).buy(Upgrade("product1", "Grandma", 100))
|
||||
|
||||
assert bought is True
|
||||
assert driver.elements["product1"].clicks == 1
|
||||
assert driver.scripts == []
|
||||
|
||||
|
||||
def test_buy_clicks_through_javascript_when_a_banner_covers_the_product() -> None:
|
||||
element = FakeElement(click_error=ElementClickInterceptedException())
|
||||
driver = FakeDriver({"product1": element})
|
||||
|
||||
bought = make_game(driver).buy(Upgrade("product1", "Grandma", 100))
|
||||
|
||||
assert bought is True
|
||||
assert driver.scripts == [(constants.JS_CLICK_SCRIPT, element)]
|
||||
|
||||
|
||||
def test_buy_returns_false_for_a_stale_product() -> None:
|
||||
driver = FakeDriver(
|
||||
{"product1": FakeElement(click_error=StaleElementReferenceException())}
|
||||
)
|
||||
|
||||
assert make_game(driver).buy(Upgrade("product1", "Grandma", 100)) is False
|
||||
assert driver.scripts == []
|
||||
|
||||
|
||||
def test_save_screenshot_creates_the_folder_and_calls_the_driver(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
driver = FakeDriver()
|
||||
target = tmp_path / "images" / "shot.png"
|
||||
|
||||
make_game(driver).save_screenshot(target)
|
||||
|
||||
assert target.parent.is_dir()
|
||||
assert driver.screenshots == [str(target)]
|
||||
|
||||
|
||||
def test_close_quits_the_driver() -> None:
|
||||
driver = FakeDriver()
|
||||
|
||||
make_game(driver).close()
|
||||
|
||||
assert driver.quit_called
|
||||
"""@file test_selenium_game.py
|
||||
@brief Tests of the Selenium adapter against a fake driver (no browser).
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from selenium.common.exceptions import (
|
||||
ElementClickInterceptedException,
|
||||
JavascriptException,
|
||||
NoSuchElementException,
|
||||
StaleElementReferenceException,
|
||||
)
|
||||
|
||||
from cookie_clicker_bot import constants
|
||||
from cookie_clicker_bot.domain.upgrades import Upgrade
|
||||
from cookie_clicker_bot.infrastructure.selenium_game import SeleniumGame
|
||||
|
||||
|
||||
class FakeElement:
|
||||
"""An element that records clicks and may raise on click."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
displayed: Callable[[], bool] = lambda: True,
|
||||
click_error: Exception | None = None,
|
||||
text: str = "",
|
||||
attributes: dict[str, str] | None = None,
|
||||
children: dict[str, "FakeElement"] | None = None,
|
||||
) -> None:
|
||||
self.text = text
|
||||
self.clicks = 0
|
||||
self._displayed = displayed
|
||||
self._click_error = click_error
|
||||
self._attributes = attributes or {}
|
||||
self._children = children or {}
|
||||
|
||||
def get_attribute(self, name: str) -> str | None:
|
||||
return self._attributes.get(name)
|
||||
|
||||
def find_element(self, by: str, value: str) -> "FakeElement":
|
||||
if by != "css selector" or value not in self._children:
|
||||
raise NoSuchElementException(value)
|
||||
return self._children[value]
|
||||
|
||||
def click(self) -> None:
|
||||
if self._click_error is not None:
|
||||
raise self._click_error
|
||||
self.clicks += 1
|
||||
|
||||
def is_displayed(self) -> bool:
|
||||
return self._displayed()
|
||||
|
||||
|
||||
class FakeDriver:
|
||||
"""A driver with elements by id."""
|
||||
|
||||
def __init__(self, elements: dict[str, FakeElement] | None = None) -> None:
|
||||
self.elements = elements or {}
|
||||
self.groups: dict[str, list[FakeElement]] = {}
|
||||
self.loaded: list[str] = []
|
||||
self.screenshots: list[str] = []
|
||||
self.quit_called = False
|
||||
self.scripts: list[tuple[str, FakeElement]] = []
|
||||
self.production: Any = []
|
||||
|
||||
def execute_script(self, script: str, *args: Any) -> Any: # noqa: ANN401
|
||||
if script == constants.JS_PRODUCTION_SCRIPT:
|
||||
if isinstance(self.production, Exception):
|
||||
raise self.production
|
||||
return self.production
|
||||
self.scripts.append((script, args[0]))
|
||||
|
||||
def get(self, url: str) -> None:
|
||||
self.loaded.append(url)
|
||||
|
||||
def find_element(self, by: str, value: str) -> FakeElement:
|
||||
if by != "id" or value not in self.elements:
|
||||
raise NoSuchElementException(value)
|
||||
return self.elements[value]
|
||||
|
||||
def find_elements(self, by: str, value: str) -> list[FakeElement]:
|
||||
if by != "css selector":
|
||||
return []
|
||||
return self.groups.get(value, [])
|
||||
|
||||
def save_screenshot(self, filename: str) -> bool:
|
||||
self.screenshots.append(filename)
|
||||
return True
|
||||
|
||||
def quit(self) -> None:
|
||||
self.quit_called = True
|
||||
|
||||
|
||||
class FakeTime:
|
||||
"""A clock that only moves when the fake sleep is called."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.now = 0.0
|
||||
self.sleeps: list[float] = []
|
||||
|
||||
def clock(self) -> float:
|
||||
return self.now
|
||||
|
||||
def sleep(self, seconds: float) -> None:
|
||||
self.sleeps.append(seconds)
|
||||
self.now += seconds
|
||||
|
||||
|
||||
def make_game(driver: FakeDriver, fake_time: FakeTime | None = None) -> SeleniumGame:
|
||||
fake_time = fake_time or FakeTime()
|
||||
return SeleniumGame(
|
||||
driver, timeout_seconds=1.0, clock=fake_time.clock, sleep=fake_time.sleep
|
||||
)
|
||||
|
||||
|
||||
def ready_driver() -> FakeDriver:
|
||||
return FakeDriver(
|
||||
{
|
||||
constants.LANGUAGE_ENGLISH_ID: FakeElement(),
|
||||
constants.BIG_COOKIE_ID: FakeElement(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_open_game_loads_the_game_address() -> None:
|
||||
driver = FakeDriver()
|
||||
|
||||
make_game(driver).open_game()
|
||||
|
||||
assert driver.loaded == [constants.GAME_URL]
|
||||
|
||||
|
||||
def test_dismiss_language_clicks_english_and_waits_for_the_cookie() -> None:
|
||||
driver = ready_driver()
|
||||
|
||||
make_game(driver).dismiss_language()
|
||||
|
||||
assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1
|
||||
|
||||
|
||||
def test_dismiss_language_polls_until_the_button_is_visible() -> None:
|
||||
fake_time = FakeTime()
|
||||
driver = ready_driver()
|
||||
driver.elements[constants.LANGUAGE_ENGLISH_ID] = FakeElement(
|
||||
displayed=lambda: fake_time.now >= 0.4
|
||||
)
|
||||
|
||||
make_game(driver, fake_time).dismiss_language()
|
||||
|
||||
assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1
|
||||
assert fake_time.sleeps == [constants.POLL_INTERVAL_SECONDS] * 2
|
||||
|
||||
|
||||
def stale_once() -> Callable[[], bool]:
|
||||
"""Return an `is_displayed` that goes stale on the first call only."""
|
||||
calls: list[int] = []
|
||||
|
||||
def displayed() -> bool:
|
||||
calls.append(1)
|
||||
if len(calls) == 1:
|
||||
raise StaleElementReferenceException
|
||||
return True
|
||||
|
||||
return displayed
|
||||
|
||||
|
||||
def test_dismiss_language_survives_a_cookie_that_goes_stale_once() -> None:
|
||||
driver = ready_driver()
|
||||
driver.elements[constants.BIG_COOKIE_ID] = FakeElement(displayed=stale_once())
|
||||
|
||||
make_game(driver).dismiss_language()
|
||||
|
||||
assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1
|
||||
|
||||
|
||||
def test_dismiss_language_survives_a_button_that_goes_stale_once() -> None:
|
||||
driver = ready_driver()
|
||||
driver.elements[constants.LANGUAGE_ENGLISH_ID] = FakeElement(displayed=stale_once())
|
||||
|
||||
make_game(driver).dismiss_language()
|
||||
|
||||
assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1
|
||||
|
||||
|
||||
def test_dismiss_language_survives_a_lookup_that_goes_stale_once() -> None:
|
||||
driver = ready_driver()
|
||||
real_find = driver.find_element
|
||||
calls: list[int] = []
|
||||
|
||||
def find_element(by: str, value: str) -> FakeElement:
|
||||
calls.append(1)
|
||||
if len(calls) == 1:
|
||||
raise StaleElementReferenceException
|
||||
return real_find(by, value)
|
||||
|
||||
driver.find_element = find_element # type: ignore[method-assign]
|
||||
|
||||
make_game(driver).dismiss_language()
|
||||
|
||||
assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1
|
||||
|
||||
|
||||
def test_dismiss_language_times_out_when_the_button_never_shows() -> None:
|
||||
fake_time = FakeTime()
|
||||
|
||||
with pytest.raises(TimeoutError):
|
||||
make_game(FakeDriver(), fake_time).dismiss_language()
|
||||
|
||||
assert fake_time.now >= 1.0
|
||||
|
||||
|
||||
def test_dismiss_language_times_out_when_the_cookie_never_shows() -> None:
|
||||
driver = FakeDriver({constants.LANGUAGE_ENGLISH_ID: FakeElement()})
|
||||
|
||||
with pytest.raises(TimeoutError):
|
||||
make_game(driver).dismiss_language()
|
||||
|
||||
|
||||
def test_click_cookie_clicks_the_big_cookie() -> None:
|
||||
driver = ready_driver()
|
||||
|
||||
make_game(driver).click_cookie()
|
||||
|
||||
assert driver.elements[constants.BIG_COOKIE_ID].clicks == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[ElementClickInterceptedException(), StaleElementReferenceException()],
|
||||
)
|
||||
def test_click_cookie_ignores_a_covered_or_stale_cookie(error: Exception) -> None:
|
||||
driver = FakeDriver({constants.BIG_COOKIE_ID: FakeElement(click_error=error)})
|
||||
|
||||
make_game(driver).click_cookie()
|
||||
|
||||
|
||||
def make_product(key: str, name: str, price: str) -> FakeElement:
|
||||
return FakeElement(
|
||||
attributes={"id": key},
|
||||
children={
|
||||
constants.PRODUCT_NAME_SELECTOR: FakeElement(text=name),
|
||||
constants.PRODUCT_PRICE_SELECTOR: FakeElement(text=price),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_cookie_count_reads_the_cookie_counter() -> None:
|
||||
driver = FakeDriver(
|
||||
{
|
||||
constants.COOKIE_COUNT_ID: FakeElement(
|
||||
text="1.235\nmillion cookies\nper second: 0"
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
assert make_game(driver).cookie_count() == 1_235_000
|
||||
|
||||
|
||||
def test_cookies_per_second_reads_its_element() -> None:
|
||||
driver = FakeDriver(
|
||||
{constants.COOKIES_PER_SECOND_ID: FakeElement(text="per second: 1,234.5")}
|
||||
)
|
||||
|
||||
assert make_game(driver).cookies_per_second() == 1234.5
|
||||
|
||||
|
||||
def test_upgrades_reads_the_visible_products() -> None:
|
||||
driver = FakeDriver()
|
||||
driver.groups[constants.VISIBLE_PRODUCT_SELECTOR] = [
|
||||
make_product("product0", "Cursor", "15"),
|
||||
make_product("product2", "Farm", "1,100"),
|
||||
]
|
||||
|
||||
assert make_game(driver).upgrades() == [
|
||||
Upgrade("product0", "Cursor", 15),
|
||||
Upgrade("product2", "Farm", 1100),
|
||||
]
|
||||
|
||||
|
||||
class StaleOnceText(FakeElement):
|
||||
"""An element whose text goes stale on the first read."""
|
||||
|
||||
def __init__(self, text: str) -> None:
|
||||
super().__init__()
|
||||
self._text = text
|
||||
self._reads = 0
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
self._reads += 1
|
||||
if self._reads == 1:
|
||||
raise StaleElementReferenceException
|
||||
return self._text
|
||||
|
||||
@text.setter
|
||||
def text(self, value: str) -> None:
|
||||
self._text = value
|
||||
|
||||
|
||||
def test_cookie_count_retries_once_when_the_counter_goes_stale() -> None:
|
||||
driver = FakeDriver({constants.COOKIE_COUNT_ID: StaleOnceText("42 cookies")})
|
||||
|
||||
assert make_game(driver).cookie_count() == 42
|
||||
|
||||
|
||||
def test_cookies_per_second_retries_once_when_the_element_goes_stale() -> None:
|
||||
driver = FakeDriver(
|
||||
{constants.COOKIES_PER_SECOND_ID: StaleOnceText("per second: 3.5")}
|
||||
)
|
||||
|
||||
assert make_game(driver).cookies_per_second() == 3.5
|
||||
|
||||
|
||||
def test_upgrades_retries_once_when_a_product_goes_stale() -> None:
|
||||
product = make_product("product0", "Cursor", "15")
|
||||
product.get_attribute = _stale_once_then(product.get_attribute) # type: ignore[assignment]
|
||||
driver = FakeDriver()
|
||||
driver.groups[constants.VISIBLE_PRODUCT_SELECTOR] = [product]
|
||||
|
||||
assert make_game(driver).upgrades() == [Upgrade("product0", "Cursor", 15)]
|
||||
|
||||
|
||||
def test_cookies_per_second_succeeds_after_four_stale_attempts() -> None:
|
||||
driver = FakeDriver()
|
||||
attempts = _stale_for(4, FakeElement(text="per second: 3.5"))
|
||||
driver.find_element = attempts.find_element # type: ignore[method-assign]
|
||||
fake_time = FakeTime()
|
||||
|
||||
assert make_game(driver, fake_time).cookies_per_second() == 3.5
|
||||
assert attempts.calls == 5
|
||||
assert fake_time.sleeps == [constants.POLL_INTERVAL_SECONDS] * 4
|
||||
|
||||
|
||||
def test_cookie_count_gives_up_when_the_counter_stays_stale() -> None:
|
||||
driver = FakeDriver()
|
||||
attempts = _stale_for(100, FakeElement(text="42 cookies"))
|
||||
driver.find_element = attempts.find_element # type: ignore[method-assign]
|
||||
fake_time = FakeTime()
|
||||
|
||||
with pytest.raises(StaleElementReferenceException):
|
||||
make_game(driver, fake_time).cookie_count()
|
||||
|
||||
assert attempts.calls == constants.STALE_READ_ATTEMPTS
|
||||
assert fake_time.sleeps == [constants.POLL_INTERVAL_SECONDS] * (
|
||||
constants.STALE_READ_ATTEMPTS - 1
|
||||
)
|
||||
|
||||
|
||||
class _StaleFor:
|
||||
"""A `find_element` that goes stale for the first `stale` calls."""
|
||||
|
||||
def __init__(self, stale: int, element: FakeElement) -> None:
|
||||
self._stale = stale
|
||||
self._element = element
|
||||
self.calls = 0
|
||||
|
||||
def find_element(self, by: str, value: str) -> FakeElement:
|
||||
self.calls += 1
|
||||
if self.calls <= self._stale:
|
||||
raise StaleElementReferenceException
|
||||
return self._element
|
||||
|
||||
|
||||
def _stale_for(stale: int, element: FakeElement) -> _StaleFor:
|
||||
return _StaleFor(stale, element)
|
||||
|
||||
|
||||
def _stale_once_then(
|
||||
original: Callable[[str], str | None],
|
||||
) -> Callable[[str], str | None]:
|
||||
calls: list[int] = []
|
||||
|
||||
def get_attribute(name: str) -> str | None:
|
||||
calls.append(1)
|
||||
if len(calls) == 1:
|
||||
raise StaleElementReferenceException
|
||||
return original(name)
|
||||
|
||||
return get_attribute
|
||||
|
||||
|
||||
def test_upgrades_is_empty_when_no_product_is_visible() -> None:
|
||||
assert make_game(FakeDriver()).upgrades() == []
|
||||
|
||||
|
||||
def test_buy_clicks_the_product_with_the_upgrade_key() -> None:
|
||||
driver = FakeDriver({"product1": FakeElement()})
|
||||
|
||||
bought = make_game(driver).buy(Upgrade("product1", "Grandma", 100))
|
||||
|
||||
assert bought is True
|
||||
assert driver.elements["product1"].clicks == 1
|
||||
assert driver.scripts == []
|
||||
|
||||
|
||||
def test_buy_clicks_through_javascript_when_a_banner_covers_the_product() -> None:
|
||||
element = FakeElement(click_error=ElementClickInterceptedException())
|
||||
driver = FakeDriver({"product1": element})
|
||||
|
||||
bought = make_game(driver).buy(Upgrade("product1", "Grandma", 100))
|
||||
|
||||
assert bought is True
|
||||
assert driver.scripts == [(constants.JS_CLICK_SCRIPT, element)]
|
||||
|
||||
|
||||
def test_buy_returns_false_for_a_stale_product() -> None:
|
||||
driver = FakeDriver(
|
||||
{"product1": FakeElement(click_error=StaleElementReferenceException())}
|
||||
)
|
||||
|
||||
assert make_game(driver).buy(Upgrade("product1", "Grandma", 100)) is False
|
||||
assert driver.scripts == []
|
||||
|
||||
|
||||
def test_save_screenshot_creates_the_folder_and_calls_the_driver(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
driver = FakeDriver()
|
||||
target = tmp_path / "images" / "shot.png"
|
||||
|
||||
make_game(driver).save_screenshot(target)
|
||||
|
||||
assert target.parent.is_dir()
|
||||
assert driver.screenshots == [str(target)]
|
||||
|
||||
|
||||
def test_close_quits_the_driver() -> None:
|
||||
driver = FakeDriver()
|
||||
|
||||
make_game(driver).close()
|
||||
|
||||
assert driver.quit_called
|
||||
|
||||
|
||||
def _driver_with_two_products() -> FakeDriver:
|
||||
driver = FakeDriver()
|
||||
driver.groups[constants.VISIBLE_PRODUCT_SELECTOR] = [
|
||||
make_product("product0", "Cursor", "15"),
|
||||
make_product("product1", "Grandma", "100"),
|
||||
]
|
||||
return driver
|
||||
|
||||
|
||||
def test_upgrades_reads_the_production_of_each_product() -> None:
|
||||
driver = _driver_with_two_products()
|
||||
driver.production = [0.1, 1]
|
||||
|
||||
assert make_game(driver).upgrades() == [
|
||||
Upgrade("product0", "Cursor", 15, 0.1),
|
||||
Upgrade("product1", "Grandma", 100, 1.0),
|
||||
]
|
||||
|
||||
|
||||
def test_upgrades_leaves_the_production_unknown_when_the_script_fails() -> None:
|
||||
driver = _driver_with_two_products()
|
||||
driver.production = JavascriptException("Game is not defined")
|
||||
|
||||
upgrades = make_game(driver).upgrades()
|
||||
|
||||
assert [upgrade.cookies_per_second for upgrade in upgrades] == [None, None]
|
||||
|
||||
|
||||
def test_upgrades_leaves_the_production_unknown_for_a_product_the_list_lacks() -> None:
|
||||
driver = _driver_with_two_products()
|
||||
driver.production = [0.1]
|
||||
|
||||
upgrades = make_game(driver).upgrades()
|
||||
|
||||
assert [upgrade.cookies_per_second for upgrade in upgrades] == [0.1, None]
|
||||
|
||||
|
||||
def test_upgrades_leaves_a_production_that_is_not_a_number_unknown() -> None:
|
||||
driver = _driver_with_two_products()
|
||||
driver.production = [{}, 1]
|
||||
|
||||
upgrades = make_game(driver).upgrades()
|
||||
|
||||
assert [upgrade.cookies_per_second for upgrade in upgrades] == [None, 1.0]
|
||||
|
||||
|
||||
def test_upgrades_leaves_the_production_unknown_for_an_unexpected_answer() -> None:
|
||||
driver = _driver_with_two_products()
|
||||
driver.production = None
|
||||
|
||||
upgrades = make_game(driver).upgrades()
|
||||
|
||||
assert [upgrade.cookies_per_second for upgrade in upgrades] == [None, None]
|
||||
|
||||
+241
-216
@@ -1,216 +1,241 @@
|
||||
"""@file test_cli.py
|
||||
@brief Tests of the command line and the run sequence with a fake game.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from cookie_clicker_bot import constants
|
||||
from cookie_clicker_bot.application.ports import GamePort
|
||||
from cookie_clicker_bot.cli import main, parse_args
|
||||
from cookie_clicker_bot.domain.upgrades import Upgrade
|
||||
|
||||
|
||||
class RecordingGame:
|
||||
"""A game that records every call in order."""
|
||||
|
||||
def __init__(self, fail_on_open: bool = False, buy_succeeds: bool = True) -> None:
|
||||
self._buy_succeeds = buy_succeeds
|
||||
self.calls: list[str] = []
|
||||
self.screenshots: list[Path] = []
|
||||
self._fail_on_open = fail_on_open
|
||||
|
||||
def open_game(self) -> None:
|
||||
self.calls.append("open_game")
|
||||
if self._fail_on_open:
|
||||
message = "boom"
|
||||
raise RuntimeError(message)
|
||||
|
||||
def dismiss_language(self) -> None:
|
||||
self.calls.append("dismiss_language")
|
||||
|
||||
def click_cookie(self) -> None:
|
||||
self.calls.append("click_cookie")
|
||||
|
||||
def cookie_count(self) -> int:
|
||||
return 1000
|
||||
|
||||
def cookies_per_second(self) -> float:
|
||||
return 8.5
|
||||
|
||||
def upgrades(self) -> list[Upgrade]:
|
||||
return [Upgrade("product0", "Cursor", 15)]
|
||||
|
||||
def buy(self, upgrade: Upgrade) -> bool:
|
||||
self.calls.append("buy")
|
||||
return self._buy_succeeds
|
||||
|
||||
def save_screenshot(self, path: Path) -> None:
|
||||
self.calls.append("save_screenshot")
|
||||
self.screenshots.append(path)
|
||||
|
||||
def close(self) -> None:
|
||||
self.calls.append("close")
|
||||
|
||||
|
||||
def test_parse_args_uses_defaults() -> None:
|
||||
args = parse_args([])
|
||||
|
||||
assert args.duration == constants.RUN_DURATION_SECONDS == 300
|
||||
assert args.interval == constants.UPGRADE_CHECK_INTERVAL_SECONDS == 5
|
||||
assert args.headless is False
|
||||
assert args.screenshot_start is None
|
||||
assert args.screenshot is None
|
||||
|
||||
|
||||
def test_parse_args_reads_every_option() -> None:
|
||||
args = parse_args(
|
||||
[
|
||||
"--headless",
|
||||
"--duration",
|
||||
"10",
|
||||
"--interval",
|
||||
"2.5",
|
||||
"--screenshot-start",
|
||||
"a.png",
|
||||
"--screenshot",
|
||||
"b.png",
|
||||
]
|
||||
)
|
||||
|
||||
assert args.headless is True
|
||||
assert args.duration == 10
|
||||
assert args.interval == 2.5
|
||||
assert args.screenshot_start == Path("a.png")
|
||||
assert args.screenshot == Path("b.png")
|
||||
|
||||
|
||||
def test_main_opens_dismisses_and_closes() -> None:
|
||||
game = RecordingGame()
|
||||
|
||||
code = main(["--duration", "0"], lambda _headless: game)
|
||||
|
||||
assert code == 0
|
||||
assert game.calls == ["open_game", "dismiss_language", "close"]
|
||||
|
||||
|
||||
def test_main_passes_headless_to_the_factory() -> None:
|
||||
seen: list[bool] = []
|
||||
|
||||
def factory(headless: bool) -> GamePort:
|
||||
seen.append(headless)
|
||||
return RecordingGame()
|
||||
|
||||
main(["--headless", "--duration", "0"], factory)
|
||||
|
||||
assert seen == [True]
|
||||
|
||||
|
||||
def test_main_takes_both_screenshots_when_asked() -> None:
|
||||
game = RecordingGame()
|
||||
|
||||
main(
|
||||
["--duration", "0", "--screenshot-start", "s.png", "--screenshot", "e.png"],
|
||||
lambda _headless: game,
|
||||
)
|
||||
|
||||
assert game.screenshots == [Path("s.png"), Path("e.png")]
|
||||
assert game.calls[-1] == "close"
|
||||
|
||||
|
||||
def test_main_takes_no_screenshot_by_default() -> None:
|
||||
game = RecordingGame()
|
||||
|
||||
main(["--duration", "0"], lambda _headless: game)
|
||||
|
||||
assert game.screenshots == []
|
||||
|
||||
|
||||
def test_main_closes_the_game_when_an_error_is_raised() -> None:
|
||||
game = RecordingGame(fail_on_open=True)
|
||||
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
main(["--duration", "0"], lambda _headless: game)
|
||||
|
||||
assert game.calls == ["open_game", "close"]
|
||||
|
||||
|
||||
def test_main_prints_exactly_one_result_line_on_stdout(
|
||||
capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
caplog.set_level("INFO")
|
||||
|
||||
main(["--duration", "0"], lambda _headless: RecordingGame())
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert out == "cookies/second: 8.5" + chr(10)
|
||||
assert "clicked the cookie" not in out
|
||||
assert "clicked the cookie" in caplog.text
|
||||
|
||||
|
||||
def test_main_logs_bought_only_for_real_purchases(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
caplog.set_level("INFO")
|
||||
|
||||
main(["--duration", "0.05", "--interval", "0.001"], lambda _h: RecordingGame())
|
||||
assert "bought Cursor" in caplog.text
|
||||
|
||||
caplog.clear()
|
||||
game = RecordingGame(buy_succeeds=False)
|
||||
main(["--duration", "0.05", "--interval", "0.001"], lambda _h: game)
|
||||
assert "buy" in game.calls
|
||||
assert "bought Cursor" not in caplog.text
|
||||
assert "bought 0 upgrades" in caplog.text
|
||||
|
||||
|
||||
def test_main_prints_nothing_on_stdout_when_the_run_fails(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
main(["--duration", "0"], lambda _headless: RecordingGame(fail_on_open=True))
|
||||
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
|
||||
def test_parse_args_buys_one_upgrade_unless_buy_all_is_given() -> None:
|
||||
assert parse_args([]).buy_all is False
|
||||
assert parse_args(["--buy-all"]).buy_all is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["0", "-1", "abc", "nan"])
|
||||
def test_parse_args_rejects_an_interval_that_is_not_positive(
|
||||
value: str, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
with pytest.raises(SystemExit) as exit_info:
|
||||
parse_args(["--interval", value])
|
||||
|
||||
assert exit_info.value.code == 2
|
||||
assert "greater than 0" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_main_logs_the_interval_and_the_strategy(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
caplog.set_level("INFO")
|
||||
|
||||
main(["--duration", "0"], lambda _h: RecordingGame())
|
||||
assert "every 5 seconds, strategy buy-one" in caplog.text
|
||||
|
||||
caplog.clear()
|
||||
main(
|
||||
["--duration", "0", "--interval", "2", "--buy-all"], lambda _h: RecordingGame()
|
||||
)
|
||||
assert "every 2.0 seconds, strategy buy-all" in caplog.text
|
||||
|
||||
|
||||
def test_main_buy_all_buys_every_affordable_upgrade_per_check(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
caplog.set_level("INFO")
|
||||
game = RecordingGame()
|
||||
|
||||
main(["--duration", "0.05", "--interval", "0.001", "--buy-all"], lambda _h: game)
|
||||
|
||||
assert "bought Cursor" in caplog.text
|
||||
"""@file test_cli.py
|
||||
@brief Tests of the command line and the run sequence with a fake game.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from cookie_clicker_bot import constants
|
||||
from cookie_clicker_bot.application.ports import GamePort
|
||||
from cookie_clicker_bot.cli import main, parse_args
|
||||
from cookie_clicker_bot.domain.upgrades import Upgrade
|
||||
|
||||
|
||||
class RecordingGame:
|
||||
"""A game that records every call in order."""
|
||||
|
||||
def __init__(self, fail_on_open: bool = False, buy_succeeds: bool = True) -> None:
|
||||
self._buy_succeeds = buy_succeeds
|
||||
self.calls: list[str] = []
|
||||
self.screenshots: list[Path] = []
|
||||
self._fail_on_open = fail_on_open
|
||||
|
||||
def open_game(self) -> None:
|
||||
self.calls.append("open_game")
|
||||
if self._fail_on_open:
|
||||
message = "boom"
|
||||
raise RuntimeError(message)
|
||||
|
||||
def dismiss_language(self) -> None:
|
||||
self.calls.append("dismiss_language")
|
||||
|
||||
def click_cookie(self) -> None:
|
||||
self.calls.append("click_cookie")
|
||||
|
||||
def cookie_count(self) -> int:
|
||||
return 1000
|
||||
|
||||
def cookies_per_second(self) -> float:
|
||||
return 8.5
|
||||
|
||||
def upgrades(self) -> list[Upgrade]:
|
||||
return [Upgrade("product0", "Cursor", 15)]
|
||||
|
||||
def buy(self, upgrade: Upgrade) -> bool:
|
||||
self.calls.append("buy")
|
||||
return self._buy_succeeds
|
||||
|
||||
def save_screenshot(self, path: Path) -> None:
|
||||
self.calls.append("save_screenshot")
|
||||
self.screenshots.append(path)
|
||||
|
||||
def close(self) -> None:
|
||||
self.calls.append("close")
|
||||
|
||||
|
||||
def test_parse_args_uses_defaults() -> None:
|
||||
args = parse_args([])
|
||||
|
||||
assert args.duration == constants.RUN_DURATION_SECONDS == 300
|
||||
assert args.interval == constants.UPGRADE_CHECK_INTERVAL_SECONDS == 5
|
||||
assert args.headless is False
|
||||
assert args.screenshot_start is None
|
||||
assert args.screenshot is None
|
||||
|
||||
|
||||
def test_parse_args_reads_every_option() -> None:
|
||||
args = parse_args(
|
||||
[
|
||||
"--headless",
|
||||
"--duration",
|
||||
"10",
|
||||
"--interval",
|
||||
"2.5",
|
||||
"--screenshot-start",
|
||||
"a.png",
|
||||
"--screenshot",
|
||||
"b.png",
|
||||
]
|
||||
)
|
||||
|
||||
assert args.headless is True
|
||||
assert args.duration == 10
|
||||
assert args.interval == 2.5
|
||||
assert args.screenshot_start == Path("a.png")
|
||||
assert args.screenshot == Path("b.png")
|
||||
|
||||
|
||||
def test_main_opens_dismisses_and_closes() -> None:
|
||||
game = RecordingGame()
|
||||
|
||||
code = main(["--duration", "0"], lambda _headless: game)
|
||||
|
||||
assert code == 0
|
||||
assert game.calls == ["open_game", "dismiss_language", "close"]
|
||||
|
||||
|
||||
def test_main_passes_headless_to_the_factory() -> None:
|
||||
seen: list[bool] = []
|
||||
|
||||
def factory(headless: bool) -> GamePort:
|
||||
seen.append(headless)
|
||||
return RecordingGame()
|
||||
|
||||
main(["--headless", "--duration", "0"], factory)
|
||||
|
||||
assert seen == [True]
|
||||
|
||||
|
||||
def test_main_takes_both_screenshots_when_asked() -> None:
|
||||
game = RecordingGame()
|
||||
|
||||
main(
|
||||
["--duration", "0", "--screenshot-start", "s.png", "--screenshot", "e.png"],
|
||||
lambda _headless: game,
|
||||
)
|
||||
|
||||
assert game.screenshots == [Path("s.png"), Path("e.png")]
|
||||
assert game.calls[-1] == "close"
|
||||
|
||||
|
||||
def test_main_takes_no_screenshot_by_default() -> None:
|
||||
game = RecordingGame()
|
||||
|
||||
main(["--duration", "0"], lambda _headless: game)
|
||||
|
||||
assert game.screenshots == []
|
||||
|
||||
|
||||
def test_main_closes_the_game_when_an_error_is_raised() -> None:
|
||||
game = RecordingGame(fail_on_open=True)
|
||||
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
main(["--duration", "0"], lambda _headless: game)
|
||||
|
||||
assert game.calls == ["open_game", "close"]
|
||||
|
||||
|
||||
def test_main_prints_exactly_one_result_line_on_stdout(
|
||||
capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
caplog.set_level("INFO")
|
||||
|
||||
main(["--duration", "0"], lambda _headless: RecordingGame())
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert out == "cookies/second: 8.5" + chr(10)
|
||||
assert "clicked the cookie" not in out
|
||||
assert "clicked the cookie" in caplog.text
|
||||
|
||||
|
||||
def test_main_logs_bought_only_for_real_purchases(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
caplog.set_level("INFO")
|
||||
|
||||
main(["--duration", "0.05", "--interval", "0.001"], lambda _h: RecordingGame())
|
||||
assert "bought Cursor" in caplog.text
|
||||
|
||||
caplog.clear()
|
||||
game = RecordingGame(buy_succeeds=False)
|
||||
main(["--duration", "0.05", "--interval", "0.001"], lambda _h: game)
|
||||
assert "buy" in game.calls
|
||||
assert "bought Cursor" not in caplog.text
|
||||
assert "bought 0 upgrades" in caplog.text
|
||||
|
||||
|
||||
def test_main_prints_nothing_on_stdout_when_the_run_fails(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
main(["--duration", "0"], lambda _headless: RecordingGame(fail_on_open=True))
|
||||
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
|
||||
def test_parse_args_buys_one_upgrade_unless_buy_all_is_given() -> None:
|
||||
assert parse_args([]).buy_all is False
|
||||
assert parse_args(["--buy-all"]).buy_all is True
|
||||
|
||||
|
||||
def test_parse_args_best_value_is_off_by_default() -> None:
|
||||
assert parse_args([]).best_value is False
|
||||
assert parse_args(["--best-value"]).best_value is True
|
||||
|
||||
|
||||
def test_parse_args_rejects_buy_all_together_with_best_value(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
with pytest.raises(SystemExit) as exit_info:
|
||||
parse_args(["--buy-all", "--best-value"])
|
||||
|
||||
assert exit_info.value.code == 2
|
||||
assert "not allowed with" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_main_logs_the_best_value_strategy(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
caplog.set_level("INFO")
|
||||
|
||||
main(["--duration", "0", "--best-value"], lambda _h: RecordingGame())
|
||||
|
||||
assert "strategy best-value" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["0", "-1", "abc", "nan"])
|
||||
def test_parse_args_rejects_an_interval_that_is_not_positive(
|
||||
value: str, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
with pytest.raises(SystemExit) as exit_info:
|
||||
parse_args(["--interval", value])
|
||||
|
||||
assert exit_info.value.code == 2
|
||||
assert "greater than 0" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_main_logs_the_interval_and_the_strategy(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
caplog.set_level("INFO")
|
||||
|
||||
main(["--duration", "0"], lambda _h: RecordingGame())
|
||||
assert "every 5 seconds, strategy buy-one" in caplog.text
|
||||
|
||||
caplog.clear()
|
||||
main(
|
||||
["--duration", "0", "--interval", "2", "--buy-all"], lambda _h: RecordingGame()
|
||||
)
|
||||
assert "every 2.0 seconds, strategy buy-all" in caplog.text
|
||||
|
||||
|
||||
def test_main_buy_all_buys_every_affordable_upgrade_per_check(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
caplog.set_level("INFO")
|
||||
game = RecordingGame()
|
||||
|
||||
main(["--duration", "0.05", "--interval", "0.001", "--buy-all"], lambda _h: game)
|
||||
|
||||
assert "bought Cursor" in caplog.text
|
||||
|
||||
+282
-250
@@ -1,250 +1,282 @@
|
||||
"""@file test_game_loop.py
|
||||
@brief Tests of the game loop with a fake game and a fake clock.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from cookie_clicker_bot.application.game_loop import (
|
||||
PlayResult,
|
||||
buy_most_expensive_upgrade,
|
||||
buy_upgrades,
|
||||
click_and_buy,
|
||||
click_cookie,
|
||||
)
|
||||
from cookie_clicker_bot.domain.buy_strategy import BuyStrategy
|
||||
from cookie_clicker_bot.domain.upgrades import Upgrade
|
||||
|
||||
CURSOR = Upgrade("product0", "Cursor", 15)
|
||||
GRANDMA = Upgrade("product1", "Grandma", 100)
|
||||
|
||||
|
||||
class FakeClock:
|
||||
"""A clock that only moves when the fake game is clicked."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.now = 0.0
|
||||
|
||||
def __call__(self) -> float:
|
||||
return self.now
|
||||
|
||||
|
||||
class FakeGame:
|
||||
"""A game with a cookie count and upgrades; each click takes one second."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cookies: int = 0,
|
||||
upgrades: list[Upgrade] | None = None,
|
||||
clock: FakeClock | None = None,
|
||||
buy_succeeds: bool = True,
|
||||
) -> None:
|
||||
self.buy_succeeds = buy_succeeds
|
||||
self.clicks = 0
|
||||
self.cookies = cookies
|
||||
self.available = upgrades or []
|
||||
self.bought: list[Upgrade] = []
|
||||
self.buy_times: list[float] = []
|
||||
self._clock = clock
|
||||
|
||||
def open_game(self) -> None:
|
||||
pass
|
||||
|
||||
def dismiss_language(self) -> None:
|
||||
pass
|
||||
|
||||
def click_cookie(self) -> None:
|
||||
self.clicks += 1
|
||||
if self._clock is not None:
|
||||
self._clock.now += 1.0
|
||||
|
||||
def cookie_count(self) -> int:
|
||||
return self.cookies
|
||||
|
||||
def cookies_per_second(self) -> float:
|
||||
return 0.0
|
||||
|
||||
def upgrades(self) -> list[Upgrade]:
|
||||
return self.available
|
||||
|
||||
def buy(self, upgrade: Upgrade) -> bool:
|
||||
self.bought.append(upgrade)
|
||||
if self._clock is not None:
|
||||
self.buy_times.append(self._clock.now)
|
||||
return self.buy_succeeds
|
||||
|
||||
def save_screenshot(self, path: Path) -> None:
|
||||
pass
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def test_click_cookie_delegates_to_the_game() -> None:
|
||||
game = FakeGame()
|
||||
|
||||
click_cookie(game)
|
||||
|
||||
assert game.clicks == 1
|
||||
|
||||
|
||||
def test_buy_most_expensive_upgrade_buys_the_best_affordable() -> None:
|
||||
game = FakeGame(cookies=105, upgrades=[CURSOR, GRANDMA])
|
||||
|
||||
bought = buy_most_expensive_upgrade(game)
|
||||
|
||||
assert bought == GRANDMA
|
||||
assert game.bought == [GRANDMA]
|
||||
|
||||
|
||||
def test_buy_most_expensive_upgrade_buys_nothing_when_too_poor() -> None:
|
||||
game = FakeGame(cookies=10, upgrades=[CURSOR, GRANDMA])
|
||||
|
||||
assert buy_most_expensive_upgrade(game) is None
|
||||
assert game.bought == []
|
||||
|
||||
|
||||
def test_click_and_buy_checks_the_upgrades_every_interval() -> None:
|
||||
clock = FakeClock()
|
||||
game = FakeGame(cookies=105, upgrades=[CURSOR, GRANDMA], clock=clock)
|
||||
|
||||
result = click_and_buy(game, 12, 5, clock)
|
||||
|
||||
assert result == PlayResult(
|
||||
clicks=12, purchases=("Grandma", "Grandma"), elapsed_seconds=12.0
|
||||
)
|
||||
# The checks fall in the iterations that start at 5 s and 10 s; each click then
|
||||
# moves the fake clock on by one second.
|
||||
assert game.buy_times == [6.0, 11.0]
|
||||
|
||||
|
||||
def test_click_and_buy_does_not_check_before_the_first_interval() -> None:
|
||||
clock = FakeClock()
|
||||
game = FakeGame(cookies=105, upgrades=[GRANDMA], clock=clock)
|
||||
|
||||
result = click_and_buy(game, 4, 5, clock)
|
||||
|
||||
assert result == PlayResult(clicks=4, purchases=(), elapsed_seconds=4.0)
|
||||
assert game.bought == []
|
||||
|
||||
|
||||
def test_click_and_buy_records_only_real_purchases() -> None:
|
||||
clock = FakeClock()
|
||||
game = FakeGame(cookies=10, upgrades=[CURSOR, GRANDMA], clock=clock)
|
||||
|
||||
result = click_and_buy(game, 11, 5, clock)
|
||||
|
||||
assert result.clicks == 11
|
||||
assert result.purchases == ()
|
||||
|
||||
|
||||
def test_buy_most_expensive_upgrade_returns_none_when_the_click_fails() -> None:
|
||||
game = FakeGame(cookies=105, upgrades=[CURSOR, GRANDMA], buy_succeeds=False)
|
||||
|
||||
assert buy_most_expensive_upgrade(game) is None
|
||||
assert game.bought == [GRANDMA]
|
||||
|
||||
|
||||
def test_click_and_buy_does_not_record_a_purchase_when_the_click_fails() -> None:
|
||||
clock = FakeClock()
|
||||
game = FakeGame(cookies=105, upgrades=[GRANDMA], clock=clock, buy_succeeds=False)
|
||||
|
||||
result = click_and_buy(game, 12, 5, clock)
|
||||
|
||||
assert game.buy_times == [6.0, 11.0]
|
||||
assert result.purchases == ()
|
||||
|
||||
|
||||
def test_click_and_buy_does_not_click_when_duration_is_zero() -> None:
|
||||
clock = FakeClock()
|
||||
game = FakeGame(clock=clock)
|
||||
|
||||
assert click_and_buy(game, 0, 5, clock) == PlayResult(
|
||||
clicks=0, purchases=(), elapsed_seconds=0.0
|
||||
)
|
||||
|
||||
|
||||
def test_click_and_buy_stops_at_the_deadline_and_reports_elapsed_time() -> None:
|
||||
clock = FakeClock()
|
||||
game = FakeGame(clock=clock)
|
||||
|
||||
result = click_and_buy(game, 300, 5, clock)
|
||||
|
||||
assert result.clicks == 300
|
||||
assert result.elapsed_seconds == 300.0
|
||||
|
||||
|
||||
def test_buy_upgrades_buy_one_buys_only_the_best_affordable() -> None:
|
||||
game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA])
|
||||
|
||||
assert buy_upgrades(game, BuyStrategy.BUY_ONE) == [GRANDMA]
|
||||
assert game.bought == [GRANDMA]
|
||||
|
||||
|
||||
def test_buy_upgrades_buy_all_buys_every_affordable_upgrade() -> None:
|
||||
game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA])
|
||||
|
||||
assert buy_upgrades(game, BuyStrategy.BUY_ALL) == [GRANDMA, CURSOR]
|
||||
assert game.bought == [GRANDMA, CURSOR]
|
||||
|
||||
|
||||
def test_buy_upgrades_buy_all_buys_nothing_when_too_poor() -> None:
|
||||
game = FakeGame(cookies=10, upgrades=[CURSOR, GRANDMA])
|
||||
|
||||
assert buy_upgrades(game, BuyStrategy.BUY_ALL) == []
|
||||
assert game.bought == []
|
||||
|
||||
|
||||
def test_buy_upgrades_buy_all_leaves_out_failed_buys() -> None:
|
||||
game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA], buy_succeeds=False)
|
||||
|
||||
assert buy_upgrades(game, BuyStrategy.BUY_ALL) == []
|
||||
assert game.bought == [GRANDMA, CURSOR]
|
||||
|
||||
|
||||
def test_click_and_buy_defaults_to_buying_one_upgrade() -> None:
|
||||
clock = FakeClock()
|
||||
game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA], clock=clock)
|
||||
|
||||
result = click_and_buy(game, 6, 5, clock)
|
||||
|
||||
assert result.purchases == ("Grandma",)
|
||||
|
||||
|
||||
def test_click_and_buy_buy_all_records_every_purchase_in_order() -> None:
|
||||
clock = FakeClock()
|
||||
game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA], clock=clock)
|
||||
|
||||
result = click_and_buy(game, 6, 5, clock, BuyStrategy.BUY_ALL)
|
||||
|
||||
assert result.purchases == ("Grandma", "Cursor")
|
||||
|
||||
|
||||
def test_click_and_buy_buy_all_does_not_record_failed_buys() -> None:
|
||||
clock = FakeClock()
|
||||
game = FakeGame(
|
||||
cookies=120, upgrades=[CURSOR, GRANDMA], clock=clock, buy_succeeds=False
|
||||
)
|
||||
|
||||
result = click_and_buy(game, 6, 5, clock, BuyStrategy.BUY_ALL)
|
||||
|
||||
assert game.bought == [GRANDMA, CURSOR]
|
||||
assert result.purchases == ()
|
||||
|
||||
|
||||
def test_click_and_buy_checks_every_second_with_a_one_second_interval() -> None:
|
||||
clock = FakeClock()
|
||||
game = FakeGame(cookies=105, upgrades=[GRANDMA], clock=clock)
|
||||
|
||||
click_and_buy(game, 4, 1, clock)
|
||||
|
||||
assert game.buy_times == [2.0, 3.0, 4.0]
|
||||
|
||||
|
||||
def test_click_and_buy_checks_every_ten_seconds_with_a_ten_second_interval() -> None:
|
||||
clock = FakeClock()
|
||||
game = FakeGame(cookies=105, upgrades=[GRANDMA], clock=clock)
|
||||
|
||||
result = click_and_buy(game, 25, 10, clock)
|
||||
|
||||
assert game.buy_times == [11.0, 21.0]
|
||||
assert result.purchases == ("Grandma", "Grandma")
|
||||
"""@file test_game_loop.py
|
||||
@brief Tests of the game loop with a fake game and a fake clock.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from cookie_clicker_bot.application.game_loop import (
|
||||
PlayResult,
|
||||
buy_best_value_upgrade,
|
||||
buy_most_expensive_upgrade,
|
||||
buy_upgrades,
|
||||
click_and_buy,
|
||||
click_cookie,
|
||||
)
|
||||
from cookie_clicker_bot.domain.buy_strategy import BuyStrategy
|
||||
from cookie_clicker_bot.domain.upgrades import Upgrade
|
||||
|
||||
VALUE_CURSOR = Upgrade("product0", "Cursor", 15, 0.1)
|
||||
VALUE_GRANDMA = Upgrade("product1", "Grandma", 100, 1.0)
|
||||
|
||||
CURSOR = Upgrade("product0", "Cursor", 15)
|
||||
GRANDMA = Upgrade("product1", "Grandma", 100)
|
||||
|
||||
|
||||
class FakeClock:
|
||||
"""A clock that only moves when the fake game is clicked."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.now = 0.0
|
||||
|
||||
def __call__(self) -> float:
|
||||
return self.now
|
||||
|
||||
|
||||
class FakeGame:
|
||||
"""A game with a cookie count and upgrades; each click takes one second."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cookies: int = 0,
|
||||
upgrades: list[Upgrade] | None = None,
|
||||
clock: FakeClock | None = None,
|
||||
buy_succeeds: bool = True,
|
||||
) -> None:
|
||||
self.buy_succeeds = buy_succeeds
|
||||
self.clicks = 0
|
||||
self.cookies = cookies
|
||||
self.available = upgrades or []
|
||||
self.bought: list[Upgrade] = []
|
||||
self.buy_times: list[float] = []
|
||||
self._clock = clock
|
||||
|
||||
def open_game(self) -> None:
|
||||
pass
|
||||
|
||||
def dismiss_language(self) -> None:
|
||||
pass
|
||||
|
||||
def click_cookie(self) -> None:
|
||||
self.clicks += 1
|
||||
if self._clock is not None:
|
||||
self._clock.now += 1.0
|
||||
|
||||
def cookie_count(self) -> int:
|
||||
return self.cookies
|
||||
|
||||
def cookies_per_second(self) -> float:
|
||||
return 0.0
|
||||
|
||||
def upgrades(self) -> list[Upgrade]:
|
||||
return self.available
|
||||
|
||||
def buy(self, upgrade: Upgrade) -> bool:
|
||||
self.bought.append(upgrade)
|
||||
if self._clock is not None:
|
||||
self.buy_times.append(self._clock.now)
|
||||
return self.buy_succeeds
|
||||
|
||||
def save_screenshot(self, path: Path) -> None:
|
||||
pass
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def test_click_cookie_delegates_to_the_game() -> None:
|
||||
game = FakeGame()
|
||||
|
||||
click_cookie(game)
|
||||
|
||||
assert game.clicks == 1
|
||||
|
||||
|
||||
def test_buy_most_expensive_upgrade_buys_the_best_affordable() -> None:
|
||||
game = FakeGame(cookies=105, upgrades=[CURSOR, GRANDMA])
|
||||
|
||||
bought = buy_most_expensive_upgrade(game)
|
||||
|
||||
assert bought == GRANDMA
|
||||
assert game.bought == [GRANDMA]
|
||||
|
||||
|
||||
def test_buy_most_expensive_upgrade_buys_nothing_when_too_poor() -> None:
|
||||
game = FakeGame(cookies=10, upgrades=[CURSOR, GRANDMA])
|
||||
|
||||
assert buy_most_expensive_upgrade(game) is None
|
||||
assert game.bought == []
|
||||
|
||||
|
||||
def test_click_and_buy_checks_the_upgrades_every_interval() -> None:
|
||||
clock = FakeClock()
|
||||
game = FakeGame(cookies=105, upgrades=[CURSOR, GRANDMA], clock=clock)
|
||||
|
||||
result = click_and_buy(game, 12, 5, clock)
|
||||
|
||||
assert result == PlayResult(
|
||||
clicks=12, purchases=("Grandma", "Grandma"), elapsed_seconds=12.0
|
||||
)
|
||||
# The checks fall in the iterations that start at 5 s and 10 s; each click then
|
||||
# moves the fake clock on by one second.
|
||||
assert game.buy_times == [6.0, 11.0]
|
||||
|
||||
|
||||
def test_click_and_buy_does_not_check_before_the_first_interval() -> None:
|
||||
clock = FakeClock()
|
||||
game = FakeGame(cookies=105, upgrades=[GRANDMA], clock=clock)
|
||||
|
||||
result = click_and_buy(game, 4, 5, clock)
|
||||
|
||||
assert result == PlayResult(clicks=4, purchases=(), elapsed_seconds=4.0)
|
||||
assert game.bought == []
|
||||
|
||||
|
||||
def test_click_and_buy_records_only_real_purchases() -> None:
|
||||
clock = FakeClock()
|
||||
game = FakeGame(cookies=10, upgrades=[CURSOR, GRANDMA], clock=clock)
|
||||
|
||||
result = click_and_buy(game, 11, 5, clock)
|
||||
|
||||
assert result.clicks == 11
|
||||
assert result.purchases == ()
|
||||
|
||||
|
||||
def test_buy_most_expensive_upgrade_returns_none_when_the_click_fails() -> None:
|
||||
game = FakeGame(cookies=105, upgrades=[CURSOR, GRANDMA], buy_succeeds=False)
|
||||
|
||||
assert buy_most_expensive_upgrade(game) is None
|
||||
assert game.bought == [GRANDMA]
|
||||
|
||||
|
||||
def test_click_and_buy_does_not_record_a_purchase_when_the_click_fails() -> None:
|
||||
clock = FakeClock()
|
||||
game = FakeGame(cookies=105, upgrades=[GRANDMA], clock=clock, buy_succeeds=False)
|
||||
|
||||
result = click_and_buy(game, 12, 5, clock)
|
||||
|
||||
assert game.buy_times == [6.0, 11.0]
|
||||
assert result.purchases == ()
|
||||
|
||||
|
||||
def test_click_and_buy_does_not_click_when_duration_is_zero() -> None:
|
||||
clock = FakeClock()
|
||||
game = FakeGame(clock=clock)
|
||||
|
||||
assert click_and_buy(game, 0, 5, clock) == PlayResult(
|
||||
clicks=0, purchases=(), elapsed_seconds=0.0
|
||||
)
|
||||
|
||||
|
||||
def test_click_and_buy_stops_at_the_deadline_and_reports_elapsed_time() -> None:
|
||||
clock = FakeClock()
|
||||
game = FakeGame(clock=clock)
|
||||
|
||||
result = click_and_buy(game, 300, 5, clock)
|
||||
|
||||
assert result.clicks == 300
|
||||
assert result.elapsed_seconds == 300.0
|
||||
|
||||
|
||||
def test_buy_upgrades_buy_one_buys_only_the_best_affordable() -> None:
|
||||
game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA])
|
||||
|
||||
assert buy_upgrades(game, BuyStrategy.BUY_ONE) == [GRANDMA]
|
||||
assert game.bought == [GRANDMA]
|
||||
|
||||
|
||||
def test_buy_upgrades_buy_all_buys_every_affordable_upgrade() -> None:
|
||||
game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA])
|
||||
|
||||
assert buy_upgrades(game, BuyStrategy.BUY_ALL) == [GRANDMA, CURSOR]
|
||||
assert game.bought == [GRANDMA, CURSOR]
|
||||
|
||||
|
||||
def test_buy_upgrades_buy_all_buys_nothing_when_too_poor() -> None:
|
||||
game = FakeGame(cookies=10, upgrades=[CURSOR, GRANDMA])
|
||||
|
||||
assert buy_upgrades(game, BuyStrategy.BUY_ALL) == []
|
||||
assert game.bought == []
|
||||
|
||||
|
||||
def test_buy_upgrades_buy_all_leaves_out_failed_buys() -> None:
|
||||
game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA], buy_succeeds=False)
|
||||
|
||||
assert buy_upgrades(game, BuyStrategy.BUY_ALL) == []
|
||||
assert game.bought == [GRANDMA, CURSOR]
|
||||
|
||||
|
||||
def test_click_and_buy_defaults_to_buying_one_upgrade() -> None:
|
||||
clock = FakeClock()
|
||||
game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA], clock=clock)
|
||||
|
||||
result = click_and_buy(game, 6, 5, clock)
|
||||
|
||||
assert result.purchases == ("Grandma",)
|
||||
|
||||
|
||||
def test_click_and_buy_buy_all_records_every_purchase_in_order() -> None:
|
||||
clock = FakeClock()
|
||||
game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA], clock=clock)
|
||||
|
||||
result = click_and_buy(game, 6, 5, clock, BuyStrategy.BUY_ALL)
|
||||
|
||||
assert result.purchases == ("Grandma", "Cursor")
|
||||
|
||||
|
||||
def test_click_and_buy_buy_all_does_not_record_failed_buys() -> None:
|
||||
clock = FakeClock()
|
||||
game = FakeGame(
|
||||
cookies=120, upgrades=[CURSOR, GRANDMA], clock=clock, buy_succeeds=False
|
||||
)
|
||||
|
||||
result = click_and_buy(game, 6, 5, clock, BuyStrategy.BUY_ALL)
|
||||
|
||||
assert game.bought == [GRANDMA, CURSOR]
|
||||
assert result.purchases == ()
|
||||
|
||||
|
||||
def test_click_and_buy_checks_every_second_with_a_one_second_interval() -> None:
|
||||
clock = FakeClock()
|
||||
game = FakeGame(cookies=105, upgrades=[GRANDMA], clock=clock)
|
||||
|
||||
click_and_buy(game, 4, 1, clock)
|
||||
|
||||
assert game.buy_times == [2.0, 3.0, 4.0]
|
||||
|
||||
|
||||
def test_click_and_buy_checks_every_ten_seconds_with_a_ten_second_interval() -> None:
|
||||
clock = FakeClock()
|
||||
game = FakeGame(cookies=105, upgrades=[GRANDMA], clock=clock)
|
||||
|
||||
result = click_and_buy(game, 25, 10, clock)
|
||||
|
||||
assert game.buy_times == [11.0, 21.0]
|
||||
assert result.purchases == ("Grandma", "Grandma")
|
||||
|
||||
|
||||
def test_buy_best_value_upgrade_buys_the_cheapest_per_cookie_per_second() -> None:
|
||||
game = FakeGame(cookies=500, upgrades=[VALUE_CURSOR, VALUE_GRANDMA])
|
||||
|
||||
bought = buy_best_value_upgrade(game)
|
||||
|
||||
assert bought == VALUE_GRANDMA
|
||||
assert game.bought == [VALUE_GRANDMA]
|
||||
|
||||
|
||||
def test_buy_best_value_upgrade_buys_nothing_when_too_poor() -> None:
|
||||
game = FakeGame(cookies=10, upgrades=[VALUE_CURSOR, VALUE_GRANDMA])
|
||||
|
||||
assert buy_best_value_upgrade(game) is None
|
||||
assert game.bought == []
|
||||
|
||||
|
||||
def test_buy_best_value_upgrade_leaves_out_a_click_that_missed() -> None:
|
||||
game = FakeGame(cookies=500, upgrades=[VALUE_GRANDMA], buy_succeeds=False)
|
||||
|
||||
assert buy_best_value_upgrade(game) is None
|
||||
|
||||
|
||||
def test_buy_upgrades_with_best_value_buys_one_upgrade() -> None:
|
||||
game = FakeGame(cookies=500, upgrades=[VALUE_CURSOR, VALUE_GRANDMA])
|
||||
|
||||
assert buy_upgrades(game, BuyStrategy.BEST_VALUE) == [VALUE_GRANDMA]
|
||||
|
||||
+114
-70
@@ -1,70 +1,114 @@
|
||||
"""@file test_upgrades.py
|
||||
@brief Tests of the rule that picks the upgrade to buy.
|
||||
"""
|
||||
|
||||
from cookie_clicker_bot.domain.upgrades import (
|
||||
Upgrade,
|
||||
choose_affordable_upgrades,
|
||||
choose_most_expensive_affordable,
|
||||
)
|
||||
|
||||
CURSOR = Upgrade("product0", "Cursor", 15)
|
||||
GRANDMA = Upgrade("product1", "Grandma", 100)
|
||||
FARM = Upgrade("product2", "Farm", 1100)
|
||||
|
||||
|
||||
def test_choose_picks_the_most_expensive_affordable() -> None:
|
||||
assert choose_most_expensive_affordable([CURSOR, GRANDMA, FARM], 105) == GRANDMA
|
||||
|
||||
|
||||
def test_choose_returns_none_when_nothing_is_affordable() -> None:
|
||||
assert choose_most_expensive_affordable([CURSOR, GRANDMA], 10) is None
|
||||
|
||||
|
||||
def test_choose_returns_none_without_upgrades() -> None:
|
||||
assert choose_most_expensive_affordable([], 1000) is None
|
||||
|
||||
|
||||
def test_choose_accepts_a_price_equal_to_the_cookies() -> None:
|
||||
assert choose_most_expensive_affordable([CURSOR, GRANDMA], 100) == GRANDMA
|
||||
|
||||
|
||||
def test_choose_takes_the_first_on_a_tie() -> None:
|
||||
twin = Upgrade("product9", "Twin", 100)
|
||||
|
||||
assert choose_most_expensive_affordable([GRANDMA, twin], 500) == GRANDMA
|
||||
|
||||
|
||||
def test_choose_ignores_the_order_of_the_list() -> None:
|
||||
assert choose_most_expensive_affordable([GRANDMA, CURSOR], 105) == GRANDMA
|
||||
|
||||
|
||||
def test_plan_skips_an_upgrade_the_remaining_cookies_cannot_pay() -> None:
|
||||
assert choose_affordable_upgrades([CURSOR, GRANDMA, FARM], 105) == [GRANDMA]
|
||||
|
||||
|
||||
def test_plan_buys_several_upgrades_most_expensive_first() -> None:
|
||||
assert choose_affordable_upgrades([CURSOR, GRANDMA, FARM], 120) == [GRANDMA, CURSOR]
|
||||
|
||||
|
||||
def test_plan_is_empty_when_nothing_is_affordable() -> None:
|
||||
assert choose_affordable_upgrades([CURSOR, GRANDMA], 10) == []
|
||||
|
||||
|
||||
def test_plan_is_empty_without_upgrades() -> None:
|
||||
assert choose_affordable_upgrades([], 1000) == []
|
||||
|
||||
|
||||
def test_plan_spends_the_whole_budget_on_equal_prices() -> None:
|
||||
twin = Upgrade("product9", "Twin", 100)
|
||||
|
||||
assert choose_affordable_upgrades([GRANDMA, twin], 250) == [GRANDMA, twin]
|
||||
assert choose_affordable_upgrades([twin, GRANDMA], 150) == [twin]
|
||||
|
||||
|
||||
def test_plan_does_not_change_the_given_list() -> None:
|
||||
upgrades = [CURSOR, GRANDMA]
|
||||
|
||||
choose_affordable_upgrades(upgrades, 1000)
|
||||
|
||||
assert upgrades == [CURSOR, GRANDMA]
|
||||
"""@file test_upgrades.py
|
||||
@brief Tests of the rule that picks the upgrade to buy.
|
||||
"""
|
||||
|
||||
from cookie_clicker_bot.domain.upgrades import (
|
||||
Upgrade,
|
||||
choose_affordable_upgrades,
|
||||
choose_best_value,
|
||||
choose_most_expensive_affordable,
|
||||
)
|
||||
|
||||
CURSOR = Upgrade("product0", "Cursor", 15)
|
||||
GRANDMA = Upgrade("product1", "Grandma", 100)
|
||||
FARM = Upgrade("product2", "Farm", 1100)
|
||||
|
||||
|
||||
def test_choose_picks_the_most_expensive_affordable() -> None:
|
||||
assert choose_most_expensive_affordable([CURSOR, GRANDMA, FARM], 105) == GRANDMA
|
||||
|
||||
|
||||
def test_choose_returns_none_when_nothing_is_affordable() -> None:
|
||||
assert choose_most_expensive_affordable([CURSOR, GRANDMA], 10) is None
|
||||
|
||||
|
||||
def test_choose_returns_none_without_upgrades() -> None:
|
||||
assert choose_most_expensive_affordable([], 1000) is None
|
||||
|
||||
|
||||
def test_choose_accepts_a_price_equal_to_the_cookies() -> None:
|
||||
assert choose_most_expensive_affordable([CURSOR, GRANDMA], 100) == GRANDMA
|
||||
|
||||
|
||||
def test_choose_takes_the_first_on_a_tie() -> None:
|
||||
twin = Upgrade("product9", "Twin", 100)
|
||||
|
||||
assert choose_most_expensive_affordable([GRANDMA, twin], 500) == GRANDMA
|
||||
|
||||
|
||||
def test_choose_ignores_the_order_of_the_list() -> None:
|
||||
assert choose_most_expensive_affordable([GRANDMA, CURSOR], 105) == GRANDMA
|
||||
|
||||
|
||||
def test_plan_skips_an_upgrade_the_remaining_cookies_cannot_pay() -> None:
|
||||
assert choose_affordable_upgrades([CURSOR, GRANDMA, FARM], 105) == [GRANDMA]
|
||||
|
||||
|
||||
def test_plan_buys_several_upgrades_most_expensive_first() -> None:
|
||||
assert choose_affordable_upgrades([CURSOR, GRANDMA, FARM], 120) == [GRANDMA, CURSOR]
|
||||
|
||||
|
||||
def test_plan_is_empty_when_nothing_is_affordable() -> None:
|
||||
assert choose_affordable_upgrades([CURSOR, GRANDMA], 10) == []
|
||||
|
||||
|
||||
def test_plan_is_empty_without_upgrades() -> None:
|
||||
assert choose_affordable_upgrades([], 1000) == []
|
||||
|
||||
|
||||
def test_plan_spends_the_whole_budget_on_equal_prices() -> None:
|
||||
twin = Upgrade("product9", "Twin", 100)
|
||||
|
||||
assert choose_affordable_upgrades([GRANDMA, twin], 250) == [GRANDMA, twin]
|
||||
assert choose_affordable_upgrades([twin, GRANDMA], 150) == [twin]
|
||||
|
||||
|
||||
def test_plan_does_not_change_the_given_list() -> None:
|
||||
upgrades = [CURSOR, GRANDMA]
|
||||
|
||||
choose_affordable_upgrades(upgrades, 1000)
|
||||
|
||||
assert upgrades == [CURSOR, GRANDMA]
|
||||
|
||||
|
||||
VALUE_CURSOR = Upgrade("product0", "Cursor", 15, 0.1)
|
||||
VALUE_GRANDMA = Upgrade("product1", "Grandma", 100, 1.0)
|
||||
VALUE_FARM = Upgrade("product2", "Farm", 1100, 8.0)
|
||||
|
||||
|
||||
def test_best_value_picks_the_lowest_price_per_cookie_per_second() -> None:
|
||||
upgrades = [VALUE_CURSOR, VALUE_GRANDMA, VALUE_FARM]
|
||||
|
||||
assert choose_best_value(upgrades, 2000) == VALUE_GRANDMA
|
||||
|
||||
|
||||
def test_best_value_ignores_what_the_cookies_cannot_pay() -> None:
|
||||
upgrades = [VALUE_CURSOR, VALUE_GRANDMA, VALUE_FARM]
|
||||
|
||||
assert choose_best_value(upgrades, 50) == VALUE_CURSOR
|
||||
|
||||
|
||||
def test_best_value_returns_none_when_nothing_is_affordable() -> None:
|
||||
assert choose_best_value([VALUE_CURSOR, VALUE_GRANDMA], 10) is None
|
||||
|
||||
|
||||
def test_best_value_returns_none_without_upgrades() -> None:
|
||||
assert choose_best_value([], 1000) is None
|
||||
|
||||
|
||||
def test_best_value_takes_the_first_on_a_tie() -> None:
|
||||
twin = Upgrade("product9", "Twin", 100, 1.0)
|
||||
|
||||
assert choose_best_value([VALUE_GRANDMA, twin], 500) == VALUE_GRANDMA
|
||||
|
||||
|
||||
def test_best_value_skips_an_upgrade_with_zero_production() -> None:
|
||||
idle = Upgrade("product9", "Idle", 10, 0.0)
|
||||
|
||||
assert choose_best_value([idle, VALUE_GRANDMA], 500) == VALUE_GRANDMA
|
||||
|
||||
|
||||
def test_best_value_falls_back_to_the_most_expensive_when_production_is_unknown() -> (
|
||||
None
|
||||
):
|
||||
assert choose_best_value([CURSOR, GRANDMA, FARM], 1200) == FARM
|
||||
|
||||
Reference in New Issue
Block a user