From cb8e5edfbf5acd1d0553568c11be7bbb4c5e5e1c Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 28 Oct 2026 16:37:21 +0800 Subject: [PATCH 1/2] Implement store strategy, five-minute run loop and command line entry point StoreItem and choose_upgrade, get_store_items, buy_upgrade, get_cps, run_bot with a Clock port, main and python -m cookie_clicker_bot. 74 offline tests. Reviewed in RC-012. Task: MIL-003#1 Task: MIL-003#2 Task: MIL-003#3 Task: MIL-003#4 Task: MIL-003#5 Task: MIL-003#6 Closes #13 Closes #14 Closes #15 Closes #16 Closes #17 Closes #18 --- docs/artifact-registry.md | 2 +- docs/sqa/reviews/rc-012-mil-003-source.md | 61 ++++++++++ docs/sqa/traceability-matrix.md | 6 +- src/cookie_clicker_bot/__main__.py | 13 +++ .../application/ports/clock.py | 15 +++ .../application/ports/game_port.py | 8 ++ .../application/use_cases/run_bot.py | 70 ++++++++++++ .../application/use_cases/store.py | 62 ++++++++++ src/cookie_clicker_bot/constants.py | 27 +++++ .../domain/number_parsing.py | 20 +++- src/cookie_clicker_bot/domain/store_item.py | 34 ++++++ .../infrastructure/selenium_game.py | 9 ++ .../infrastructure/system_clock.py | 15 +++ src/cookie_clicker_bot/presentation/cli.py | 57 ++++++++++ tests/integration/test_selenium_game.py | 23 +++- tests/unit/fakes.py | 37 +++++- tests/unit/test_cli.py | 92 +++++++++++++++ tests/unit/test_number_parsing.py | 25 +++- tests/unit/test_run_bot.py | 107 ++++++++++++++++++ tests/unit/test_store.py | 100 ++++++++++++++++ tests/unit/test_store_item.py | 48 ++++++++ 21 files changed, 816 insertions(+), 15 deletions(-) create mode 100644 docs/sqa/reviews/rc-012-mil-003-source.md create mode 100644 src/cookie_clicker_bot/__main__.py create mode 100644 src/cookie_clicker_bot/application/ports/clock.py create mode 100644 src/cookie_clicker_bot/application/use_cases/run_bot.py create mode 100644 src/cookie_clicker_bot/application/use_cases/store.py create mode 100644 src/cookie_clicker_bot/domain/store_item.py create mode 100644 src/cookie_clicker_bot/infrastructure/system_clock.py create mode 100644 src/cookie_clicker_bot/presentation/cli.py create mode 100644 tests/unit/test_cli.py create mode 100644 tests/unit/test_run_bot.py create mode 100644 tests/unit/test_store.py create mode 100644 tests/unit/test_store_item.py diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index 8f01b13..cb19f58 100644 --- a/docs/artifact-registry.md +++ b/docs/artifact-registry.md @@ -17,7 +17,7 @@ document of a type. `Primary File` may contain a glob (e.g. | MIL | Milestone / Gateway | docs/milestones/mil-*.md | 006 | | ADR | Architecture Decision Record | docs/adr/adr-*.md | 0002 | | TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 | -| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 012 | +| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 013 | ## Languages diff --git a/docs/sqa/reviews/rc-012-mil-003-source.md b/docs/sqa/reviews/rc-012-mil-003-source.md new file mode 100644 index 0000000..a4020c9 --- /dev/null +++ b/docs/sqa/reviews/rc-012-mil-003-source.md @@ -0,0 +1,61 @@ +# SQA Review Record + +## Metadata +| Key | Value | +| --- | --- | +| ID | RC-012 | +| CrossReference | [MIL-003], [QC-PY-001] | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | pending | + +--- + +## Artifact Under Review + +- Instance reviewed: source of milestone MIL-003 (store, choose_upgrade, buy_upgrade, get_cps, run_bot, main) ([MIL-003]) +- Checklist used: [QC-PY-001] +- Scope: full review +- Language and domain: n/a (technical type) +- Language reviewer: none +- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S01`) + +## Checklist Results + +| # | Criterion | Status | Evidence/Notes | +| --- | --- | --- | --- | +| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | PEP 8 casing throughout. | +| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose (choose_upgrade, STORE_CHECK_INTERVAL_SECONDS). | +| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff check` clean, `ruff format` applied, no suppressions. | +| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | All signatures annotated; mypy --strict passes on 33 files. | +| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | Specific exceptions (ValueError, UnreadableNumberError); a failed purchase is logged and stops the buying loop instead of being swallowed. | +| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults, no shadowed builtins. | +| 7 | Files, locks and connections are managed with context managers | Pass | The browser is released in a finally block in cli.main (tested when run_bot raises). | +| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Doxygen docstrings on all public items. | +| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | Progress goes through logging; the only print is the program result `Result : ` in the command line entry point; no secrets are logged. | +| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | Plain technical tasks; no Design Class Diagram exists or is required. | +| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 74 offline tests with a fake game and a fake clock; no sleeps, no network, no browser; positive, negative, boundary, edge, error handling and timing cases. | +| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | mypy --strict clean; no Any. | +| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | selenium remains the only runtime dependency. | +| 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 | Ids, class names, burst size and intervals live in constants.py; decimal parsing is shared by parse_game_number. | +| 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 only constants and errors; application imports domain and ports; selenium only in infrastructure; the CLI is the composition root. | +| 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 | Clock and GamePort are narrow Protocols; run_bot receives both (dependency inversion); one purpose per module. | +| 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 under src/ and unit and integration tests under tests/. | +| 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 | All names English, snake_case, no spaces. | + +## Overall Verdict + +Go - All Mandatory criteria pass or are not applicable; behaviour against the live game is verified in MIL-004. Stand-in review, not independent. + +## Action Items + +| Action | Owner | Due | +| --- | --- | --- | +| None | S01 | - | + +--- + +[MIL-003]: ../../milestones/mil-003-upgrades-and-five-minute-run.md +[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md diff --git a/docs/sqa/traceability-matrix.md b/docs/sqa/traceability-matrix.md index 5f0235f..1dd2744 100644 --- a/docs/sqa/traceability-matrix.md +++ b/docs/sqa/traceability-matrix.md @@ -9,8 +9,8 @@ ## Version History | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | -| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Added ADR-0001, RC-009 and RC-010 | [ad3334f] | | 2026-10-28 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Added RC-011 (MIL-002 source review) | [68c4391] | +| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Added RC-012 (MIL-003 source review) | pending | --- @@ -29,7 +29,7 @@ updated whenever an artifact instance is created or reviewed. | [PP-001] | PP | en | it | [BC-001], [SA-001] | [MIL-001], [MIL-002], [MIL-003], [MIL-004], [MIL-005] | [RC-003] | | [MIL-001] | MIL | en | it | [PP-001] | [MIL-002], [ADR-0001] | [RC-004], [RC-010] | | [MIL-002] | MIL | en | it | [MIL-001] | [MIL-003] | [RC-005], [RC-011] | -| [MIL-003] | MIL | en | it | [MIL-002] | [MIL-004] | [RC-006] | +| [MIL-003] | MIL | en | it | [MIL-002] | [MIL-004] | [RC-006], [RC-012] | | [MIL-004] | MIL | en | it | [MIL-003] | [MIL-005] | [RC-007] | | [MIL-005] | MIL | en | it | [MIL-004] | - | [RC-008] | | [ADR-0001] | ADR | - | - | [BC-001], [SA-001], [MIL-001] | - | [RC-009] | @@ -73,6 +73,6 @@ updated whenever an artifact instance is created or reviewed. [RC-008]: ./reviews/rc-008-mil-005-wiki-and-release.md [RC-009]: ./reviews/rc-009-adr-0001-selenium-runtime-dependency.md [RC-010]: ./reviews/rc-010-mil-001-source.md -[ad3334f]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/commit/ad3334fa13c4261c3d034fbffcc7b43fe95fbb31 [RC-011]: ./reviews/rc-011-mil-002-source.md [68c4391]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/commit/68c439137f13299e20cf6271e392365d9f5371c4 +[RC-012]: ./reviews/rc-012-mil-003-source.md diff --git a/src/cookie_clicker_bot/__main__.py b/src/cookie_clicker_bot/__main__.py new file mode 100644 index 0000000..486ae86 --- /dev/null +++ b/src/cookie_clicker_bot/__main__.py @@ -0,0 +1,13 @@ +"""@file +@brief Lets `python -m cookie_clicker_bot` start the bot. +""" + +import logging + +from cookie_clicker_bot.presentation.cli import main + +if __name__ == "__main__": + logging.basicConfig( + level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s" + ) + raise SystemExit(main()) diff --git a/src/cookie_clicker_bot/application/ports/clock.py b/src/cookie_clicker_bot/application/ports/clock.py new file mode 100644 index 0000000..f0753b5 --- /dev/null +++ b/src/cookie_clicker_bot/application/ports/clock.py @@ -0,0 +1,15 @@ +"""@file +@brief The port through which the bot tells the time. +""" + +from typing import Protocol + + +class Clock(Protocol): + """@brief A monotonic clock. The system clock implements it.""" + + def monotonic(self) -> float: + """@brief Return seconds from a fixed but arbitrary start, never going back. + @return The seconds. + """ + ... diff --git a/src/cookie_clicker_bot/application/ports/game_port.py b/src/cookie_clicker_bot/application/ports/game_port.py index 0a3c1f0..578c73c 100644 --- a/src/cookie_clicker_bot/application/ports/game_port.py +++ b/src/cookie_clicker_bot/application/ports/game_port.py @@ -32,3 +32,11 @@ class GamePort(Protocol): @return The text as shown on the page. """ ... + + def read_attribute(self, element_id: str, attribute: str) -> str: + """@brief Return an attribute of the element. + @param element_id The HTML id of the element. + @param attribute The attribute name, for example "class". + @return The value, or an empty string when the attribute is absent. + """ + ... diff --git a/src/cookie_clicker_bot/application/use_cases/run_bot.py b/src/cookie_clicker_bot/application/use_cases/run_bot.py new file mode 100644 index 0000000..86cf1b4 --- /dev/null +++ b/src/cookie_clicker_bot/application/use_cases/run_bot.py @@ -0,0 +1,70 @@ +"""@file +@brief Lecture step 5: play for a set time, buying upgrades, then report the rate. +""" + +import logging +from decimal import Decimal + +from cookie_clicker_bot.application.ports.clock import Clock +from cookie_clicker_bot.application.ports.game_port import GamePort +from cookie_clicker_bot.application.use_cases.bot_core import click_cookie +from cookie_clicker_bot.application.use_cases.store import ( + buy_upgrade, + get_cps, + get_store_items, +) +from cookie_clicker_bot.constants import ( + CLICKS_PER_BURST, + RUN_DURATION_SECONDS, + STORE_CHECK_INTERVAL_SECONDS, +) +from cookie_clicker_bot.domain.store_item import choose_upgrade + +_logger = logging.getLogger(__name__) + + +def _buy_while_affordable(game: GamePort) -> int: + """@brief Buy the best affordable product until none is left or a purchase fails.""" + bought = 0 + while (item := choose_upgrade(get_store_items(game))) is not None: + if not buy_upgrade(game, item): + _logger.warning( + "Purchase of product %d did not lower the cookies", item.index + ) + break + bought += 1 + _logger.info("Bought product %d for %d cookies", item.index, item.price) + return bought + + +def run_bot( + game: GamePort, + clock: Clock, + duration_seconds: float = RUN_DURATION_SECONDS, +) -> Decimal: + """@brief Click the cookie and buy upgrades until the time is up. + + One loop: a burst of clicks, then, every store check interval, a visit to + the store that buys the most expensive affordable product until none is. + + @param game The game page to operate. + @param clock The clock that measures the run. + @param duration_seconds How long to play, more than zero. + @return The cookies per second at the end of the run. + @throws ValueError When duration_seconds is zero or negative. + """ + if duration_seconds <= 0: + raise ValueError(f"duration_seconds must be positive, got {duration_seconds}") + start = clock.monotonic() + next_store_check = start + STORE_CHECK_INTERVAL_SECONDS + now = start + while now - start < duration_seconds: + click_cookie(game, CLICKS_PER_BURST) + now = clock.monotonic() + if now >= next_store_check: + _buy_while_affordable(game) + next_store_check = now + STORE_CHECK_INTERVAL_SECONDS + _logger.info("%.0f of %s seconds played", now - start, duration_seconds) + cps = get_cps(game) + _logger.info("Run finished at %s cookies per second", cps) + return cps diff --git a/src/cookie_clicker_bot/application/use_cases/store.py b/src/cookie_clicker_bot/application/use_cases/store.py new file mode 100644 index 0000000..fcf7f06 --- /dev/null +++ b/src/cookie_clicker_bot/application/use_cases/store.py @@ -0,0 +1,62 @@ +"""@file +@brief Lecture step 4: read the store, buy a product and read the cookies per second. +""" + +from decimal import Decimal + +from cookie_clicker_bot.application.ports.game_port import GamePort +from cookie_clicker_bot.application.use_cases.bot_core import get_cookie_count +from cookie_clicker_bot.constants import ( + CLASS_ATTRIBUTE, + CPS_ID, + CPS_SEPARATOR, + ENABLED_CLASS, + PRODUCT_COUNT, + PRODUCT_ID_PREFIX, + PRODUCT_PRICE_ID_PREFIX, + UNLOCKED_CLASS, +) +from cookie_clicker_bot.domain.number_parsing import ( + parse_game_decimal, + parse_game_number, +) +from cookie_clicker_bot.domain.store_item import StoreItem + + +def get_store_items(game: GamePort) -> list[StoreItem]: + """@brief Read the products that are visible in the store. + @param game The game page to read. + @return One item per unlocked product, in store order. + @throws UnreadableNumberError When a price shows no number. + """ + items: list[StoreItem] = [] + for index in range(PRODUCT_COUNT): + classes = game.read_attribute( + PRODUCT_ID_PREFIX + str(index), CLASS_ATTRIBUTE + ).split() + if UNLOCKED_CLASS not in classes: + continue + price = parse_game_number(game.read_text(PRODUCT_PRICE_ID_PREFIX + str(index))) + items.append(StoreItem(index, price, ENABLED_CLASS in classes)) + return items + + +def buy_upgrade(game: GamePort, item: StoreItem) -> bool: + """@brief Click a product and check that the purchase went through. + @param game The game page to operate. + @param item The product to buy. + @return True when the cookie count dropped after the click. + """ + before = get_cookie_count(game) + game.click_element(PRODUCT_ID_PREFIX + str(item.index)) + return get_cookie_count(game) < before + + +def get_cps(game: GamePort) -> Decimal: + """@brief Read the cookies per second the bakery makes now. + @param game The game page to read. + @return The rate, with its decimals. + @throws UnreadableNumberError When the line shows no number. + """ + text = game.read_text(CPS_ID) + return parse_game_decimal(text.partition(CPS_SEPARATOR)[2]) diff --git a/src/cookie_clicker_bot/constants.py b/src/cookie_clicker_bot/constants.py index 764cbb7..806acd2 100644 --- a/src/cookie_clicker_bot/constants.py +++ b/src/cookie_clicker_bot/constants.py @@ -29,6 +29,33 @@ COOKIE_COUNTER_ID: str = "cookies" ## Element id of the line that shows the cookies per second. CPS_ID: str = "cookiesPerSecond" +## Number of products in the store. +PRODUCT_COUNT: int = 20 + +## Element id prefix of a store product; the product index follows. +PRODUCT_ID_PREFIX: str = "product" + +## Element id prefix of the price of a store product; the product index follows. +PRODUCT_PRICE_ID_PREFIX: str = "productPrice" + +## Attribute that holds the CSS classes of an element. +CLASS_ATTRIBUTE: str = "class" + +## Class of a product that is visible in the store. +UNLOCKED_CLASS: str = "unlocked" + +## Class of a product the player can afford. +ENABLED_CLASS: str = "enabled" + +## Clicks on the big cookie between two looks at the clock. +CLICKS_PER_BURST: int = 20 + +## Separator between the label and the amount in the cookies per second line. +CPS_SEPARATOR: str = ":" + +## Label printed before the cookies per second at the end of the run. +RESULT_LABEL: str = "Result" + ## Number words the game prints for large amounts, with their value. NUMBER_SCALES: dict[str, int] = { "thousand": 10**3, diff --git a/src/cookie_clicker_bot/domain/number_parsing.py b/src/cookie_clicker_bot/domain/number_parsing.py index 15c2c49..3fa1b63 100644 --- a/src/cookie_clicker_bot/domain/number_parsing.py +++ b/src/cookie_clicker_bot/domain/number_parsing.py @@ -11,20 +11,28 @@ from cookie_clicker_bot.domain.errors import UnreadableNumberError _AMOUNT = re.compile(r"^\s*(\d[\d,]*(?:\.\d+)?)(?:\s+([a-z]+))?", re.IGNORECASE) -def parse_game_number(text: str) -> int: - """@brief Read the first amount in a text printed by the game. +def parse_game_decimal(text: str) -> Decimal: + """@brief Read the first amount in a text printed by the game, keeping decimals. The game groups thousands with commas ("12,345") and writes large amounts with a number word ("1.234 million"). Any other word after the number, such as "cookies", is ignored. - @param text The text, for example "1.234 million" or "12,345 cookies". - @return The amount, rounded to a whole number. + @param text The text, for example "0.1", "1.234 million" or "12,345 cookies". + @return The exact amount. @throws UnreadableNumberError When the text does not start with a number. """ match = _AMOUNT.match(text) if match is None: raise UnreadableNumberError(f"No number at the start of {text!r}") value = Decimal(match.group(1).replace(",", "")) - scale = NUMBER_SCALES.get((match.group(2) or "").lower(), 1) - return int((value * scale).to_integral_value(rounding=ROUND_HALF_UP)) + return value * NUMBER_SCALES.get((match.group(2) or "").lower(), 1) + + +def parse_game_number(text: str) -> int: + """@brief Read the first amount in a text printed by the game as a whole number. + @param text The text, for example "1.234 million" or "12,345 cookies". + @return The amount, rounded to a whole number. + @throws UnreadableNumberError When the text does not start with a number. + """ + return int(parse_game_decimal(text).to_integral_value(rounding=ROUND_HALF_UP)) diff --git a/src/cookie_clicker_bot/domain/store_item.py b/src/cookie_clicker_bot/domain/store_item.py new file mode 100644 index 0000000..3e41f2c --- /dev/null +++ b/src/cookie_clicker_bot/domain/store_item.py @@ -0,0 +1,34 @@ +"""@file +@brief A product in the store and the rule that picks which one to buy. +""" + +from collections.abc import Sequence +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class StoreItem: + """@brief A product that is visible in the store. + + @param index Position of the product in the store, from zero. + @param price Cookies the product costs now. + @param is_affordable True when the player has enough cookies to buy it. + """ + + index: int + price: int + is_affordable: bool + + +def choose_upgrade(items: Sequence[StoreItem]) -> StoreItem | None: + """@brief Pick the most expensive product the player can afford. + + A tie in price goes to the product with the highest index. + + @param items The visible products. + @return The product to buy, or None when nothing is affordable. + """ + affordable = [item for item in items if item.is_affordable] + if not affordable: + return None + return max(affordable, key=lambda item: (item.price, item.index)) diff --git a/src/cookie_clicker_bot/infrastructure/selenium_game.py b/src/cookie_clicker_bot/infrastructure/selenium_game.py index 26d4374..fd745a4 100644 --- a/src/cookie_clicker_bot/infrastructure/selenium_game.py +++ b/src/cookie_clicker_bot/infrastructure/selenium_game.py @@ -64,6 +64,15 @@ class SeleniumGame: """ return self._driver.find_element(By.ID, element_id).text + def read_attribute(self, element_id: str, attribute: str) -> str: + """@brief Return an attribute of the element. + @param element_id The HTML id of the element. + @param attribute The attribute name, for example "class". + @return The value, or an empty string when the attribute is absent. + """ + value = self._driver.find_element(By.ID, element_id).get_attribute(attribute) + return value or "" + def close(self) -> None: """@brief Quit the browser and end the driver session.""" self._driver.quit() diff --git a/src/cookie_clicker_bot/infrastructure/system_clock.py b/src/cookie_clicker_bot/infrastructure/system_clock.py new file mode 100644 index 0000000..2b6d595 --- /dev/null +++ b/src/cookie_clicker_bot/infrastructure/system_clock.py @@ -0,0 +1,15 @@ +"""@file +@brief The Clock port backed by the operating system. +""" + +import time + + +class SystemClock: + """@brief Reads the real monotonic clock.""" + + def monotonic(self) -> float: + """@brief Return seconds from a fixed but arbitrary start, never going back. + @return The seconds. + """ + return time.monotonic() diff --git a/src/cookie_clicker_bot/presentation/cli.py b/src/cookie_clicker_bot/presentation/cli.py new file mode 100644 index 0000000..a114581 --- /dev/null +++ b/src/cookie_clicker_bot/presentation/cli.py @@ -0,0 +1,57 @@ +"""@file +@brief Command line entry point and composition root of the bot. +""" + +import argparse +from collections.abc import Callable, Sequence + +from selenium.webdriver.remote.webdriver import WebDriver + +from cookie_clicker_bot.application.use_cases.bot_core import open_game, select_language +from cookie_clicker_bot.application.use_cases.run_bot import run_bot +from cookie_clicker_bot.constants import RESULT_LABEL, RUN_DURATION_SECONDS +from cookie_clicker_bot.infrastructure.selenium_game import ( + SeleniumGame, + create_chrome_driver, +) +from cookie_clicker_bot.infrastructure.system_clock import SystemClock + +DriverFactory = Callable[[bool], WebDriver] + + +def _parse_arguments(argv: Sequence[str] | None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="cookie_clicker_bot", description="Play Cookie Clicker." + ) + parser.add_argument( + "--headless", action="store_true", help="run Chrome without a window" + ) + parser.add_argument( + "--duration", + type=float, + default=RUN_DURATION_SECONDS, + help="seconds to play (default: %(default)s)", + ) + return parser.parse_args(argv) + + +def main( + argv: Sequence[str] | None = None, + driver_factory: DriverFactory = create_chrome_driver, +) -> int: + """@brief Start the browser, run the bot, print the result and close the browser. + @param argv The command line arguments, or None to read them from the process. + @param driver_factory Builds the web driver; replaced in tests so no browser starts. + @return The process exit code, zero on success. + @throws ValueError When the duration is zero or negative. + """ + arguments = _parse_arguments(argv) + game = SeleniumGame(driver_factory(arguments.headless)) + try: + open_game(game) + select_language(game) + cps = run_bot(game, SystemClock(), arguments.duration) + print(f"{RESULT_LABEL} : {cps}") + finally: + game.close() + return 0 diff --git a/tests/integration/test_selenium_game.py b/tests/integration/test_selenium_game.py index 43690c9..936b331 100644 --- a/tests/integration/test_selenium_game.py +++ b/tests/integration/test_selenium_game.py @@ -17,10 +17,16 @@ from cookie_clicker_bot.infrastructure.selenium_game import SeleniumGame class FakeElement: """@brief An element that counts its real clicks.""" - def __init__(self, text: str = "") -> None: + def __init__( + self, text: str = "", attributes: dict[str, str | None] | None = None + ) -> None: self.text = text + self.attributes = attributes or {} self.clicks = 0 + def get_attribute(self, name: str) -> str | None: + return self.attributes.get(name) + def is_displayed(self) -> bool: return True @@ -97,6 +103,21 @@ def test_read_text_raises_when_element_is_missing() -> None: game.read_text("missing") +def test_read_attribute_returns_the_attribute_value() -> None: + driver = FakeDriver( + {"product0": FakeElement(attributes={"class": "product unlocked"})} + ) + + assert make_game(driver).read_attribute("product0", "class") == "product unlocked" + assert driver.lookups == [(By.ID, "product0")] + + +def test_read_attribute_returns_empty_string_when_absent() -> None: + driver = FakeDriver({"product0": FakeElement()}) + + assert make_game(driver).read_attribute("product0", "class") == "" + + def test_close_quits_the_driver() -> None: driver = FakeDriver({}) diff --git a/tests/unit/fakes.py b/tests/unit/fakes.py index d3fa92a..ad34b3c 100644 --- a/tests/unit/fakes.py +++ b/tests/unit/fakes.py @@ -1,16 +1,26 @@ """@file -@brief A fake game page for tests that must not start a browser. +@brief Fakes of the ports for tests that must not start a browser or sleep. """ +from collections.abc import Callable + class FakeGame: - """@brief Records what the bot does and answers reads from a dictionary.""" + """@brief Records what the bot does and answers reads from dictionaries.""" - def __init__(self, texts: dict[str, str] | None = None) -> None: + def __init__( + self, + texts: dict[str, str] | None = None, + attributes: dict[tuple[str, str], str] | None = None, + on_click: Callable[[str], None] | None = None, + ) -> None: self.texts: dict[str, str] = texts or {} + self.attributes: dict[tuple[str, str], str] = attributes or {} + self.on_click = on_click self.opened: list[str] = [] self.waited: list[str] = [] self.clicked: list[str] = [] + self.close_calls = 0 def open_page(self, url: str) -> None: self.opened.append(url) @@ -20,6 +30,27 @@ class FakeGame: def click_element(self, element_id: str) -> None: self.clicked.append(element_id) + if self.on_click is not None: + self.on_click(element_id) def read_text(self, element_id: str) -> str: return self.texts[element_id] + + def read_attribute(self, element_id: str, attribute: str) -> str: + return self.attributes.get((element_id, attribute), "") + + def close(self) -> None: + self.close_calls += 1 + + +class FakeClock: + """@brief A clock that moves forward by a fixed step on every reading.""" + + def __init__(self, step: float = 1.0) -> None: + self.now = 0.0 + self.step = step + + def monotonic(self) -> float: + reading = self.now + self.now += self.step + return reading diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py new file mode 100644 index 0000000..06062a1 --- /dev/null +++ b/tests/unit/test_cli.py @@ -0,0 +1,92 @@ +"""@file +@brief Tests of the command line entry point; no browser is started. +""" + +from decimal import Decimal +from typing import cast + +import pytest +from selenium.webdriver.remote.webdriver import WebDriver + +from cookie_clicker_bot.application.ports.clock import Clock +from cookie_clicker_bot.application.ports.game_port import GamePort +from cookie_clicker_bot.constants import RUN_DURATION_SECONDS +from cookie_clicker_bot.presentation import cli + + +class FakeDriver: + def __init__(self) -> None: + self.quit_calls = 0 + + def quit(self) -> None: + self.quit_calls += 1 + + +class Recorder: + """Stands in for the use cases and the driver factory and records the calls.""" + + def __init__(self) -> None: + self.driver = FakeDriver() + self.headless: list[bool] = [] + self.durations: list[float] = [] + self.steps: list[str] = [] + self.error: Exception | None = None + + def driver_factory(self, headless: bool) -> WebDriver: + self.headless.append(headless) + return cast(WebDriver, self.driver) + + def open_game(self, game: GamePort) -> None: + self.steps.append("open") + + def select_language(self, game: GamePort) -> None: + self.steps.append("language") + + def run_bot(self, game: GamePort, clock: Clock, duration: float) -> Decimal: + self.steps.append("run") + self.durations.append(duration) + if self.error is not None: + raise self.error + return Decimal("12.5") + + +@pytest.fixture +def recorder(monkeypatch: pytest.MonkeyPatch) -> Recorder: + rec = Recorder() + monkeypatch.setattr(cli, "open_game", rec.open_game) + monkeypatch.setattr(cli, "select_language", rec.select_language) + monkeypatch.setattr(cli, "run_bot", rec.run_bot) + return rec + + +def test_main_runs_the_steps_prints_the_result_and_closes( + recorder: Recorder, capsys: pytest.CaptureFixture[str] +) -> None: + assert cli.main([], recorder.driver_factory) == 0 + assert recorder.steps == ["open", "language", "run"] + assert capsys.readouterr().out == "Result : 12.5\n" + assert recorder.driver.quit_calls == 1 + + +def test_main_uses_the_default_duration_and_a_visible_browser( + recorder: Recorder, +) -> None: + cli.main([], recorder.driver_factory) + assert recorder.durations == [RUN_DURATION_SECONDS] + assert recorder.headless == [False] + + +def test_main_passes_the_headless_flag_and_duration(recorder: Recorder) -> None: + cli.main(["--headless", "--duration", "30"], recorder.driver_factory) + assert recorder.durations == [30.0] + assert recorder.headless == [True] + + +def test_main_closes_the_browser_when_the_run_fails( + recorder: Recorder, capsys: pytest.CaptureFixture[str] +) -> None: + recorder.error = RuntimeError("boom") + with pytest.raises(RuntimeError, match="boom"): + cli.main([], recorder.driver_factory) + assert recorder.driver.quit_calls == 1 + assert capsys.readouterr().out == "" diff --git a/tests/unit/test_number_parsing.py b/tests/unit/test_number_parsing.py index 1ca8004..8ad20fa 100644 --- a/tests/unit/test_number_parsing.py +++ b/tests/unit/test_number_parsing.py @@ -2,10 +2,15 @@ @brief Tests of parse_game_number. """ +from decimal import Decimal + import pytest from cookie_clicker_bot.domain.errors import UnreadableNumberError -from cookie_clicker_bot.domain.number_parsing import parse_game_number +from cookie_clicker_bot.domain.number_parsing import ( + parse_game_decimal, + parse_game_number, +) @pytest.mark.parametrize( @@ -43,6 +48,24 @@ def test_parse_rounds_decimals() -> None: assert parse_game_number("7.6 cookies") == 8 +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("0.1", Decimal("0.1")), + ("1,234.5", Decimal("1234.5")), + ("1.234 million", Decimal("1234000")), + ("7", Decimal(7)), + ], +) +def test_parse_decimal_keeps_the_decimals(text: str, expected: Decimal) -> None: + assert parse_game_decimal(text) == expected + + +def test_parse_decimal_rejects_text_without_a_number() -> None: + with pytest.raises(UnreadableNumberError): + parse_game_decimal("none") + + @pytest.mark.parametrize("text", ["", "cookies", "-5", "per second: 3"]) def test_parse_rejects_text_without_a_leading_number(text: str) -> None: with pytest.raises(UnreadableNumberError): diff --git a/tests/unit/test_run_bot.py b/tests/unit/test_run_bot.py new file mode 100644 index 0000000..80dc445 --- /dev/null +++ b/tests/unit/test_run_bot.py @@ -0,0 +1,107 @@ +"""@file +@brief Tests of the run loop against a fake game and a fake clock. +""" + +from decimal import Decimal + +import pytest + +from cookie_clicker_bot.application.use_cases.run_bot import run_bot +from cookie_clicker_bot.constants import ( + BIG_COOKIE_ID, + CLASS_ATTRIBUTE, + CLICKS_PER_BURST, + COOKIE_COUNTER_ID, + CPS_ID, + PRODUCT_ID_PREFIX, + PRODUCT_PRICE_ID_PREFIX, + RUN_DURATION_SECONDS, + STORE_CHECK_INTERVAL_SECONDS, +) +from cookie_clicker_bot.domain.errors import UnreadableNumberError +from tests.unit.fakes import FakeClock, FakeGame + +PRODUCT = f"{PRODUCT_ID_PREFIX}0" + + +def make_game(cookies: int = 100, price: int = 15) -> FakeGame: + """A store with one affordable product that is spent once, then unaffordable.""" + game = FakeGame( + texts={ + COOKIE_COUNTER_ID: f"{cookies} cookies", + f"{PRODUCT_PRICE_ID_PREFIX}0": str(price), + CPS_ID: "per second: 4.5", + }, + attributes={(PRODUCT, CLASS_ATTRIBUTE): "product unlocked enabled"}, + ) + + def spend(element_id: str) -> None: + if element_id == PRODUCT: + game.texts[COOKIE_COUNTER_ID] = f"{cookies - price} cookies" + game.attributes[(PRODUCT, CLASS_ATTRIBUTE)] = "product unlocked disabled" + + game.on_click = spend + return game + + +def test_run_bot_returns_the_cps() -> None: + assert run_bot(make_game(), FakeClock(), 3) == Decimal("4.5") + + +def test_run_bot_clicks_in_bursts_until_the_duration_is_over() -> None: + game = FakeGame(texts={CPS_ID: "per second: 1"}) + run_bot(game, FakeClock(step=1.0), 3) + assert game.clicked == [BIG_COOKIE_ID] * CLICKS_PER_BURST * 3 + + +def test_run_bot_stops_after_the_duration() -> None: + clock = FakeClock(step=10.0) + game = FakeGame(texts={CPS_ID: "per second: 1"}) + run_bot(game, clock, 25) + assert clock.now == 40.0 + + +def test_run_bot_does_not_visit_the_store_before_the_interval() -> None: + game = make_game() + run_bot(game, FakeClock(step=1.0), STORE_CHECK_INTERVAL_SECONDS - 1) + assert PRODUCT not in game.clicked + + +def test_run_bot_buys_at_the_interval() -> None: + game = make_game() + run_bot(game, FakeClock(step=1.0), STORE_CHECK_INTERVAL_SECONDS + 2) + assert game.clicked.count(PRODUCT) == 1 + + +def test_run_bot_stops_buying_when_nothing_is_affordable() -> None: + game = make_game() + run_bot(game, FakeClock(step=1.0), STORE_CHECK_INTERVAL_SECONDS * 3) + assert game.clicked.count(PRODUCT) == 1 + + +def test_run_bot_stops_buying_when_a_purchase_fails() -> None: + game = make_game() + game.on_click = None + run_bot(game, FakeClock(step=1.0), STORE_CHECK_INTERVAL_SECONDS + 1) + assert game.clicked.count(PRODUCT) == 1 + + +@pytest.mark.parametrize("duration", [0, -1]) +def test_run_bot_rejects_a_non_positive_duration(duration: float) -> None: + game = FakeGame() + with pytest.raises(ValueError, match="positive"): + run_bot(game, FakeClock(), duration) + assert game.clicked == [] + + +def test_run_bot_propagates_an_unreadable_cps() -> None: + game = FakeGame(texts={CPS_ID: "nothing"}) + with pytest.raises(UnreadableNumberError): + run_bot(game, FakeClock(), 1) + + +def test_run_duration_default_is_the_constant() -> None: + clock = FakeClock(step=100.0) + game = FakeGame(texts={CPS_ID: "per second: 1"}) + run_bot(game, clock) + assert clock.now == 100.0 * (RUN_DURATION_SECONDS // 100 + 1) diff --git a/tests/unit/test_store.py b/tests/unit/test_store.py new file mode 100644 index 0000000..c8041ff --- /dev/null +++ b/tests/unit/test_store.py @@ -0,0 +1,100 @@ +"""@file +@brief Tests of get_store_items, buy_upgrade and get_cps against a fake game. +""" + +from decimal import Decimal + +import pytest + +from cookie_clicker_bot.application.use_cases.store import ( + buy_upgrade, + get_cps, + get_store_items, +) +from cookie_clicker_bot.constants import ( + CLASS_ATTRIBUTE, + COOKIE_COUNTER_ID, + CPS_ID, + PRODUCT_ID_PREFIX, + PRODUCT_PRICE_ID_PREFIX, +) +from cookie_clicker_bot.domain.errors import UnreadableNumberError +from cookie_clicker_bot.domain.store_item import StoreItem +from tests.unit.fakes import FakeGame + + +def make_store(classes: dict[int, str], prices: dict[int, str]) -> FakeGame: + attributes = { + (f"{PRODUCT_ID_PREFIX}{i}", CLASS_ATTRIBUTE): c for i, c in classes.items() + } + texts = {f"{PRODUCT_PRICE_ID_PREFIX}{i}": p for i, p in prices.items()} + return FakeGame(texts=texts, attributes=attributes) + + +def test_get_store_items_skips_locked_products() -> None: + game = make_store( + {0: "product unlocked enabled", 1: "product locked disabled", 2: "product"}, + {0: "15", 1: "100", 2: "1,100"}, + ) + assert get_store_items(game) == [StoreItem(0, 15, True)] + + +def test_get_store_items_reads_price_and_affordability() -> None: + game = make_store( + { + 0: "product unlocked enabled", + 1: "product unlocked disabled", + 2: "product unlocked disabled", + }, + {0: "15", 1: "1,100", 2: "1.5 million"}, + ) + assert get_store_items(game) == [ + StoreItem(0, 15, True), + StoreItem(1, 1100, False), + StoreItem(2, 1_500_000, False), + ] + + +def test_get_store_items_is_empty_when_nothing_is_unlocked() -> None: + assert get_store_items(FakeGame()) == [] + + +def test_get_store_items_propagates_an_unreadable_price() -> None: + game = make_store({0: "product unlocked"}, {0: "???"}) + with pytest.raises(UnreadableNumberError): + get_store_items(game) + + +def test_buy_upgrade_returns_true_when_the_cookies_drop() -> None: + game = FakeGame(texts={COOKIE_COUNTER_ID: "100 cookies"}) + + def spend(element_id: str) -> None: + game.texts[COOKIE_COUNTER_ID] = "85 cookies" + + game.on_click = spend + assert buy_upgrade(game, StoreItem(3, 15, True)) is True + assert game.clicked == [f"{PRODUCT_ID_PREFIX}3"] + + +def test_buy_upgrade_returns_false_when_the_cookies_do_not_drop() -> None: + game = FakeGame(texts={COOKIE_COUNTER_ID: "100 cookies"}) + assert buy_upgrade(game, StoreItem(3, 15, True)) is False + assert game.clicked == [f"{PRODUCT_ID_PREFIX}3"] + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("per second: 0.1", Decimal("0.1")), + ("per second: 1,234.5", Decimal("1234.5")), + ("per second: 1.234 million", Decimal("1234000")), + ("per second: 0", Decimal(0)), + ], +) +def test_get_cps_reads_the_amount_after_the_colon(text: str, expected: Decimal) -> None: + assert get_cps(FakeGame(texts={CPS_ID: text})) == expected + + +def test_get_cps_rejects_a_line_without_amount() -> None: + with pytest.raises(UnreadableNumberError): + get_cps(FakeGame(texts={CPS_ID: "per second"})) diff --git a/tests/unit/test_store_item.py b/tests/unit/test_store_item.py new file mode 100644 index 0000000..575e01c --- /dev/null +++ b/tests/unit/test_store_item.py @@ -0,0 +1,48 @@ +"""@file +@brief Tests of the purchase strategy choose_upgrade. +""" + +import dataclasses + +import pytest + +from cookie_clicker_bot.domain.store_item import StoreItem, choose_upgrade + + +def test_choose_upgrade_picks_the_most_expensive_affordable_item() -> None: + items = [StoreItem(0, 15, True), StoreItem(1, 100, True), StoreItem(2, 1100, False)] + assert choose_upgrade(items) == StoreItem(1, 100, True) + + +def test_choose_upgrade_returns_none_when_nothing_is_affordable() -> None: + assert choose_upgrade([StoreItem(0, 15, False), StoreItem(1, 100, False)]) is None + + +def test_choose_upgrade_returns_none_for_an_empty_store() -> None: + assert choose_upgrade([]) is None + + +def test_choose_upgrade_returns_the_only_item_when_it_is_affordable() -> None: + item = StoreItem(0, 15, True) + assert choose_upgrade([item]) == item + + +def test_choose_upgrade_prefers_the_highest_index_on_equal_price() -> None: + items = [StoreItem(3, 50, True), StoreItem(7, 50, True), StoreItem(5, 50, True)] + assert choose_upgrade(items) == StoreItem(7, 50, True) + + +def test_choose_upgrade_trusts_the_affordable_flag_at_price_boundary() -> None: + items = [StoreItem(0, 15, True), StoreItem(1, 16, False)] + assert choose_upgrade(items) == StoreItem(0, 15, True) + + +def test_choose_upgrade_ignores_the_order_of_the_items() -> None: + items = [StoreItem(2, 500, True), StoreItem(0, 15, True), StoreItem(1, 100, True)] + assert choose_upgrade(items) == StoreItem(2, 500, True) + + +def test_store_item_is_immutable() -> None: + item = StoreItem(0, 15, True) + with pytest.raises(dataclasses.FrozenInstanceError): + item.price = 1 # type: ignore[misc] -- 2.54.0 From 6b68dd29d6ddb17644753118ccf28e990f217d73 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 28 Oct 2026 16:37:26 +0800 Subject: [PATCH 2/2] Resolve pending commit links --- docs/sqa/reviews/rc-012-mil-003-source.md | 3 ++- docs/sqa/traceability-matrix.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/sqa/reviews/rc-012-mil-003-source.md b/docs/sqa/reviews/rc-012-mil-003-source.md index a4020c9..3f07252 100644 --- a/docs/sqa/reviews/rc-012-mil-003-source.md +++ b/docs/sqa/reviews/rc-012-mil-003-source.md @@ -9,7 +9,7 @@ ## Version History | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | -| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | pending | +| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [cb8e5ed] | --- @@ -59,3 +59,4 @@ Go - All Mandatory criteria pass or are not applicable; behaviour against the li [MIL-003]: ../../milestones/mil-003-upgrades-and-five-minute-run.md [QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md +[cb8e5ed]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/commit/cb8e5edfbf5acd1d0553568c11be7bbb4c5e5e1c diff --git a/docs/sqa/traceability-matrix.md b/docs/sqa/traceability-matrix.md index 1dd2744..04a35ee 100644 --- a/docs/sqa/traceability-matrix.md +++ b/docs/sqa/traceability-matrix.md @@ -10,7 +10,7 @@ | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | | 2026-10-28 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Added RC-011 (MIL-002 source review) | [68c4391] | -| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Added RC-012 (MIL-003 source review) | pending | +| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Added RC-012 (MIL-003 source review) | [cb8e5ed] | --- @@ -76,3 +76,4 @@ updated whenever an artifact instance is created or reviewed. [RC-011]: ./reviews/rc-011-mil-002-source.md [68c4391]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/commit/68c439137f13299e20cf6271e392365d9f5371c4 [RC-012]: ./reviews/rc-012-mil-003-source.md +[cb8e5ed]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/commit/cb8e5edfbf5acd1d0553568c11be7bbb4c5e5e1c -- 2.54.0