diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index e285cc2..ad5aebe 100644 --- a/docs/artifact-registry.md +++ b/docs/artifact-registry.md @@ -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 | diff --git a/docs/images/step-3.png b/docs/images/step-3.png new file mode 100644 index 0000000..76eeeea Binary files /dev/null and b/docs/images/step-3.png differ diff --git a/docs/sqa/reviews/rc-016-code-mil-004.md b/docs/sqa/reviews/rc-016-code-mil-004.md new file mode 100644 index 0000000..2eed76f --- /dev/null +++ b/docs/sqa/reviews/rc-016-code-mil-004.md @@ -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 diff --git a/docs/sqa/traceability-matrix.md b/docs/sqa/traceability-matrix.md index b3f08d0..bd7cc94 100644 --- a/docs/sqa/traceability-matrix.md +++ b/docs/sqa/traceability-matrix.md @@ -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 diff --git a/src/cookie_clicker_bot/application/game_loop.py b/src/cookie_clicker_bot/application/game_loop.py index 116e5b0..acf1d90 100644 --- a/src/cookie_clicker_bot/application/game_loop.py +++ b/src/cookie_clicker_bot/application/game_loop.py @@ -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)) diff --git a/src/cookie_clicker_bot/application/ports.py b/src/cookie_clicker_bot/application/ports.py index d3bfa42..3c746b9 100644 --- a/src/cookie_clicker_bot/application/ports.py +++ b/src/cookie_clicker_bot/application/ports.py @@ -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. diff --git a/src/cookie_clicker_bot/cli.py b/src/cookie_clicker_bot/cli.py index eae2ccd..477d7bd 100644 --- a/src/cookie_clicker_bot/cli.py +++ b/src/cookie_clicker_bot/cli.py @@ -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: diff --git a/src/cookie_clicker_bot/constants.py b/src/cookie_clicker_bot/constants.py index 746d18a..c9e5f93 100644 --- a/src/cookie_clicker_bot/constants.py +++ b/src/cookie_clicker_bot/constants.py @@ -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 diff --git a/src/cookie_clicker_bot/domain/parsing.py b/src/cookie_clicker_bot/domain/parsing.py new file mode 100644 index 0000000..3bfb618 --- /dev/null +++ b/src/cookie_clicker_bot/domain/parsing.py @@ -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}" diff --git a/src/cookie_clicker_bot/domain/upgrades.py b/src/cookie_clicker_bot/domain/upgrades.py new file mode 100644 index 0000000..49abd27 --- /dev/null +++ b/src/cookie_clicker_bot/domain/upgrades.py @@ -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) diff --git a/src/cookie_clicker_bot/infrastructure/selenium_game.py b/src/cookie_clicker_bot/infrastructure/selenium_game.py index 1ca8bf3..6c65a7d 100644 --- a/src/cookie_clicker_bot/infrastructure/selenium_game.py +++ b/src/cookie_clicker_bot/infrastructure/selenium_game.py @@ -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: diff --git a/tests/integration/test_selenium_game.py b/tests/integration/test_selenium_game.py index d7c8fdb..ae2a451 100644 --- a/tests/integration/test_selenium_game.py +++ b/tests/integration/test_selenium_game.py @@ -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: diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 1932d47..337df99 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -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") diff --git a/tests/unit/test_game_loop.py b/tests/unit/test_game_loop.py index 93999fd..b79b12d 100644 --- a/tests/unit/test_game_loop.py +++ b/tests/unit/test_game_loop.py @@ -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=()) diff --git a/tests/unit/test_parsing.py b/tests/unit/test_parsing.py new file mode 100644 index 0000000..8e40a2a --- /dev/null +++ b/tests/unit/test_parsing.py @@ -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") diff --git a/tests/unit/test_upgrades.py b/tests/unit/test_upgrades.py new file mode 100644 index 0000000..52764fc --- /dev/null +++ b/tests/unit/test_upgrades.py @@ -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