MIL-008: smarter purchase rule #49

Merged
Tirsvad merged 5 commits from mil-008-smarter-rule into main 2026-10-28 16:43:41 +01:00
11 changed files with 1764 additions and 1495 deletions
Showing only changes of commit ecbe0d2671 - Show all commits
+1
View File
@@ -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 |
+116 -96
View File
@@ -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
View File
@@ -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
+6
View File
@@ -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",
+16 -14
View File
@@ -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"
+81 -52
View File
@@ -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)
+490 -429
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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