diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index 77df07b..5bbfefe 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 | 018 | +| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 019 | | 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-5.png b/docs/images/step-5.png new file mode 100644 index 0000000..466c034 Binary files /dev/null and b/docs/images/step-5.png differ diff --git a/docs/sqa/reviews/rc-018-code-mil-006.md b/docs/sqa/reviews/rc-018-code-mil-006.md new file mode 100644 index 0000000..6428b6c --- /dev/null +++ b/docs/sqa/reviews/rc-018-code-mil-006.md @@ -0,0 +1,62 @@ +# SQA Review Record + +## Metadata +| Key | Value | +| --- | --- | +| ID | RC-018 | +| CrossReference | [MIL-006], [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 | [afcb7d3] | + +--- + +## Artifact Under Review + +- Instance reviewed: [MIL-006] +- Checklist used: [QC-PY-001] (`QC-PY-001`) +- Scope: full review of the source and tests added by MIL-006: BuyStrategy, buy-all plan, interval validation, --buy-all and --interval options, five attempt stale-read retry and 22 new tests (written with the python-developer agent); two batches of five 300 s container runs produced the comparison table and step-5.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 | 116 tests pass with fakes; 100% coverage of the logic modules; a regression test covers the stale read found in a real run. | +| 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-006 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-006]: ../../milestones/mil-006-strategy.md +[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md +[afcb7d3]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/commit/afcb7d35a8d33f1eabacf6460769b887f326f6cc diff --git a/docs/sqa/traceability-matrix.md b/docs/sqa/traceability-matrix.md index c0793fa..bc0793d 100644 --- a/docs/sqa/traceability-matrix.md +++ b/docs/sqa/traceability-matrix.md @@ -45,6 +45,7 @@ Tracks backward and forward links between artifact instances so that the Busines | 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 | | Source and tests of [MIL-005] | Python source code | - | - | [MIL-005], [ADR-0002] | - | RC-017 | +| Source and tests of [MIL-006] | Python source code | - | - | [MIL-006], [ADR-0002] | - | RC-018 | ## Concern Coverage diff --git a/docs/wiki/Step-5-Improve-the-Strategy.md b/docs/wiki/Step-5-Improve-the-Strategy.md new file mode 100644 index 0000000..a545926 --- /dev/null +++ b/docs/wiki/Step-5-Improve-the-Strategy.md @@ -0,0 +1,44 @@ +**English** · [Dansk](Step-5-Improve-the-Strategy-da) · [Bahasa Malaysia](Step-5-Improve-the-Strategy-ms) + +# Lecture step 5: tweak the algorithm + +![The game after the best run, checking every 10 seconds](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/raw/branch/main/docs/images/step-5.png) + +## What the assignment asks + +Try another check interval (every second, every 10 seconds) or buy all affordable upgrades, and see whether the cookies per second go up. + +## What we built + +- `--interval SECONDS` sets the time between upgrade checks. It must be greater than 0; the default stays 5 seconds. +- `--buy-all` buys every upgrade that still fits in the cookies you have, the most expensive first (`choose_affordable_upgrades`). Without it the bot buys one upgrade, the most expensive affordable one (`choose_most_expensive_affordable`). +- Both rules are pure functions in the domain package, so they are unit tested without a browser. The strategy is a value (`BuyStrategy`), not a flag spread through the code. + +```bash +cookie-clicker-bot --interval 10 +cookie-clicker-bot --interval 1 --buy-all +``` + +## What we measured + +Each row is a full 300 second run in a Docker container. Five containers ran at the same time on one computer, in two batches, so the numbers are good for comparing the rows with each other but a run alone is faster (our solo baseline of lecture step 4 was 18.9). + +| Check every | Strategy | Batch 1 | Batch 2 | +| --- | --- | --- | --- | +| 1 second | buy one | 13.5 | 13.5 | +| 1 second | buy all | 13.5 | 13.5 | +| 5 seconds (assignment) | buy one | 15.5 | 15.5 | +| 5 seconds | buy all | crashed, see below | 14.5 | +| 10 seconds | buy one | **18.2** | **18.2** | + +The best result is the 10 second interval with buy one. Why: + +- Checking more often is not better. Every check reads the page and takes time away from clicking, and the most expensive affordable product is usually only a little better than the cheaper one you could have bought a moment before. +- Buy all makes no difference early in the game: only Cursor and Grandma are affordable, and the bot buys them anyway. It would matter later, when the cookie count holds several products at once. + +## What we gained in this step + +- **Small, pure rules are easy to compare.** Because the choice of upgrade does not touch Selenium, a new strategy is a new function plus a test. +- **Measure, do not guess.** The result we expected (more checks, more cookies) was the opposite of what the runs showed. +- **A second real bug.** The first batch crashed in one container: the game rewrites the counter every frame, and a computer under load made our single retry fail. The read now retries five times with a short pause. A test with a fake driver that goes stale four times shows it. +- **Fair comparisons need the same conditions.** We ran the rows at the same time and report two batches, because one run can be lucky. diff --git a/src/cookie_clicker_bot/application/game_loop.py b/src/cookie_clicker_bot/application/game_loop.py index 53a75e9..ad94f11 100644 --- a/src/cookie_clicker_bot/application/game_loop.py +++ b/src/cookie_clicker_bot/application/game_loop.py @@ -7,7 +7,12 @@ 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 +from cookie_clicker_bot.domain.buy_strategy import BuyStrategy +from cookie_clicker_bot.domain.upgrades import ( + Upgrade, + choose_affordable_upgrades, + choose_most_expensive_affordable, +) @dataclass(frozen=True, slots=True) @@ -43,11 +48,27 @@ def buy_most_expensive_upgrade(game: GamePort) -> Upgrade | None: return None +def buy_upgrades(game: GamePort, strategy: BuyStrategy) -> list[Upgrade]: + """@brief Do one purchase round with the given strategy (lecture step 5). + + @param game The game to play. + @param strategy BUY_ONE buys the most expensive affordable upgrade, BUY_ALL buys + every upgrade the cookies pay for. + @return The upgrades really bought, in order; a click that missed is left out. + """ + if strategy is BuyStrategy.BUY_ONE: + bought = buy_most_expensive_upgrade(game) + return [] if bought is None else [bought] + plan = choose_affordable_upgrades(game.upgrades(), game.cookie_count()) + return [upgrade for upgrade in plan if game.buy(upgrade)] + + def click_and_buy( game: GamePort, duration_seconds: float, check_interval_seconds: float, clock: Callable[[], float] = time.monotonic, + strategy: BuyStrategy = BuyStrategy.BUY_ONE, ) -> PlayResult: """@brief Click the cookie nonstop and check the upgrades at every interval. @@ -56,6 +77,7 @@ def click_and_buy( @param check_interval_seconds Time between two upgrade checks; the first check comes one interval after the start. @param clock Monotonic clock in seconds; a test passes a fake one. + @param strategy What to buy at each check. @return The clicks, the names of the upgrades bought in order, and the time spent. """ start = clock() @@ -68,9 +90,7 @@ def click_and_buy( clicks += 1 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) + purchases.extend(upgrade.name for upgrade in buy_upgrades(game, strategy)) return PlayResult( clicks=clicks, purchases=tuple(purchases), elapsed_seconds=clock() - start ) diff --git a/src/cookie_clicker_bot/cli.py b/src/cookie_clicker_bot/cli.py index 756d17a..a7cdce4 100644 --- a/src/cookie_clicker_bot/cli.py +++ b/src/cookie_clicker_bot/cli.py @@ -11,6 +11,7 @@ from cookie_clicker_bot import constants from cookie_clicker_bot.application.game_loop import click_and_buy from cookie_clicker_bot.application.ports import GamePort from cookie_clicker_bot.application.report import format_result +from cookie_clicker_bot.domain.buy_strategy import BuyStrategy from cookie_clicker_bot.infrastructure.browser import create_driver from cookie_clicker_bot.infrastructure.selenium_game import SeleniumGame @@ -26,6 +27,23 @@ def create_game(headless: bool) -> GamePort: return SeleniumGame(create_driver(headless)) +def positive_float(text: str) -> float: + """@brief Read a number greater than zero from the command line. + + @param text The argument text. + @return The number. + @throws argparse.ArgumentTypeError When the text is not a number above zero. + """ + message = constants.POSITIVE_NUMBER_ERROR_TEMPLATE.format(value=text) + try: + value = float(text) + except ValueError: + raise argparse.ArgumentTypeError(message) from None + if not value > 0: + raise argparse.ArgumentTypeError(message) + return value + + def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: """@brief Read the command line. @@ -47,9 +65,17 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: ) parser.add_argument( "--interval", - type=float, + type=positive_float, default=constants.UPGRADE_CHECK_INTERVAL_SECONDS, - help="seconds between upgrade checks (default: %(default)s)", + help="seconds between upgrade checks, greater than 0 (default: %(default)s)", + ) + parser.add_argument( + "--buy-all", + action="store_true", + help=( + "at each check buy every affordable upgrade, most expensive first " + "(default: buy only the most expensive affordable one)" + ), ) parser.add_argument( "--headless", @@ -87,7 +113,13 @@ def main( game.dismiss_language() if args.screenshot_start is not None: game.save_screenshot(args.screenshot_start) - result = click_and_buy(game, args.duration, args.interval) + strategy = BuyStrategy.BUY_ALL if args.buy_all else BuyStrategy.BUY_ONE + logger.info( + "checking upgrades every %s seconds, strategy %s", + args.interval, + strategy.value, + ) + result = click_and_buy(game, args.duration, args.interval, strategy=strategy) for name in result.purchases: logger.info("bought %s", name) logger.info( diff --git a/src/cookie_clicker_bot/constants.py b/src/cookie_clicker_bot/constants.py index ab20781..f484f79 100644 --- a/src/cookie_clicker_bot/constants.py +++ b/src/cookie_clicker_bot/constants.py @@ -15,6 +15,9 @@ RUN_DURATION_SECONDS: Final[int] = 300 ## Seconds between two checks for an affordable upgrade (lecture step 3). UPGRADE_CHECK_INTERVAL_SECONDS: Final[int] = 5 +## Error shown when a command-line number must be greater than zero. +POSITIVE_NUMBER_ERROR_TEMPLATE: Final[str] = "{value!r} is not a number greater than 0" + ## Longest time to wait for an element of the page, in seconds. WAIT_TIMEOUT_SECONDS: Final[int] = 20 @@ -72,6 +75,9 @@ MAGNITUDE_MULTIPLIERS: Final[dict[str, int]] = { ## Seconds between two polls while waiting for an element. POLL_INTERVAL_SECONDS: Final[float] = 0.2 +## Total attempts to read a value the game re-rendered under us (stale element). +STALE_READ_ATTEMPTS: Final[int] = 5 + ## Environment variable that names the Chromium binary (set in the Docker image). CHROME_BIN_ENV: Final[str] = "CHROME_BIN" diff --git a/src/cookie_clicker_bot/domain/buy_strategy.py b/src/cookie_clicker_bot/domain/buy_strategy.py new file mode 100644 index 0000000..3aa1d2f --- /dev/null +++ b/src/cookie_clicker_bot/domain/buy_strategy.py @@ -0,0 +1,14 @@ +"""@file buy_strategy.py +@brief How many upgrades the bot buys at each check. +""" + +from enum import Enum + + +class BuyStrategy(Enum): + """@brief The purchase strategy of one upgrade check (lecture step 5).""" + + ## Buy only the most expensive affordable upgrade (the assignment). + BUY_ONE = "buy-one" + ## Buy every upgrade the cookies pay for, most expensive first. + BUY_ALL = "buy-all" diff --git a/src/cookie_clicker_bot/domain/upgrades.py b/src/cookie_clicker_bot/domain/upgrades.py index 49abd27..571ccb9 100644 --- a/src/cookie_clicker_bot/domain/upgrades.py +++ b/src/cookie_clicker_bot/domain/upgrades.py @@ -18,6 +18,27 @@ class Upgrade: price: int +def choose_affordable_upgrades( + upgrades: Sequence[Upgrade], cookies: int +) -> list[Upgrade]: + """@brief Plan a buy-all round: as many upgrades as the cookies pay for. + + Walks the upgrades from most expensive to cheapest (the list order on a tie) and + takes each one whose price still fits in the cookies left. + + @param upgrades The visible upgrades, in store order. + @param cookies The cookies the player has. + @return The upgrades to buy, most expensive first; empty when none is affordable. + """ + plan: list[Upgrade] = [] + remaining = cookies + for upgrade in sorted(upgrades, key=lambda candidate: -candidate.price): + if upgrade.price <= remaining: + plan.append(upgrade) + remaining -= upgrade.price + return plan + + def choose_most_expensive_affordable( upgrades: Sequence[Upgrade], cookies: int ) -> Upgrade | None: @@ -27,7 +48,5 @@ def choose_most_expensive_affordable( @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) + plan = choose_affordable_upgrades(upgrades, cookies) + return plan[0] if plan else None diff --git a/src/cookie_clicker_bot/infrastructure/selenium_game.py b/src/cookie_clicker_bot/infrastructure/selenium_game.py index 363b334..7ae7932 100644 --- a/src/cookie_clicker_bot/infrastructure/selenium_game.py +++ b/src/cookie_clicker_bot/infrastructure/selenium_game.py @@ -151,13 +151,17 @@ class SeleniumGame: ) return [self._read_upgrade(product) for product in products] - @staticmethod - def _read_again_if_stale(read: Callable[[], T]) -> T: - """@brief Run a read; repeat it once when the page re-rendered under it.""" - try: - return read() - except StaleElementReferenceException: - return read() + def _read_again_if_stale(self, read: Callable[[], T]) -> T: + """@brief Run a read; repeat it while the page re-renders under it. + + @throws StaleElementReferenceException When every attempt went stale. + """ + for _ in range(constants.STALE_READ_ATTEMPTS - 1): + try: + return read() + except StaleElementReferenceException: + self._sleep(constants.POLL_INTERVAL_SECONDS) + return read() def buy(self, upgrade: Upgrade) -> bool: """@brief Buy an upgrade by clicking its product. diff --git a/tests/integration/test_selenium_game.py b/tests/integration/test_selenium_game.py index 097d0d7..0b672b4 100644 --- a/tests/integration/test_selenium_game.py +++ b/tests/integration/test_selenium_game.py @@ -317,16 +317,49 @@ def test_upgrades_retries_once_when_a_product_goes_stale() -> None: assert make_game(driver).upgrades() == [Upgrade("product0", "Cursor", 15)] +def test_cookies_per_second_succeeds_after_four_stale_attempts() -> None: + driver = FakeDriver() + attempts = _stale_for(4, FakeElement(text="per second: 3.5")) + driver.find_element = attempts.find_element # type: ignore[method-assign] + fake_time = FakeTime() + + assert make_game(driver, fake_time).cookies_per_second() == 3.5 + assert attempts.calls == 5 + assert fake_time.sleeps == [constants.POLL_INTERVAL_SECONDS] * 4 + + def test_cookie_count_gives_up_when_the_counter_stays_stale() -> None: driver = FakeDriver() - driver.find_element = _always_stale # type: ignore[method-assign] + attempts = _stale_for(100, FakeElement(text="42 cookies")) + driver.find_element = attempts.find_element # type: ignore[method-assign] + fake_time = FakeTime() with pytest.raises(StaleElementReferenceException): - make_game(driver).cookie_count() + make_game(driver, fake_time).cookie_count() + + assert attempts.calls == constants.STALE_READ_ATTEMPTS + assert fake_time.sleeps == [constants.POLL_INTERVAL_SECONDS] * ( + constants.STALE_READ_ATTEMPTS - 1 + ) -def _always_stale(by: str, value: str) -> FakeElement: - raise StaleElementReferenceException +class _StaleFor: + """A `find_element` that goes stale for the first `stale` calls.""" + + def __init__(self, stale: int, element: FakeElement) -> None: + self._stale = stale + self._element = element + self.calls = 0 + + def find_element(self, by: str, value: str) -> FakeElement: + self.calls += 1 + if self.calls <= self._stale: + raise StaleElementReferenceException + return self._element + + +def _stale_for(stale: int, element: FakeElement) -> _StaleFor: + return _StaleFor(stale, element) def _stale_once_then( diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 1374aa8..9a3bfc4 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -154,12 +154,12 @@ def test_main_logs_bought_only_for_real_purchases( ) -> None: caplog.set_level("INFO") - main(["--duration", "0.05", "--interval", "0"], lambda _h: RecordingGame()) + main(["--duration", "0.05", "--interval", "0.001"], lambda _h: RecordingGame()) assert "bought Cursor" in caplog.text caplog.clear() game = RecordingGame(buy_succeeds=False) - main(["--duration", "0.05", "--interval", "0"], lambda _h: game) + main(["--duration", "0.05", "--interval", "0.001"], lambda _h: game) assert "buy" in game.calls assert "bought Cursor" not in caplog.text assert "bought 0 upgrades" in caplog.text @@ -172,3 +172,45 @@ def test_main_prints_nothing_on_stdout_when_the_run_fails( main(["--duration", "0"], lambda _headless: RecordingGame(fail_on_open=True)) assert capsys.readouterr().out == "" + + +def test_parse_args_buys_one_upgrade_unless_buy_all_is_given() -> None: + assert parse_args([]).buy_all is False + assert parse_args(["--buy-all"]).buy_all is True + + +@pytest.mark.parametrize("value", ["0", "-1", "abc", "nan"]) +def test_parse_args_rejects_an_interval_that_is_not_positive( + value: str, capsys: pytest.CaptureFixture[str] +) -> None: + with pytest.raises(SystemExit) as exit_info: + parse_args(["--interval", value]) + + assert exit_info.value.code == 2 + assert "greater than 0" in capsys.readouterr().err + + +def test_main_logs_the_interval_and_the_strategy( + caplog: pytest.LogCaptureFixture, +) -> None: + caplog.set_level("INFO") + + main(["--duration", "0"], lambda _h: RecordingGame()) + assert "every 5 seconds, strategy buy-one" in caplog.text + + caplog.clear() + main( + ["--duration", "0", "--interval", "2", "--buy-all"], lambda _h: RecordingGame() + ) + assert "every 2.0 seconds, strategy buy-all" in caplog.text + + +def test_main_buy_all_buys_every_affordable_upgrade_per_check( + caplog: pytest.LogCaptureFixture, +) -> None: + caplog.set_level("INFO") + game = RecordingGame() + + main(["--duration", "0.05", "--interval", "0.001", "--buy-all"], lambda _h: game) + + assert "bought Cursor" in caplog.text diff --git a/tests/unit/test_game_loop.py b/tests/unit/test_game_loop.py index e2f055d..3fd477e 100644 --- a/tests/unit/test_game_loop.py +++ b/tests/unit/test_game_loop.py @@ -7,9 +7,11 @@ from pathlib import Path from cookie_clicker_bot.application.game_loop import ( PlayResult, buy_most_expensive_upgrade, + buy_upgrades, click_and_buy, click_cookie, ) +from cookie_clicker_bot.domain.buy_strategy import BuyStrategy from cookie_clicker_bot.domain.upgrades import Upgrade CURSOR = Upgrade("product0", "Cursor", 15) @@ -169,3 +171,80 @@ def test_click_and_buy_stops_at_the_deadline_and_reports_elapsed_time() -> None: assert result.clicks == 300 assert result.elapsed_seconds == 300.0 + + +def test_buy_upgrades_buy_one_buys_only_the_best_affordable() -> None: + game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA]) + + assert buy_upgrades(game, BuyStrategy.BUY_ONE) == [GRANDMA] + assert game.bought == [GRANDMA] + + +def test_buy_upgrades_buy_all_buys_every_affordable_upgrade() -> None: + game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA]) + + assert buy_upgrades(game, BuyStrategy.BUY_ALL) == [GRANDMA, CURSOR] + assert game.bought == [GRANDMA, CURSOR] + + +def test_buy_upgrades_buy_all_buys_nothing_when_too_poor() -> None: + game = FakeGame(cookies=10, upgrades=[CURSOR, GRANDMA]) + + assert buy_upgrades(game, BuyStrategy.BUY_ALL) == [] + assert game.bought == [] + + +def test_buy_upgrades_buy_all_leaves_out_failed_buys() -> None: + game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA], buy_succeeds=False) + + assert buy_upgrades(game, BuyStrategy.BUY_ALL) == [] + assert game.bought == [GRANDMA, CURSOR] + + +def test_click_and_buy_defaults_to_buying_one_upgrade() -> None: + clock = FakeClock() + game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA], clock=clock) + + result = click_and_buy(game, 6, 5, clock) + + assert result.purchases == ("Grandma",) + + +def test_click_and_buy_buy_all_records_every_purchase_in_order() -> None: + clock = FakeClock() + game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA], clock=clock) + + result = click_and_buy(game, 6, 5, clock, BuyStrategy.BUY_ALL) + + assert result.purchases == ("Grandma", "Cursor") + + +def test_click_and_buy_buy_all_does_not_record_failed_buys() -> None: + clock = FakeClock() + game = FakeGame( + cookies=120, upgrades=[CURSOR, GRANDMA], clock=clock, buy_succeeds=False + ) + + result = click_and_buy(game, 6, 5, clock, BuyStrategy.BUY_ALL) + + assert game.bought == [GRANDMA, CURSOR] + assert result.purchases == () + + +def test_click_and_buy_checks_every_second_with_a_one_second_interval() -> None: + clock = FakeClock() + game = FakeGame(cookies=105, upgrades=[GRANDMA], clock=clock) + + click_and_buy(game, 4, 1, clock) + + assert game.buy_times == [2.0, 3.0, 4.0] + + +def test_click_and_buy_checks_every_ten_seconds_with_a_ten_second_interval() -> None: + clock = FakeClock() + game = FakeGame(cookies=105, upgrades=[GRANDMA], clock=clock) + + result = click_and_buy(game, 25, 10, clock) + + assert game.buy_times == [11.0, 21.0] + assert result.purchases == ("Grandma", "Grandma") diff --git a/tests/unit/test_upgrades.py b/tests/unit/test_upgrades.py index 52764fc..63b7b69 100644 --- a/tests/unit/test_upgrades.py +++ b/tests/unit/test_upgrades.py @@ -2,7 +2,11 @@ @brief Tests of the rule that picks the upgrade to buy. """ -from cookie_clicker_bot.domain.upgrades import Upgrade, choose_most_expensive_affordable +from cookie_clicker_bot.domain.upgrades import ( + Upgrade, + choose_affordable_upgrades, + choose_most_expensive_affordable, +) CURSOR = Upgrade("product0", "Cursor", 15) GRANDMA = Upgrade("product1", "Grandma", 100) @@ -33,3 +37,34 @@ def test_choose_takes_the_first_on_a_tie() -> None: def test_choose_ignores_the_order_of_the_list() -> None: assert choose_most_expensive_affordable([GRANDMA, CURSOR], 105) == GRANDMA + + +def test_plan_skips_an_upgrade_the_remaining_cookies_cannot_pay() -> None: + assert choose_affordable_upgrades([CURSOR, GRANDMA, FARM], 105) == [GRANDMA] + + +def test_plan_buys_several_upgrades_most_expensive_first() -> None: + assert choose_affordable_upgrades([CURSOR, GRANDMA, FARM], 120) == [GRANDMA, CURSOR] + + +def test_plan_is_empty_when_nothing_is_affordable() -> None: + assert choose_affordable_upgrades([CURSOR, GRANDMA], 10) == [] + + +def test_plan_is_empty_without_upgrades() -> None: + assert choose_affordable_upgrades([], 1000) == [] + + +def test_plan_spends_the_whole_budget_on_equal_prices() -> None: + twin = Upgrade("product9", "Twin", 100) + + assert choose_affordable_upgrades([GRANDMA, twin], 250) == [GRANDMA, twin] + assert choose_affordable_upgrades([twin, GRANDMA], 150) == [twin] + + +def test_plan_does_not_change_the_given_list() -> None: + upgrades = [CURSOR, GRANDMA] + + choose_affordable_upgrades(upgrades, 1000) + + assert upgrades == [CURSOR, GRANDMA]