MIL-004: buy the most expensive affordable upgrade #39

Merged
Tirsvad merged 2 commits from mil-004-buy-upgrades into main 2026-10-28 10:28:24 +01:00
16 changed files with 600 additions and 44 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ document of a type. `Primary File` may contain a glob (e.g.
| PP | Project Plan | docs/project-plan.md | 002 |
| MIL | Milestone / Gateway | docs/milestones/*.md | 008 |
| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 016 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 017 |
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
| DICT | Domain Dictionary (PO and IT terms) | docs/dictionary.md | 002 |
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+62
View File
@@ -0,0 +1,62 @@
# SQA Review Record
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-016 |
| CrossReference | [MIL-004], [QC-PY-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Code review record | [97215c4] |
---
## Artifact Under Review
- Instance reviewed: [MIL-004]
- Checklist used: [QC-PY-001] (`QC-PY-001`)
- Scope: full review of the source and tests added by MIL-004: domain parsing and upgrade rule, port and adapter extensions, click_and_buy loop, cli --interval and 43 new tests (written with the python-developer agent); a 45 s container run bought 8 upgrades and produced step-3.png
- Language and domain: n/a
- Language reviewer: none
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S02`)
## Checklist Results
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | ruff (N rules) passes; names follow PEP 8 casing. |
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names use the terms of DICT-001 (cookie, upgrade, bot); no single-letter names outside comprehensions. |
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff format` and `ruff check` pass with the rules in `pyproject.toml`; no suppressions. |
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | `mypy --strict` passes; every signature is annotated. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No bare `except`; ruff B and S rules pass. |
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults; ruff B006 and A rules pass. |
| 7 | Files, locks and connections are managed with context managers | Pass | The browser is closed in a `finally` block in `main`. |
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Public modules, classes and functions have Doxygen docstrings with `@brief`. |
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | Diagnostics use `logging`; the required result line `cookies/second: N` is the program's output, not a log; no secrets are read or logged. |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | No Design Class Diagram exists (ADR-0001 skips use case design); ADR-0002 records the layout instead. |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 66 tests pass with fakes; 100% coverage of the logic modules; the 105-cookie Grandma versus Cursor case is tested. |
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy --strict` passes; no `Any`. |
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `pyproject.toml` lists Selenium as the only runtime dependency; dev extras are test and lint tools. |
| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | Constants live only in `constants.py`; rules are defined once. |
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `domain` imports no Selenium; `application` depends on a port; no import cycles. |
| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | Modules have one reason to change; the game is behind a narrow port. |
| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | Layers `domain`, `application`, `infrastructure` under `src/`, `unit` and `integration` under `tests/`; no `presentation` package by ADR-0002. |
| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | English names, snake_case files, no spaces. |
## Overall Verdict
Go — the code of MIL-004 meets every Mandatory criterion; `ruff`, `mypy --strict` and `pytest` were run and pass. The review is not independent: the AI wrote and reviewed it as stand-in for S02, and S01 can challenge it in the end-of-run summary.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| None | S01 | - |
---
[MIL-004]: ../../milestones/mil-004-buy-upgrades.md
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[97215c4]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/commit/97215c4137ac798f63600abba947968671ad3957
+1
View File
@@ -43,6 +43,7 @@ Tracks backward and forward links between artifact instances so that the Busines
| [MIL-007] | Milestone | en | it | [BC-001], [PP-001] | - | RC-013 |
| Source and tests of [MIL-002] | Python source code | - | - | [MIL-002], [ADR-0002] | - | RC-014 |
| Source and tests of [MIL-003] | Python source code | - | - | [MIL-003], [ADR-0002] | - | RC-015 |
| Source and tests of [MIL-004] | Python source code | - | - | [MIL-004], [ADR-0002] | - | RC-016 |
## Concern Coverage
+47 -12
View File
@@ -4,8 +4,20 @@
import time
from collections.abc import Callable
from dataclasses import dataclass
from cookie_clicker_bot.application.ports import GamePort
from cookie_clicker_bot.domain.upgrades import Upgrade, 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, ...]
def click_cookie(game: GamePort) -> None:
@@ -16,21 +28,44 @@ def click_cookie(game: GamePort) -> None:
game.click_cookie()
def click_cookie_for(
game: GamePort,
duration_seconds: float,
clock: Callable[[], float] = time.monotonic,
) -> int:
"""@brief Click the cookie as fast as possible until the time is up.
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.
@param duration_seconds How long to keep clicking.
@param clock Monotonic clock in seconds; a test passes a fake one.
@return The number of clicks made.
@return The upgrade that was bought, or None when nothing was affordable.
"""
deadline = clock() + duration_seconds
chosen = choose_most_expensive_affordable(game.upgrades(), game.cookie_count())
if chosen is not None:
game.buy(chosen)
return chosen
def click_and_buy(
game: GamePort,
duration_seconds: float,
check_interval_seconds: float,
clock: Callable[[], float] = time.monotonic,
) -> 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.
@return The number of clicks and the names of the upgrades bought, in order.
"""
start = clock()
deadline = start + duration_seconds
next_check = start + check_interval_seconds
clicks = 0
while clock() < deadline:
purchases: list[str] = []
while (now := clock()) < deadline:
click_cookie(game)
clicks += 1
return clicks
if now >= next_check:
next_check = now + check_interval_seconds
bought = buy_most_expensive_upgrade(game)
if bought is not None:
purchases.append(bought.name)
return PlayResult(clicks=clicks, purchases=tuple(purchases))
@@ -5,6 +5,8 @@
from pathlib import Path
from typing import Protocol
from cookie_clicker_bot.domain.upgrades import Upgrade
class GamePort(Protocol):
"""@brief What the bot needs from the game, whatever drives the browser."""
@@ -18,6 +20,26 @@ class GamePort(Protocol):
def click_cookie(self) -> None:
"""@brief Click the big cookie once."""
def cookie_count(self) -> int:
"""@brief Read how many cookies the player has.
@return The cookie count.
"""
...
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.
"""
...
def buy(self, upgrade: Upgrade) -> None:
"""@brief Buy an upgrade by clicking it.
@param upgrade The upgrade to buy.
"""
def save_screenshot(self, path: Path) -> None:
"""@brief Write a screenshot of the game to a PNG file.
+20 -4
View File
@@ -8,7 +8,7 @@ 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_cookie_for
from cookie_clicker_bot.application.game_loop import click_and_buy
from cookie_clicker_bot.application.ports import GamePort
from cookie_clicker_bot.infrastructure.browser import create_driver
from cookie_clicker_bot.infrastructure.selenium_game import SeleniumGame
@@ -33,7 +33,10 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
"""
parser = argparse.ArgumentParser(
prog="cookie-clicker-bot",
description="Plays Cookie Clicker: clicks the big cookie as fast as it can.",
description=(
"Plays Cookie Clicker: clicks the big cookie as fast as it can "
"and buys upgrades."
),
)
parser.add_argument(
"--duration",
@@ -41,6 +44,12 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
default=constants.RUN_DURATION_SECONDS,
help="seconds to play (default: %(default)s)",
)
parser.add_argument(
"--interval",
type=float,
default=constants.UPGRADE_CHECK_INTERVAL_SECONDS,
help="seconds between upgrade checks (default: %(default)s)",
)
parser.add_argument(
"--headless",
action="store_true",
@@ -77,8 +86,15 @@ def main(
game.dismiss_language()
if args.screenshot_start is not None:
game.save_screenshot(args.screenshot_start)
clicks = click_cookie_for(game, args.duration)
logger.info("clicked the cookie %d times in %s seconds", clicks, args.duration)
result = click_and_buy(game, args.duration, args.interval)
for name in result.purchases:
logger.info("bought %s", name)
logger.info(
"clicked the cookie %d times and bought %d upgrades in %s seconds",
result.clicks,
len(result.purchases),
args.duration,
)
if args.screenshot is not None:
game.save_screenshot(args.screenshot)
finally:
+33
View File
@@ -30,6 +30,39 @@ BIG_COOKIE_ID: Final[str] = "bigCookie"
## Id of the element that shows the cookie count and the cookies per second.
COOKIE_COUNT_ID: Final[str] = "cookies"
## CSS selector of the products in the right-hand pane that the player can see.
VISIBLE_PRODUCT_SELECTOR: Final[str] = ".product.unlocked"
## CSS selector of the name inside a product.
PRODUCT_NAME_SELECTOR: Final[str] = ".productName"
## CSS selector of the price inside a product.
PRODUCT_PRICE_SELECTOR: Final[str] = ".price"
## Attribute of a product element that holds its id (product0 .. product19).
ID_ATTRIBUTE: Final[str] = "id"
## Text in `#cookies` after which the cookies per second follow; the count ends here.
PER_SECOND_MARKER: Final[str] = "per second"
## The words the game puts after the cookie count (the longer word first).
COOKIE_WORDS: Final[tuple[str, ...]] = ("cookies", "cookie")
## Multiplier of each magnitude word the game writes in numbers (1.4 million).
MAGNITUDE_MULTIPLIERS: Final[dict[str, int]] = {
"thousand": 10**3,
"million": 10**6,
"billion": 10**9,
"trillion": 10**12,
"quadrillion": 10**15,
"quintillion": 10**18,
"sextillion": 10**21,
"septillion": 10**24,
"octillion": 10**27,
"nonillion": 10**30,
"decillion": 10**33,
}
## Seconds between two polls while waiting for an element.
POLL_INTERVAL_SECONDS: Final[float] = 0.2
+53
View File
@@ -0,0 +1,53 @@
"""@file parsing.py
@brief Turn the texts of the game page into whole numbers of cookies.
"""
from decimal import Decimal, InvalidOperation
from cookie_clicker_bot import constants
def parse_amount(text: str) -> int:
"""@brief Read an amount such as `1,100`, `15` or `1.4 million`.
@param text The text of a price or a count, with optional thousands commas
and an optional magnitude word.
@return The amount as a whole number.
@throws ValueError When the text is not an amount.
"""
words = text.strip().lower().replace(",", "").split()
if not 1 <= len(words) <= 2:
raise ValueError(_message(text))
multiplier = 1
if len(words) == 2:
multiplier = constants.MAGNITUDE_MULTIPLIERS.get(words[1], 0)
if multiplier == 0:
raise ValueError(_message(text))
try:
number = Decimal(words[0])
except InvalidOperation:
raise ValueError(_message(text)) from None
if not number.is_finite() or number < 0:
raise ValueError(_message(text))
return int(number * multiplier)
def parse_cookie_count(text: str) -> int:
"""@brief Read the cookie count from the whole text of the `#cookies` element.
The text looks like `5,000 cookies` then a line `per second: 0`; a big count
puts the magnitude word on the second line: `1.235`, `million cookies`.
@param text The visible text of the element.
@return The number of cookies.
@throws ValueError When the text holds no count.
"""
count = text.lower().split(constants.PER_SECOND_MARKER)[0]
for word in constants.COOKIE_WORDS:
count = count.replace(word, "")
return parse_amount(" ".join(count.split()))
def _message(text: str) -> str:
"""@brief Build the error message for a text that is not an amount."""
return f"not an amount of cookies: {text!r}"
+33
View File
@@ -0,0 +1,33 @@
"""@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_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.
"""
affordable = [upgrade for upgrade in upgrades if upgrade.price <= cookies]
if not affordable:
return None
return max(affordable, key=lambda upgrade: upgrade.price)
@@ -3,7 +3,7 @@
"""
import time
from collections.abc import Callable
from collections.abc import Callable, Sequence
from pathlib import Path
from typing import Protocol, TypeVar
@@ -15,6 +15,8 @@ from selenium.common.exceptions import (
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
from cookie_clicker_bot.domain.upgrades import Upgrade
T = TypeVar("T")
@@ -34,6 +36,14 @@ class ElementLike(Protocol):
"""@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."""
@@ -45,6 +55,10 @@ class DriverLike(Protocol):
"""@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."""
...
@@ -93,6 +107,35 @@ class SeleniumGame:
# 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.
"""
element = self._driver.find_element(By.ID, constants.COOKIE_COUNT_ID)
return parse_cookie_count(element.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.
"""
products = self._driver.find_elements(
By.CSS_SELECTOR, constants.VISIBLE_PRODUCT_SELECTOR
)
return [self._read_upgrade(product) for product in products]
def buy(self, upgrade: Upgrade) -> None:
"""@brief Buy an upgrade by clicking its product.
@param upgrade The upgrade to buy.
"""
try:
self._driver.find_element(By.ID, upgrade.key).click()
except (ElementClickInterceptedException, StaleElementReferenceException):
# The pane re-rendered or is covered for a moment; the next check retries.
return
def save_screenshot(self, path: Path) -> None:
"""@brief Write a screenshot of the game to a PNG file.
@@ -105,6 +148,14 @@ class SeleniumGame:
"""@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:
+78 -1
View File
@@ -13,6 +13,7 @@ from selenium.common.exceptions import (
)
from cookie_clicker_bot import constants
from cookie_clicker_bot.domain.upgrades import Upgrade
from cookie_clicker_bot.infrastructure.selenium_game import SeleniumGame
@@ -23,11 +24,24 @@ class FakeElement:
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 = ""
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:
@@ -43,6 +57,7 @@ class FakeDriver:
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
@@ -55,6 +70,11 @@ class FakeDriver:
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
@@ -157,6 +177,63 @@ def test_click_cookie_ignores_a_covered_or_stale_cookie(error: Exception) -> Non
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_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),
]
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()})
make_game(driver).buy(Upgrade("product1", "Grandma", 100))
assert driver.elements["product1"].clicks == 1
@pytest.mark.parametrize(
"error",
[ElementClickInterceptedException(), StaleElementReferenceException()],
)
def test_buy_ignores_a_covered_or_stale_product(error: Exception) -> None:
driver = FakeDriver({"product1": FakeElement(click_error=error)})
make_game(driver).buy(Upgrade("product1", "Grandma", 100))
def test_save_screenshot_creates_the_folder_and_calls_the_driver(
tmp_path: Path,
) -> None:
+14
View File
@@ -9,6 +9,7 @@ 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:
@@ -31,6 +32,15 @@ class RecordingGame:
def click_cookie(self) -> None:
self.calls.append("click_cookie")
def cookie_count(self) -> int:
return 0
def upgrades(self) -> list[Upgrade]:
return []
def buy(self, upgrade: Upgrade) -> None:
self.calls.append("buy")
def save_screenshot(self, path: Path) -> None:
self.calls.append("save_screenshot")
self.screenshots.append(path)
@@ -43,6 +53,7 @@ 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
@@ -54,6 +65,8 @@ def test_parse_args_reads_every_option() -> None:
"--headless",
"--duration",
"10",
"--interval",
"2.5",
"--screenshot-start",
"a.png",
"--screenshot",
@@ -63,6 +76,7 @@ def test_parse_args_reads_every_option() -> None:
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")
+93 -25
View File
@@ -4,14 +4,43 @@
from pathlib import Path
from cookie_clicker_bot.application.game_loop import click_cookie, click_cookie_for
from cookie_clicker_bot.application.game_loop import (
PlayResult,
buy_most_expensive_upgrade,
click_and_buy,
click_cookie,
)
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 that only counts clicks."""
"""A game with a cookie count and upgrades; each click takes one second."""
def __init__(self) -> None:
def __init__(
self,
cookies: int = 0,
upgrades: list[Upgrade] | None = None,
clock: FakeClock | None = None,
) -> None:
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
@@ -21,6 +50,19 @@ class FakeGame:
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 upgrades(self) -> list[Upgrade]:
return self.available
def buy(self, upgrade: Upgrade) -> None:
self.bought.append(upgrade)
if self._clock is not None:
self.buy_times.append(self._clock.now)
def save_screenshot(self, path: Path) -> None:
pass
@@ -29,18 +71,6 @@ class FakeGame:
pass
class FakeClock:
"""A clock that moves forward one second each time it is read."""
def __init__(self) -> None:
self.now = 0.0
def __call__(self) -> float:
value = self.now
self.now += 1.0
return value
def test_click_cookie_delegates_to_the_game() -> None:
game = FakeGame()
@@ -49,18 +79,56 @@ def test_click_cookie_delegates_to_the_game() -> None:
assert game.clicks == 1
def test_click_cookie_for_clicks_until_the_deadline() -> None:
game = FakeGame()
def test_buy_most_expensive_upgrade_buys_the_best_affordable() -> None:
game = FakeGame(cookies=105, upgrades=[CURSOR, GRANDMA])
# Reads: deadline = 0 + 5; the loop reads 1, 2, 3, 4 (clicks) and 5 (stops).
clicks = click_cookie_for(game, 5, FakeClock())
bought = buy_most_expensive_upgrade(game)
assert clicks == 4
assert game.clicks == 4
assert bought == GRANDMA
assert game.bought == [GRANDMA]
def test_click_cookie_for_does_not_click_when_duration_is_zero() -> None:
game = FakeGame()
def test_buy_most_expensive_upgrade_buys_nothing_when_too_poor() -> None:
game = FakeGame(cookies=10, upgrades=[CURSOR, GRANDMA])
assert click_cookie_for(game, 0, FakeClock()) == 0
assert game.clicks == 0
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"))
# 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=())
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_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=())
+56
View File
@@ -0,0 +1,56 @@
"""@file test_parsing.py
@brief Tests of the parsing of prices and cookie counts.
"""
import pytest
from cookie_clicker_bot.domain.parsing import parse_amount, parse_cookie_count
@pytest.mark.parametrize(
("text", "expected"),
[
("15", 15),
("0", 0),
("1,100", 1100),
("130,000", 130000),
(" 100 ", 100),
("1.4 million", 1_400_000),
("20 million", 20_000_000),
("1.235 million", 1_235_000),
("3.5 Billion", 3_500_000_000),
("2 thousand", 2000),
("1 decillion", 10**33),
],
)
def test_parse_amount_reads_prices(text: str, expected: int) -> None:
assert parse_amount(text) == expected
@pytest.mark.parametrize(
"text", ["", " ", "abc", "1.4 gazillion", "1 2 3", "-5", "nan", "million"]
)
def test_parse_amount_rejects_garbage(text: str) -> None:
with pytest.raises(ValueError, match="not an amount of cookies"):
parse_amount(text)
@pytest.mark.parametrize(
("text", "expected"),
[
("120 cookies\nper second: 0", 120),
("5,000 cookies\nper second: 0", 5000),
("1 cookie\nper second: 0", 1),
("0 cookies\nper second: 0", 0),
("1.235\nmillion cookies\nper second: 0", 1_235_000),
("3.500\nbillion cookies\nper second: 12.5", 3_500_000_000),
("120 cookies", 120),
],
)
def test_parse_cookie_count_reads_the_count_element(text: str, expected: int) -> None:
assert parse_cookie_count(text) == expected
def test_parse_cookie_count_rejects_text_without_a_count() -> None:
with pytest.raises(ValueError, match="not an amount of cookies"):
parse_cookie_count("per second: 0")
+35
View File
@@ -0,0 +1,35 @@
"""@file test_upgrades.py
@brief Tests of the rule that picks the upgrade to buy.
"""
from cookie_clicker_bot.domain.upgrades import Upgrade, 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