Compare commits

..
Author SHA1 Message Date
Tirsvad 3f611ab584 Resolve pending commit link for RC-017 2026-10-28 17:48:28 +08:00
Tirsvad e3155de9b5 Add timed run: cookies per second after five minutes
Result parsing and formatting, one stdout result line, elapsed time, handling
of stale elements and of clicks blocked by the cookie banner (found in real
container runs), the baseline result and the screenshot of lecture step 4.
Code review RC-017.

Closes #22
Closes #23
Closes #24
Closes #25

Task: MIL-005#1
Task: MIL-005#2
Task: MIL-005#3
Task: MIL-005#4
2026-10-28 17:48:24 +08:00
Tirsvad 18b1b4f282 Merge pull request 'MIL-004: buy the most expensive affordable upgrade' (#39) from mil-004-buy-upgrades into main
TirSystem/git-workflows: Sync GitHub mirror metadata / sync-metadata (push) Successful in 6s
2026-10-28 10:28:24 +01:00
Tirsvad 9d933074f2 Resolve pending commit link for RC-016 2026-10-28 17:28:13 +08:00
Tirsvad 97215c4137 Add upgrade buying every interval: parse prices, choose, buy
Parsing of cookie counts and prices, the most-expensive-affordable rule, the
click-and-buy loop with a check interval, adapter methods and cli option, and
the screenshot of lecture step 3 from a container run. Code review RC-016.

Closes #18
Closes #19
Closes #20
Closes #21

Task: MIL-004#1
Task: MIL-004#2
Task: MIL-004#3
Task: MIL-004#4
2026-10-28 17:28:09 +08:00
Tirsvad e28096100b Merge pull request 'MIL-003: open the game and click the cookie' (#38) from mil-003-click-bot into main
TirSystem/git-workflows: Sync GitHub mirror metadata / sync-metadata (push) Successful in 6s
2026-10-28 10:22:31 +01:00
21 changed files with 1072 additions and 49 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ document of a type. `Primary File` may contain a glob (e.g.
| PP | Project Plan | docs/project-plan.md | 002 |
| MIL | Milestone / Gateway | docs/milestones/*.md | 008 |
| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 016 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 018 |
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
| DICT | Domain Dictionary (PO and IT terms) | docs/dictionary.md | 002 |
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+62
View File
@@ -0,0 +1,62 @@
# SQA Review Record
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-016 |
| CrossReference | [MIL-004], [QC-PY-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Code review record | [97215c4] |
---
## Artifact Under Review
- Instance reviewed: [MIL-004]
- Checklist used: [QC-PY-001] (`QC-PY-001`)
- Scope: full review of the source and tests added by MIL-004: domain parsing and upgrade rule, port and adapter extensions, click_and_buy loop, cli --interval and 43 new tests (written with the python-developer agent); a 45 s container run bought 8 upgrades and produced step-3.png
- Language and domain: n/a
- Language reviewer: none
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S02`)
## Checklist Results
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | ruff (N rules) passes; names follow PEP 8 casing. |
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names use the terms of DICT-001 (cookie, upgrade, bot); no single-letter names outside comprehensions. |
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff format` and `ruff check` pass with the rules in `pyproject.toml`; no suppressions. |
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | `mypy --strict` passes; every signature is annotated. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No bare `except`; ruff B and S rules pass. |
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults; ruff B006 and A rules pass. |
| 7 | Files, locks and connections are managed with context managers | Pass | The browser is closed in a `finally` block in `main`. |
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Public modules, classes and functions have Doxygen docstrings with `@brief`. |
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | Diagnostics use `logging`; the required result line `cookies/second: N` is the program's output, not a log; no secrets are read or logged. |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | No Design Class Diagram exists (ADR-0001 skips use case design); ADR-0002 records the layout instead. |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 66 tests pass with fakes; 100% coverage of the logic modules; the 105-cookie Grandma versus Cursor case is tested. |
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy --strict` passes; no `Any`. |
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `pyproject.toml` lists Selenium as the only runtime dependency; dev extras are test and lint tools. |
| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | Constants live only in `constants.py`; rules are defined once. |
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `domain` imports no Selenium; `application` depends on a port; no import cycles. |
| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | Modules have one reason to change; the game is behind a narrow port. |
| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | Layers `domain`, `application`, `infrastructure` under `src/`, `unit` and `integration` under `tests/`; no `presentation` package by ADR-0002. |
| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | English names, snake_case files, no spaces. |
## Overall Verdict
Go — the code of MIL-004 meets every Mandatory criterion; `ruff`, `mypy --strict` and `pytest` were run and pass. The review is not independent: the AI wrote and reviewed it as stand-in for S02, and S01 can challenge it in the end-of-run summary.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| None | S01 | - |
---
[MIL-004]: ../../milestones/mil-004-buy-upgrades.md
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[97215c4]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/commit/97215c4137ac798f63600abba947968671ad3957
+62
View File
@@ -0,0 +1,62 @@
# SQA Review Record
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-017 |
| CrossReference | [MIL-005], [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 | [e3155de] |
---
## Artifact Under Review
- Instance reviewed: [MIL-005]
- Checklist used: [QC-PY-001] (`QC-PY-001`)
- Scope: full review of the source and tests added by MIL-005: cookies per second parsing, result formatter, elapsed time, stale-element handling, JavaScript fallback for blocked product clicks and 27 new tests (written with the python-developer agent); a full 300 s container run printed one stdout line, cookies/second: 18.9, and produced step-4.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` (stderr); the required result line is printed to stdout by `main` and is marked as the program output; 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 | 93 tests pass with fakes, including regressions for the stale element and the blocked click found in real runs; 100% coverage of the logic modules. |
| 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-005 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-005]: ../../milestones/mil-005-timed-run.md
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[e3155de]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/commit/e3155de9b58e16bd7be98ef73724fe90305e6627
+2
View File
@@ -43,6 +43,8 @@ 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 |
| Source and tests of [MIL-005] | Python source code | - | - | [MIL-005], [ADR-0002] | - | RC-017 |
## Concern Coverage
+33
View File
@@ -0,0 +1,33 @@
**English** · [Dansk](Step-4-Five-Minute-Run-da) · [Bahasa Malaysia](Step-4-Five-Minute-Run-ms)
# Lecture step 4: stop after five minutes and print cookies per second
![The game after the five minute run](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/raw/branch/main/docs/images/step-4.png)
## What the assignment asks
After 5 minutes the bot stops and prints `cookies/second`.
## What we built
- `click_and_buy` runs until a deadline measured with a monotonic clock (`time.monotonic`), so the run lasts exactly `RUN_DURATION_SECONDS` (300) and does not drift if a click is slow.
- `SeleniumGame.cookies_per_second` reads the game's own figure from `#cookiesPerSecond` (for example `per second: 18.9`), and `parse_cookies_per_second` turns it into a number, including values such as `1.2 million`.
- `format_result` builds the line `cookies/second: 18.9`. The program prints exactly this one line on standard output; the log (clicks, purchases, elapsed time) goes to standard error, so `cookie-clicker-bot > result.txt` keeps only the result.
## Our first result
A full run in the Docker container with the assignment's rules (check every 5 seconds, buy the most expensive affordable product):
| Measure | Value |
| --- | --- |
| Duration | 300.0 seconds |
| Clicks | 7,666 |
| Products bought | 45 (29 Cursor, 16 Grandma) |
| Result | `cookies/second: 18.9` |
## What we gained in this step
- **A timed loop that does not drift.** A monotonic clock and a deadline are more reliable than counting sleeps.
- **Separate output for people and for programs.** Logging goes to standard error and the result to standard output.
- **Two real bugs found by a real run.** The game re-renders after the language is chosen, so an element can go stale between finding it and using it; the fix is to look again. A cookie banner covers part of the store, so a click on a product can be intercepted; the bot then clicks with JavaScript and counts a purchase only when the click went through. Unit tests with a fake driver did not show either problem, which is why every step has a container run and a screenshot.
- **A baseline.** 18.9 cookies per second is the number step 5 tries to beat.
+52 -12
View File
@@ -4,8 +4,22 @@
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, ...]
## Seconds the run lasted, measured with the clock given to `click_and_buy`.
elapsed_seconds: float
def click_cookie(game: GamePort) -> None:
@@ -16,21 +30,47 @@ 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 or
the click did not reach the product.
"""
deadline = clock() + duration_seconds
chosen = choose_most_expensive_affordable(game.upgrades(), game.cookie_count())
if chosen is not None and game.buy(chosen):
return chosen
return None
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 clicks, the names of the upgrades bought in order, and the time spent.
"""
start = clock()
deadline = start + duration_seconds
next_check = start + check_interval_seconds
clicks = 0
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), elapsed_seconds=clock() - start
)
@@ -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,35 @@ 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 cookies_per_second(self) -> float:
"""@brief Read how many cookies the player makes each second.
@return The cookies per second.
"""
...
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) -> bool:
"""@brief Buy an upgrade by clicking it.
@param upgrade The upgrade to buy.
@return True only when a click really reached the product.
"""
...
def save_screenshot(self, path: Path) -> None:
"""@brief Write a screenshot of the game to a PNG file.
@@ -0,0 +1,16 @@
"""@file report.py
@brief The text the program prints when a run ends.
"""
from cookie_clicker_bot import constants
def format_result(cookies_per_second: float) -> str:
"""@brief Build the result line, for example `cookies/second: 8.5`.
@param cookies_per_second The cookies per second at the end of the run.
@return The line, without a trailing newline.
"""
return constants.RESULT_MESSAGE_TEMPLATE.format(
cookies_per_second=cookies_per_second
)
+25 -4
View File
@@ -8,8 +8,9 @@ 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.application.report import format_result
from cookie_clicker_bot.infrastructure.browser import create_driver
from cookie_clicker_bot.infrastructure.selenium_game import SeleniumGame
@@ -33,7 +34,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 +45,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,10 +87,21 @@ 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 %.1f seconds",
result.clicks,
len(result.purchases),
result.elapsed_seconds,
)
cookies_per_second = game.cookies_per_second()
logger.info("%s", format_result(cookies_per_second))
if args.screenshot is not None:
game.save_screenshot(args.screenshot)
finally:
game.close()
# The program's required result: the only line written to stdout (logs use stderr).
print(format_result(cookies_per_second))
return 0
+43 -1
View File
@@ -30,6 +30,45 @@ 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"
## Id of the element that shows only the cookies per second (child of `#cookies`).
COOKIES_PER_SECOND_ID: Final[str] = "cookiesPerSecond"
## The one line the program prints on stdout when the run ends.
RESULT_MESSAGE_TEMPLATE: Final[str] = "cookies/second: {cookies_per_second:.12g}"
## 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
@@ -40,7 +79,10 @@ CHROME_BIN_ENV: Final[str] = "CHROME_BIN"
CHROMEDRIVER_PATH_ENV: Final[str] = "CHROMEDRIVER_PATH"
## Size of the browser window in pixels, so the screenshots look the same every time.
WINDOW_SIZE: Final[str] = "1366,768"
WINDOW_SIZE: Final[str] = "1366,900"
## Script that clicks an element from inside the page, past anything that covers it.
JS_CLICK_SCRIPT: Final[str] = "arguments[0].click();"
## Chromium arguments needed to run headless inside a container.
HEADLESS_BROWSER_ARGUMENTS: Final[tuple[str, ...]] = (
+69
View File
@@ -0,0 +1,69 @@
"""@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.
"""
return int(_read_amount(text))
def parse_cookies_per_second(text: str) -> float:
"""@brief Read the text of `#cookiesPerSecond`, such as `per second: 1,234.5`.
@param text The visible text of the element, with its `per second:` label.
@return The cookies per second.
@throws ValueError When the text holds no amount.
"""
amount = text.lower().replace(constants.PER_SECOND_MARKER, "", 1).strip()
return float(_read_amount(amount.removeprefix(":")))
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 _read_amount(text: str) -> Decimal:
"""@brief Read an amount with optional commas and magnitude word, exactly."""
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 number * multiplier
def _message(text: str) -> str:
"""@brief Build the error message for a text that is not an amount."""
return f"not an amount of cookies: {text!r}"
+33
View File
@@ -0,0 +1,33 @@
"""@file upgrades.py
@brief The rule for which upgrade to buy.
"""
from collections.abc import Sequence
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Upgrade:
"""@brief A product of the right-hand pane that can be bought."""
## Id of the product element on the page (product0 .. product19).
key: str
## Name shown in the store, for example Cursor.
name: str
## Price in cookies.
price: int
def choose_most_expensive_affordable(
upgrades: Sequence[Upgrade], cookies: int
) -> Upgrade | None:
"""@brief Pick the most expensive upgrade the player can pay for.
@param upgrades The visible upgrades, in store order.
@param cookies The cookies the player has.
@return The chosen upgrade (the first on a tie), or None when none is affordable.
"""
affordable = [upgrade for upgrade in upgrades if upgrade.price <= cookies]
if not affordable:
return None
return max(affordable, key=lambda upgrade: upgrade.price)
@@ -3,9 +3,9 @@
"""
import time
from collections.abc import Callable
from collections.abc import Callable, Sequence
from pathlib import Path
from typing import Protocol, TypeVar
from typing import Any, Protocol, TypeVar
from selenium.common.exceptions import (
ElementClickInterceptedException,
@@ -15,6 +15,12 @@ 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,
parse_cookies_per_second,
)
from cookie_clicker_bot.domain.upgrades import Upgrade
T = TypeVar("T")
@@ -34,6 +40,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,10 +59,18 @@ 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."""
...
def execute_script(self, script: str, *args: Any) -> Any: # noqa: ANN401
"""@brief Run JavaScript in the page; the arguments are `arguments[n]`."""
...
def quit(self) -> None:
"""@brief Close the browser."""
@@ -93,6 +115,69 @@ 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.
"""
return self._read_again_if_stale(
lambda: parse_cookie_count(
self._driver.find_element(By.ID, constants.COOKIE_COUNT_ID).text
)
)
def cookies_per_second(self) -> float:
"""@brief Read how many cookies the player makes each second.
@return The cookies per second.
"""
return self._read_again_if_stale(
lambda: parse_cookies_per_second(
self._driver.find_element(By.ID, constants.COOKIES_PER_SECOND_ID).text
)
)
def upgrades(self) -> list[Upgrade]:
"""@brief Read the upgrades the player can see in the right-hand pane.
@return The visible upgrades, affordable or not, in store order.
"""
return self._read_again_if_stale(self._read_upgrades)
def _read_upgrades(self) -> list[Upgrade]:
"""@brief Find the visible products and read each one."""
products = self._driver.find_elements(
By.CSS_SELECTOR, constants.VISIBLE_PRODUCT_SELECTOR
)
return [self._read_upgrade(product) for product in products]
@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 buy(self, upgrade: Upgrade) -> bool:
"""@brief Buy an upgrade by clicking its product.
@param upgrade The upgrade to buy.
@return True only when a click really reached the product.
"""
try:
element = self._driver.find_element(By.ID, upgrade.key)
try:
element.click()
except ElementClickInterceptedException:
# The cookie-consent banner covers the lower products. Click from
# inside the page instead; the banner itself is never clicked.
self._driver.execute_script(constants.JS_CLICK_SCRIPT, element)
except StaleElementReferenceException:
# The pane re-rendered under us; the next check retries.
return False
return True
def save_screenshot(self, path: Path) -> None:
"""@brief Write a screenshot of the game to a PNG file.
@@ -105,13 +190,22 @@ 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:
element = self._driver.find_element(By.ID, element_id)
except NoSuchElementException:
return element if element.is_displayed() else None
except (NoSuchElementException, StaleElementReferenceException):
# Not there yet, or the page re-rendered under us: poll again.
return None
return element if element.is_displayed() else None
def _wait_for(self, locate: Callable[[], T | None]) -> T:
"""@brief Poll until `locate` returns a value.
+220 -1
View File
@@ -4,6 +4,7 @@
from collections.abc import Callable
from pathlib import Path
from typing import Any
import pytest
from selenium.common.exceptions import (
@@ -13,6 +14,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 +25,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,9 +58,14 @@ 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
self.scripts: list[tuple[str, FakeElement]] = []
def execute_script(self, script: str, *args: Any) -> None: # noqa: ANN401
self.scripts.append((script, args[0]))
def get(self, url: str) -> None:
self.loaded.append(url)
@@ -55,6 +75,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
@@ -123,6 +148,55 @@ def test_dismiss_language_polls_until_the_button_is_visible() -> None:
assert fake_time.sleeps == [constants.POLL_INTERVAL_SECONDS] * 2
def stale_once() -> Callable[[], bool]:
"""Return an `is_displayed` that goes stale on the first call only."""
calls: list[int] = []
def displayed() -> bool:
calls.append(1)
if len(calls) == 1:
raise StaleElementReferenceException
return True
return displayed
def test_dismiss_language_survives_a_cookie_that_goes_stale_once() -> None:
driver = ready_driver()
driver.elements[constants.BIG_COOKIE_ID] = FakeElement(displayed=stale_once())
make_game(driver).dismiss_language()
assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1
def test_dismiss_language_survives_a_button_that_goes_stale_once() -> None:
driver = ready_driver()
driver.elements[constants.LANGUAGE_ENGLISH_ID] = FakeElement(displayed=stale_once())
make_game(driver).dismiss_language()
assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1
def test_dismiss_language_survives_a_lookup_that_goes_stale_once() -> None:
driver = ready_driver()
real_find = driver.find_element
calls: list[int] = []
def find_element(by: str, value: str) -> FakeElement:
calls.append(1)
if len(calls) == 1:
raise StaleElementReferenceException
return real_find(by, value)
driver.find_element = find_element # type: ignore[method-assign]
make_game(driver).dismiss_language()
assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1
def test_dismiss_language_times_out_when_the_button_never_shows() -> None:
fake_time = FakeTime()
@@ -157,6 +231,151 @@ 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_cookies_per_second_reads_its_element() -> None:
driver = FakeDriver(
{constants.COOKIES_PER_SECOND_ID: FakeElement(text="per second: 1,234.5")}
)
assert make_game(driver).cookies_per_second() == 1234.5
def test_upgrades_reads_the_visible_products() -> None:
driver = FakeDriver()
driver.groups[constants.VISIBLE_PRODUCT_SELECTOR] = [
make_product("product0", "Cursor", "15"),
make_product("product2", "Farm", "1,100"),
]
assert make_game(driver).upgrades() == [
Upgrade("product0", "Cursor", 15),
Upgrade("product2", "Farm", 1100),
]
class StaleOnceText(FakeElement):
"""An element whose text goes stale on the first read."""
def __init__(self, text: str) -> None:
super().__init__()
self._text = text
self._reads = 0
@property
def text(self) -> str:
self._reads += 1
if self._reads == 1:
raise StaleElementReferenceException
return self._text
@text.setter
def text(self, value: str) -> None:
self._text = value
def test_cookie_count_retries_once_when_the_counter_goes_stale() -> None:
driver = FakeDriver({constants.COOKIE_COUNT_ID: StaleOnceText("42 cookies")})
assert make_game(driver).cookie_count() == 42
def test_cookies_per_second_retries_once_when_the_element_goes_stale() -> None:
driver = FakeDriver(
{constants.COOKIES_PER_SECOND_ID: StaleOnceText("per second: 3.5")}
)
assert make_game(driver).cookies_per_second() == 3.5
def test_upgrades_retries_once_when_a_product_goes_stale() -> None:
product = make_product("product0", "Cursor", "15")
product.get_attribute = _stale_once_then(product.get_attribute) # type: ignore[assignment]
driver = FakeDriver()
driver.groups[constants.VISIBLE_PRODUCT_SELECTOR] = [product]
assert make_game(driver).upgrades() == [Upgrade("product0", "Cursor", 15)]
def test_cookie_count_gives_up_when_the_counter_stays_stale() -> None:
driver = FakeDriver()
driver.find_element = _always_stale # type: ignore[method-assign]
with pytest.raises(StaleElementReferenceException):
make_game(driver).cookie_count()
def _always_stale(by: str, value: str) -> FakeElement:
raise StaleElementReferenceException
def _stale_once_then(
original: Callable[[str], str | None],
) -> Callable[[str], str | None]:
calls: list[int] = []
def get_attribute(name: str) -> str | None:
calls.append(1)
if len(calls) == 1:
raise StaleElementReferenceException
return original(name)
return get_attribute
def test_upgrades_is_empty_when_no_product_is_visible() -> None:
assert make_game(FakeDriver()).upgrades() == []
def test_buy_clicks_the_product_with_the_upgrade_key() -> None:
driver = FakeDriver({"product1": FakeElement()})
bought = make_game(driver).buy(Upgrade("product1", "Grandma", 100))
assert bought is True
assert driver.elements["product1"].clicks == 1
assert driver.scripts == []
def test_buy_clicks_through_javascript_when_a_banner_covers_the_product() -> None:
element = FakeElement(click_error=ElementClickInterceptedException())
driver = FakeDriver({"product1": element})
bought = make_game(driver).buy(Upgrade("product1", "Grandma", 100))
assert bought is True
assert driver.scripts == [(constants.JS_CLICK_SCRIPT, element)]
def test_buy_returns_false_for_a_stale_product() -> None:
driver = FakeDriver(
{"product1": FakeElement(click_error=StaleElementReferenceException())}
)
assert make_game(driver).buy(Upgrade("product1", "Grandma", 100)) is False
assert driver.scripts == []
def test_save_screenshot_creates_the_folder_and_calls_the_driver(
tmp_path: Path,
) -> None:
+58 -1
View File
@@ -9,12 +9,14 @@ import pytest
from cookie_clicker_bot import constants
from cookie_clicker_bot.application.ports import GamePort
from cookie_clicker_bot.cli import main, parse_args
from cookie_clicker_bot.domain.upgrades import Upgrade
class RecordingGame:
"""A game that records every call in order."""
def __init__(self, fail_on_open: bool = False) -> None:
def __init__(self, fail_on_open: bool = False, buy_succeeds: bool = True) -> None:
self._buy_succeeds = buy_succeeds
self.calls: list[str] = []
self.screenshots: list[Path] = []
self._fail_on_open = fail_on_open
@@ -31,6 +33,19 @@ class RecordingGame:
def click_cookie(self) -> None:
self.calls.append("click_cookie")
def cookie_count(self) -> int:
return 1000
def cookies_per_second(self) -> float:
return 8.5
def upgrades(self) -> list[Upgrade]:
return [Upgrade("product0", "Cursor", 15)]
def buy(self, upgrade: Upgrade) -> bool:
self.calls.append("buy")
return self._buy_succeeds
def save_screenshot(self, path: Path) -> None:
self.calls.append("save_screenshot")
self.screenshots.append(path)
@@ -43,6 +58,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 +70,8 @@ def test_parse_args_reads_every_option() -> None:
"--headless",
"--duration",
"10",
"--interval",
"2.5",
"--screenshot-start",
"a.png",
"--screenshot",
@@ -63,6 +81,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")
@@ -115,3 +134,41 @@ def test_main_closes_the_game_when_an_error_is_raised() -> None:
main(["--duration", "0"], lambda _headless: game)
assert game.calls == ["open_game", "close"]
def test_main_prints_exactly_one_result_line_on_stdout(
capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture
) -> None:
caplog.set_level("INFO")
main(["--duration", "0"], lambda _headless: RecordingGame())
out = capsys.readouterr().out
assert out == "cookies/second: 8.5" + chr(10)
assert "clicked the cookie" not in out
assert "clicked the cookie" in caplog.text
def test_main_logs_bought_only_for_real_purchases(
caplog: pytest.LogCaptureFixture,
) -> None:
caplog.set_level("INFO")
main(["--duration", "0.05", "--interval", "0"], 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)
assert "buy" in game.calls
assert "bought Cursor" not in caplog.text
assert "bought 0 upgrades" in caplog.text
def test_main_prints_nothing_on_stdout_when_the_run_fails(
capsys: pytest.CaptureFixture[str],
) -> None:
with pytest.raises(RuntimeError, match="boom"):
main(["--duration", "0"], lambda _headless: RecordingGame(fail_on_open=True))
assert capsys.readouterr().out == ""
+130 -25
View File
@@ -4,14 +4,45 @@
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,
buy_succeeds: bool = True,
) -> None:
self.buy_succeeds = buy_succeeds
self.clicks = 0
self.cookies = cookies
self.available = upgrades or []
self.bought: list[Upgrade] = []
self.buy_times: list[float] = []
self._clock = clock
def open_game(self) -> None:
pass
@@ -21,6 +52,23 @@ 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 cookies_per_second(self) -> float:
return 0.0
def upgrades(self) -> list[Upgrade]:
return self.available
def buy(self, upgrade: Upgrade) -> bool:
self.bought.append(upgrade)
if self._clock is not None:
self.buy_times.append(self._clock.now)
return self.buy_succeeds
def save_screenshot(self, path: Path) -> None:
pass
@@ -29,18 +77,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 +85,87 @@ 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"), elapsed_seconds=12.0
)
# The checks fall in the iterations that start at 5 s and 10 s; each click then
# moves the fake clock on by one second.
assert game.buy_times == [6.0, 11.0]
def test_click_and_buy_does_not_check_before_the_first_interval() -> None:
clock = FakeClock()
game = FakeGame(cookies=105, upgrades=[GRANDMA], clock=clock)
result = click_and_buy(game, 4, 5, clock)
assert result == PlayResult(clicks=4, purchases=(), elapsed_seconds=4.0)
assert game.bought == []
def test_click_and_buy_records_only_real_purchases() -> None:
clock = FakeClock()
game = FakeGame(cookies=10, upgrades=[CURSOR, GRANDMA], clock=clock)
result = click_and_buy(game, 11, 5, clock)
assert result.clicks == 11
assert result.purchases == ()
def test_buy_most_expensive_upgrade_returns_none_when_the_click_fails() -> None:
game = FakeGame(cookies=105, upgrades=[CURSOR, GRANDMA], buy_succeeds=False)
assert buy_most_expensive_upgrade(game) is None
assert game.bought == [GRANDMA]
def test_click_and_buy_does_not_record_a_purchase_when_the_click_fails() -> None:
clock = FakeClock()
game = FakeGame(cookies=105, upgrades=[GRANDMA], clock=clock, buy_succeeds=False)
result = click_and_buy(game, 12, 5, clock)
assert game.buy_times == [6.0, 11.0]
assert result.purchases == ()
def test_click_and_buy_does_not_click_when_duration_is_zero() -> None:
clock = FakeClock()
game = FakeGame(clock=clock)
assert click_and_buy(game, 0, 5, clock) == PlayResult(
clicks=0, purchases=(), elapsed_seconds=0.0
)
def test_click_and_buy_stops_at_the_deadline_and_reports_elapsed_time() -> None:
clock = FakeClock()
game = FakeGame(clock=clock)
result = click_and_buy(game, 300, 5, clock)
assert result.clicks == 300
assert result.elapsed_seconds == 300.0
+82
View File
@@ -0,0 +1,82 @@
"""@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,
parse_cookies_per_second,
)
@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")
@pytest.mark.parametrize(
("text", "expected"),
[
("per second: 0", 0.0),
("per second: 8.5", 8.5),
("per second: 1,234.5", 1234.5),
("per second: 1.2 million", 1_200_000.0),
(" Per second: 3 ", 3.0),
],
)
def test_parse_cookies_per_second_reads_the_element(text: str, expected: float) -> None:
assert parse_cookies_per_second(text) == expected
@pytest.mark.parametrize(
"text", ["", "per second:", "per second: abc", "per second: -1"]
)
def test_parse_cookies_per_second_rejects_garbage(text: str) -> None:
with pytest.raises(ValueError, match="not an amount of cookies"):
parse_cookies_per_second(text)
+20
View File
@@ -0,0 +1,20 @@
"""@file test_report.py
@brief Tests of the result line.
"""
import pytest
from cookie_clicker_bot.application.report import format_result
@pytest.mark.parametrize(
("value", "expected"),
[
(8.5, "cookies/second: 8.5"),
(0.0, "cookies/second: 0"),
(1234.5, "cookies/second: 1234.5"),
(1_200_000.0, "cookies/second: 1200000"),
],
)
def test_format_result_writes_the_summary_line(value: float, expected: str) -> None:
assert format_result(value) == expected
+35
View File
@@ -0,0 +1,35 @@
"""@file test_upgrades.py
@brief Tests of the rule that picks the upgrade to buy.
"""
from cookie_clicker_bot.domain.upgrades import Upgrade, choose_most_expensive_affordable
CURSOR = Upgrade("product0", "Cursor", 15)
GRANDMA = Upgrade("product1", "Grandma", 100)
FARM = Upgrade("product2", "Farm", 1100)
def test_choose_picks_the_most_expensive_affordable() -> None:
assert choose_most_expensive_affordable([CURSOR, GRANDMA, FARM], 105) == GRANDMA
def test_choose_returns_none_when_nothing_is_affordable() -> None:
assert choose_most_expensive_affordable([CURSOR, GRANDMA], 10) is None
def test_choose_returns_none_without_upgrades() -> None:
assert choose_most_expensive_affordable([], 1000) is None
def test_choose_accepts_a_price_equal_to_the_cookies() -> None:
assert choose_most_expensive_affordable([CURSOR, GRANDMA], 100) == GRANDMA
def test_choose_takes_the_first_on_a_tie() -> None:
twin = Upgrade("product9", "Twin", 100)
assert choose_most_expensive_affordable([GRANDMA, twin], 500) == GRANDMA
def test_choose_ignores_the_order_of_the_list() -> None:
assert choose_most_expensive_affordable([GRANDMA, CURSOR], 105) == GRANDMA