Compare commits

..
Author SHA1 Message Date
Tirsvad f5e9e0b737 Resolve pending commit links 2026-10-28 17:00:42 +08:00
Tirsvad 9a9ea53cc1 Add headless Docker run, screenshots per lecture step and README
Dockerfile with Chromium, capture_steps module, Chrome environment
support, stale-element retries, owned-amount purchase check, the five
screenshots from a 300 second run (16.6 cookies per second) and the README
from the framework template. Reviewed in RC-013.

Task: MIL-004#1
Task: MIL-004#2
Task: MIL-004#3
Task: MIL-004#4
Task: MIL-004#5
Closes #19
Closes #20
Closes #21
Closes #22
Closes #23
2026-10-28 17:00:38 +08:00
Tirsvad 9592dd0ea0 Merge pull request 'MIL-003: Upgrades and the five-minute run' (#31) from mil-003-upgrades-run into main
TirSystem/git-workflows: Sync GitHub mirror metadata / sync-metadata (push) Successful in 5s
2026-10-28 09:37:31 +01:00
22 changed files with 1071 additions and 29 deletions
+13
View File
@@ -0,0 +1,13 @@
.git
.venv
.env
.claude
.agents
framework
docs
runs
tests
**/__pycache__
.pytest_cache
.mypy_cache
.ruff_cache
+172 -1
View File
@@ -1,2 +1,173 @@
# 048-the-coockie-factory
# 🚀 Cookie Clicker Bot
A Selenium bot that plays [Cookie Clicker](https://ozh.github.io/cookieclicker/) for five minutes: it clicks the big cookie, buys upgrades and reports its cookies per second (CPS). It is the day 48 project of Udemy's *100 Days of Code: The Complete Python Pro Bootcamp*, written to be read, run and compared by other course participants.
## 📚 Table of Contents
- [Overview](#-overview)
- [Requirements](#-requirements)
- [Setup](#-setup)
- [Run](#-run)
- [Tests](#-tests)
- [License](#-license)
- [Links](#-links)
- [Appendix: Project documents](#-appendix-project-documents)
## 🧭 Overview
The bot follows the five steps of the lecture. Every step is one function with the assignment's name, and every step has a screenshot taken by the bot itself in a headless Chromium inside Docker.
| Step | Function | What it does |
| --- | --- | --- |
| 1 | `open_game`, `select_language` | Opens the game and picks English |
| 2 | `click_cookie` | Clicks the big cookie |
| 3 | `get_cookie_count` | Reads the number of cookies |
| 4 | `get_store_items`, `choose_upgrade`, `buy_upgrade` | Buys the most expensive upgrade it can afford |
| 5 | `run_bot`, `get_cps` | Plays for 300 seconds and reports the CPS |
### Lecture step screenshots
**Step 1: open the game and choose a language**
![Step 1: the language prompt](docs/images/step-1-open-game.png)
**Step 2: click the big cookie**
![Step 2: after 15 clicks](docs/images/step-2-click-cookie.png)
**Step 3: read the cookie count**
![Step 3: the cookie counter](docs/images/step-3-read-count.png)
**Step 4: buy the first upgrade**
![Step 4: a cursor bought](docs/images/step-4-buy-upgrade.png)
**Step 5: the five-minute result**
![Step 5: the bakery after five minutes](docs/images/step-5-result.png)
In the run that made these screenshots the bot reached **16.6 cookies per second** after 300 seconds. Your number will differ: it depends on the speed of your machine and the network.
### Parts of the repository
| Folder | Holds |
| --- | --- |
| `src/cookie_clicker_bot/domain/` | Game rules without Selenium: number parsing and the purchase choice |
| `src/cookie_clicker_bot/application/` | The lecture step functions and the ports they need |
| `src/cookie_clicker_bot/infrastructure/` | The Selenium adapter and the clock |
| `src/cookie_clicker_bot/presentation/` | The command line entry points |
| `src/cookie_clicker_bot/constants.py` | All constants: address, element ids, timings |
| `tests/` | pytest tests that need no browser |
| `docker/` | The Docker image used for headless runs and screenshots |
| `docs/` | Project documents, screenshots and the wiki |
The only runtime dependency is `selenium`; a bot cannot drive a browser without it (see ADR-0001 in `docs/adr/`).
## 📋 Requirements
- Python 3.13 or newer.
- Google Chrome (Selenium Manager downloads the matching driver). Not needed to run the tests.
- Docker, only to repeat the headless run and the screenshots.
- Doxygen, only to build the API documentation.
## 🛠️ Setup
Create a local virtual environment, upgrade `pip` and install the project with its development tools.
Windows (PowerShell):
```powershell
python -m venv .venv
.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```
Linux and macOS:
```bash
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```
Leave the environment with `deactivate`. The `.venv` folder is ignored by git.
## ▶️ Run
With the virtual environment active, start the bot. A Chrome window opens and plays for five minutes, then the bot prints the CPS and closes the browser:
```bash
python -m cookie_clicker_bot
```
Useful options:
```bash
python -m cookie_clicker_bot --headless
python -m cookie_clicker_bot --duration 60
```
To repeat the headless run and the screenshots without touching your desktop, use Docker. The screenshots and a `steps.json` summary land in `runs/` (the ones in `docs/images/` came from such a run):
```bash
docker build -t cookie-clicker-bot -f docker/Dockerfile .
docker run --rm -v "$(pwd)/runs:/runs" cookie-clicker-bot --out /runs
```
To build the API documentation from the Doxygen comments, run `doxygen Doxyfile`; the result is in `docs/api/html/`.
## 🧪 Tests
The tests use fakes for the game and the clock, so they need neither Chrome nor the network:
```bash
python -m pytest
python -m ruff check src tests
python -m mypy src tests
```
## 📄 License
GNU Affero General Public License v3.0, see [LICENSE](LICENSE).
## 🔗 Links
- [Repository][repository]
- [Documentation][documentation]
- [Issue tracker][issues]
## 📎 Appendix: Project documents
The documents a newcomer needs, in reading order.
| Document | What it tells you |
| --- | --- |
| [AGENTS.md][agents] | The rules AI agents follow in this repository |
| [Business Case][BC-001] | Why the project exists, its scope and success criteria |
| [Stakeholder Analysis][SA-001] | Who is involved; the stakeholder IDs used for owners and reviewers |
| [Project Plan][PP-001] | The gateways and their schedule |
| [Milestones][milestones] | One document per gateway: Go/No-Go criteria and tasks |
| [ADR-0001][ADR-0001] | Why Selenium is the one runtime dependency |
| [Artifact registry][registry] | Where each artifact type lives, the next version, the PO language and domain |
| [Traceability matrix][TM-001] | How the artifacts link and when each was last reviewed |
| [Framework README][framework-readme] | The framework this project uses |
| [Framework changelog][framework-changelog] | What changed in each framework release |
---
[repository]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory
[documentation]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/wiki
[issues]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/issues
[agents]: ./AGENTS.md
[BC-001]: ./docs/business-case.md
[SA-001]: ./docs/stakeholder-analysis.md
[PP-001]: ./docs/project-plan.md
[milestones]: ./docs/milestones/
[ADR-0001]: ./docs/adr/adr-0001-selenium-runtime-dependency.md
[registry]: ./docs/artifact-registry.md
[TM-001]: ./docs/sqa/traceability-matrix.md
[framework-readme]: ./framework/README.md
[framework-changelog]: ./framework/CHANGELOG.md
+25
View File
@@ -0,0 +1,25 @@
# Headless Chromium for the Cookie Clicker bot. GUI runs never use the host desktop.
FROM python:3.13-slim-bookworm
RUN apt-get update \
&& apt-get install -y --no-install-recommends chromium chromium-driver fonts-liberation \
&& rm -rf /var/lib/apt/lists/*
ENV CHROME_BIN=/usr/bin/chromium \
CHROMEDRIVER=/usr/bin/chromedriver \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY pyproject.toml README.md LICENSE ./
COPY src ./src
RUN python -m pip install --upgrade pip \
&& python -m pip install .
RUN useradd --create-home bot \
&& mkdir /runs \
&& chown bot /runs
USER bot
VOLUME ["/runs"]
ENTRYPOINT ["python", "-m", "cookie_clicker_bot.presentation.capture_steps"]
CMD ["--out", "/runs"]
+1 -1
View File
@@ -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 | 013 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 014 |
## Languages
+123
View File
@@ -0,0 +1,123 @@
INFO:__main__:Read 15 cookies
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 18 cookies
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
INFO:cookie_clicker_bot.application.use_cases.run_bot:6 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 100 cookies
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
INFO:cookie_clicker_bot.application.use_cases.run_bot:11 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 115 cookies
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
INFO:cookie_clicker_bot.application.use_cases.run_bot:16 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 133 cookies
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
INFO:cookie_clicker_bot.application.use_cases.run_bot:23 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 20 cookies
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 23 cookies
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 27 cookies
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 31 cookies
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
INFO:cookie_clicker_bot.application.use_cases.run_bot:28 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 35 cookies
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 40 cookies
INFO:cookie_clicker_bot.application.use_cases.run_bot:33 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 46 cookies
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 53 cookies
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
INFO:cookie_clicker_bot.application.use_cases.run_bot:39 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 61 cookies
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
INFO:cookie_clicker_bot.application.use_cases.run_bot:45 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 153 cookies
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
INFO:cookie_clicker_bot.application.use_cases.run_bot:51 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 70 cookies
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
INFO:cookie_clicker_bot.application.use_cases.run_bot:56 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 175 cookies
INFO:cookie_clicker_bot.application.use_cases.run_bot:62 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 81 cookies
INFO:cookie_clicker_bot.application.use_cases.run_bot:67 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 93 cookies
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
INFO:cookie_clicker_bot.application.use_cases.run_bot:72 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 107 cookies
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
INFO:cookie_clicker_bot.application.use_cases.run_bot:78 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 123 cookies
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
INFO:cookie_clicker_bot.application.use_cases.run_bot:83 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 141 cookies
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
INFO:cookie_clicker_bot.application.use_cases.run_bot:89 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 162 cookies
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
INFO:cookie_clicker_bot.application.use_cases.run_bot:95 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:101 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 202 cookies
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
INFO:cookie_clicker_bot.application.use_cases.run_bot:107 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 186 cookies
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
INFO:cookie_clicker_bot.application.use_cases.run_bot:112 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:117 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 232 cookies
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
INFO:cookie_clicker_bot.application.use_cases.run_bot:123 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 214 cookies
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
INFO:cookie_clicker_bot.application.use_cases.run_bot:129 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:134 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 267 cookies
INFO:cookie_clicker_bot.application.use_cases.run_bot:140 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:146 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 306 cookies
INFO:cookie_clicker_bot.application.use_cases.run_bot:151 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:157 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 246 cookies
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
INFO:cookie_clicker_bot.application.use_cases.run_bot:162 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:167 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 352 cookies
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
INFO:cookie_clicker_bot.application.use_cases.run_bot:172 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:178 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 283 cookies
INFO:cookie_clicker_bot.application.use_cases.run_bot:184 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:190 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 325 cookies
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
INFO:cookie_clicker_bot.application.use_cases.run_bot:195 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:201 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 405 cookies
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
INFO:cookie_clicker_bot.application.use_cases.run_bot:207 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:213 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 374 cookies
INFO:cookie_clicker_bot.application.use_cases.run_bot:218 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:223 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:229 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 466 cookies
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
INFO:cookie_clicker_bot.application.use_cases.run_bot:234 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:240 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 430 cookies
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
INFO:cookie_clicker_bot.application.use_cases.run_bot:246 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:251 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:256 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 536 cookies
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
INFO:cookie_clicker_bot.application.use_cases.run_bot:262 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:268 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:273 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 494 cookies
INFO:cookie_clicker_bot.application.use_cases.run_bot:278 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:283 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:289 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 616 cookies
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
INFO:cookie_clicker_bot.application.use_cases.run_bot:294 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:299 of 300 seconds played
INFO:cookie_clicker_bot.application.use_cases.run_bot:Run finished at 16.6 cookies per second
Result : 16.6
exit 0
Binary file not shown.

After

Width:  |  Height:  |  Size: 663 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+62
View File
@@ -0,0 +1,62 @@
# SQA Review Record
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-013 |
| 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 S01 | Initial version | [9a9ea53] |
---
## Artifact Under Review
- Instance reviewed: source of milestone MIL-004 (capture_steps, Chrome environment support, retry, owned-amount purchase check) and docker/Dockerfile ([MIL-004])
- 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 (StepUnreachableError, build_chrome_options, OWNED_CHECK_ATTEMPTS). |
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff check` and `ruff format --check` clean, no suppressions. |
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | All signatures annotated; mypy --strict passes on 35 files. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | StepUnreachableError is raised and not swallowed; stale-element retries re-raise the last error after the limit. |
| 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 and capture_steps.main. |
| 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; print is used only for the program result; no secrets in output. |
| 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 | 99 offline tests (fake game, fake clock, fake driver); the live behaviour was checked in Docker: two full 300 second runs reported 15.7 and 16.6 cookies per second, the last one is in the README. |
| 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; the Docker image installs only chromium for the browser. |
| 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 | Environment variable names, step file names and limits live in constants.py. |
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | selenium is imported only in infrastructure; presentation modules are composition roots. |
| 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 | Ports stay narrow; capture_steps uses SeleniumGame directly because screenshots are documentation tooling (decision recorded in the hand-over). |
| 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/, unit, integration and e2e 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, no spaces; Dockerfile is a fixed tool name. |
## Overall Verdict
Go - All Mandatory criteria pass or are not applicable. GUI runs were done headless in a Docker container; no GUI was started on the host desktop. The Docker run showed that about 40 percent of the purchase attempts fail because the store flags are read just before the game redraws; the bot logs and retries at the next store check (noted for the wiki). Stand-in review, not independent.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| None | S01 | - |
---
[MIL-004]: ../../milestones/mil-004-screenshots-and-readme.md
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[9a9ea53]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/commit/9a9ea53cc17d0d4acb258855088ddf45b48b26ea
+4 -3
View File
@@ -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 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) | [cb8e5ed] |
| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Added RC-013 (MIL-004 source review) | [9a9ea53] |
---
@@ -30,7 +30,7 @@ updated whenever an artifact instance is created or reviewed.
| [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], [RC-012] |
| [MIL-004] | MIL | en | it | [MIL-003] | [MIL-005] | [RC-007] |
| [MIL-004] | MIL | en | it | [MIL-003] | [MIL-005] | [RC-007], [RC-013] |
| [MIL-005] | MIL | en | it | [MIL-004] | - | [RC-008] |
| [ADR-0001] | ADR | - | - | [BC-001], [SA-001], [MIL-001] | - | [RC-009] |
@@ -74,6 +74,7 @@ updated whenever an artifact instance is created or reviewed.
[RC-009]: ./reviews/rc-009-adr-0001-selenium-runtime-dependency.md
[RC-010]: ./reviews/rc-010-mil-001-source.md
[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
[RC-013]: ./reviews/rc-013-mil-004-source.md
[9a9ea53]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/commit/9a9ea53cc17d0d4acb258855088ddf45b48b26ea
@@ -29,7 +29,7 @@ def _buy_while_affordable(game: GamePort) -> int:
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
"Purchase of product %d did not raise the owned amount", item.index
)
break
bought += 1
@@ -5,14 +5,15 @@
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,
OWNED_CHECK_ATTEMPTS,
PRODUCT_COUNT,
PRODUCT_ID_PREFIX,
PRODUCT_OWNED_ID_PREFIX,
PRODUCT_PRICE_ID_PREFIX,
UNLOCKED_CLASS,
)
@@ -41,15 +42,28 @@ def get_store_items(game: GamePort) -> list[StoreItem]:
return items
def _read_owned(game: GamePort, index: int) -> int:
"""@brief Read how many of a product is owned; the game shows none as empty."""
text = game.read_text(PRODUCT_OWNED_ID_PREFIX + str(index)).strip()
return parse_game_number(text) if text else 0
def buy_upgrade(game: GamePort, item: StoreItem) -> bool:
"""@brief Click a product and check that the purchase went through.
The game redraws the page a moment after the click, so the owned amount is
read again, up to OWNED_CHECK_ATTEMPTS times, until it has grown.
@param game The game page to operate.
@param item The product to buy.
@return True when the cookie count dropped after the click.
@return True when the owned amount of the product grew after the click.
@throws UnreadableNumberError When the owned amount shows no number.
"""
before = get_cookie_count(game)
before = _read_owned(game, item.index)
game.click_element(PRODUCT_ID_PREFIX + str(item.index))
return get_cookie_count(game) < before
return any(
_read_owned(game, item.index) > before for _ in range(OWNED_CHECK_ATTEMPTS)
)
def get_cps(game: GamePort) -> Decimal:
+39
View File
@@ -38,6 +38,15 @@ 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"
## Element id prefix of the owned amount of a store product; the product index follows.
PRODUCT_OWNED_ID_PREFIX: str = "productOwned"
## Reads of the owned amount after a purchase click, as the game redraws late.
OWNED_CHECK_ATTEMPTS: int = 20
## Times an action on the page is repeated after a stale or blocked element.
CLICK_RETRIES: int = 3
## Attribute that holds the CSS classes of an element.
CLASS_ATTRIBUTE: str = "class"
@@ -56,6 +65,36 @@ CPS_SEPARATOR: str = ":"
## Label printed before the cookies per second at the end of the run.
RESULT_LABEL: str = "Result"
## Environment variable that names the Chrome binary to start.
CHROME_BIN_ENV: str = "CHROME_BIN"
## Environment variable that names the chromedriver executable to use.
CHROMEDRIVER_ENV: str = "CHROMEDRIVER"
## Screenshot file name of lecture step 1, the game opened at the language prompt.
SCREENSHOT_OPEN_GAME: str = "step-1-open-game.png"
## Screenshot file name of lecture step 2, after the first clicks on the cookie.
SCREENSHOT_CLICK_COOKIE: str = "step-2-click-cookie.png"
## Screenshot file name of lecture step 3, after the cookie count was read.
SCREENSHOT_READ_COUNT: str = "step-3-read-count.png"
## Screenshot file name of lecture step 4, after an upgrade was bought.
SCREENSHOT_BUY_UPGRADE: str = "step-4-buy-upgrade.png"
## Screenshot file name of lecture step 5, after the full run.
SCREENSHOT_RESULT: str = "step-5-result.png"
## File name of the record of what each capture step read and did.
STEPS_RECORD_FILE: str = "steps.json"
## Clicks on the big cookie in lecture step 2 of the capture.
CAPTURE_CLICK_COUNT: int = 15
## Most bursts of clicks the capture makes while it waits for an affordable product.
CAPTURE_MAX_BURSTS: int = 200
## Number words the game prints for large amounts, with their value.
NUMBER_SCALES: dict[str, int] = {
"thousand": 10**3,
@@ -2,13 +2,27 @@
@brief Selenium adapter for the GamePort, and the factory for the Chrome driver.
"""
import os
from collections.abc import Callable, Mapping
from pathlib import Path
from selenium import webdriver
from selenium.common.exceptions import (
ElementClickInterceptedException,
StaleElementReferenceException,
)
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support import expected_conditions
from selenium.webdriver.support.ui import WebDriverWait
from cookie_clicker_bot.constants import ELEMENT_TIMEOUT_SECONDS
from cookie_clicker_bot.constants import (
CHROME_BIN_ENV,
CHROMEDRIVER_ENV,
CLICK_RETRIES,
ELEMENT_TIMEOUT_SECONDS,
)
_HEADLESS_ARGUMENTS: tuple[str, ...] = (
"--headless=new",
@@ -17,6 +31,28 @@ _HEADLESS_ARGUMENTS: tuple[str, ...] = (
"--window-size=1280,1024",
)
_RETRYABLE_ERRORS = (StaleElementReferenceException, ElementClickInterceptedException)
def _retry_on_page_change[T](action: Callable[[], T]) -> T:
"""@brief Run the action again when the game re-renders the page under it.
The action finds its element itself, so each attempt works on a fresh one.
@param action Finds the element and acts on it.
@return What the action returns.
@throws StaleElementReferenceException When every attempt found a stale element.
@throws ElementClickInterceptedException When every attempt was blocked.
"""
attempts_left = CLICK_RETRIES
while True:
try:
return action()
except _RETRYABLE_ERRORS:
if attempts_left == 0:
raise
attempts_left -= 1
class SeleniumGame:
"""@brief Operates the game page through a Selenium web driver."""
@@ -38,8 +74,10 @@ class SeleniumGame:
@param element_id The HTML id of the element.
@throws selenium.common.exceptions.TimeoutException When it never appears.
"""
WebDriverWait(self._driver, ELEMENT_TIMEOUT_SECONDS).until(
expected_conditions.presence_of_element_located((By.ID, element_id))
_retry_on_page_change(
lambda: WebDriverWait(self._driver, ELEMENT_TIMEOUT_SECONDS).until(
expected_conditions.presence_of_element_located((By.ID, element_id))
)
)
def click_element(self, element_id: str) -> None:
@@ -51,7 +89,12 @@ class SeleniumGame:
@param element_id The HTML id of the element.
@throws selenium.common.exceptions.TimeoutException When it never
becomes clickable.
@throws StaleElementReferenceException When the element stays stale
after CLICK_RETRIES retries.
"""
_retry_on_page_change(lambda: self._click_once(element_id))
def _click_once(self, element_id: str) -> None:
element = WebDriverWait(self._driver, ELEMENT_TIMEOUT_SECONDS).until(
expected_conditions.element_to_be_clickable((By.ID, element_id))
)
@@ -62,7 +105,9 @@ class SeleniumGame:
@param element_id The HTML id of the element.
@return The text as shown on the page.
"""
return self._driver.find_element(By.ID, element_id).text
return _retry_on_page_change(
lambda: 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.
@@ -70,21 +115,61 @@ class SeleniumGame:
@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)
value = _retry_on_page_change(
lambda: self._driver.find_element(By.ID, element_id).get_attribute(
attribute
)
)
return value or ""
def save_screenshot(self, path: Path) -> None:
"""@brief Save a PNG of the page, creating the parent folder when needed.
@param path The file to write.
"""
path.parent.mkdir(parents=True, exist_ok=True)
self._driver.save_screenshot(str(path))
def close(self) -> None:
"""@brief Quit the browser and end the driver session."""
self._driver.quit()
def build_chrome_options(
headless: bool, env: Mapping[str, str]
) -> webdriver.ChromeOptions:
"""@brief Build the Chrome options.
@param headless True to run without a window, for a container or CI.
@param env The environment; CHROME_BIN names the Chrome binary when set.
@return The options for the Chrome driver.
"""
options = webdriver.ChromeOptions()
if headless:
for argument in _HEADLESS_ARGUMENTS:
options.add_argument(argument)
chrome_binary = env.get(CHROME_BIN_ENV)
if chrome_binary:
options.binary_location = chrome_binary
return options
def build_chrome_service(env: Mapping[str, str]) -> Service | None:
"""@brief Build the chromedriver service when the environment names one.
@param env The environment; CHROMEDRIVER names the executable when set.
@return The service, or None to let Selenium Manager find the driver.
"""
driver_path = env.get(CHROMEDRIVER_ENV)
if not driver_path:
return None
return Service(executable_path=driver_path)
def create_chrome_driver(headless: bool = False) -> WebDriver:
"""@brief Build a Chrome web driver.
@param headless True to run without a window, for a container or CI.
@return The started Chrome driver.
"""
options = webdriver.ChromeOptions()
if headless:
for argument in _HEADLESS_ARGUMENTS:
options.add_argument(argument)
return webdriver.Chrome(options=options)
options = build_chrome_options(headless, os.environ)
service = build_chrome_service(os.environ)
if service is None:
return webdriver.Chrome(options=options)
return webdriver.Chrome(options=options, service=service)
@@ -0,0 +1,152 @@
"""@file
@brief Entry point that plays the five lecture steps and saves a screenshot after each.
Run it as `python -m cookie_clicker_bot.presentation.capture_steps --out DIR`.
It is a documentation tool, so it uses the Selenium adapter directly and always
runs headless.
"""
import argparse
import json
import logging
from collections.abc import Callable, Sequence
from pathlib import Path
from selenium.webdriver.remote.webdriver import WebDriver
from cookie_clicker_bot.application.use_cases.bot_core import (
click_cookie,
get_cookie_count,
open_game,
select_language,
)
from cookie_clicker_bot.application.use_cases.run_bot import run_bot
from cookie_clicker_bot.application.use_cases.store import (
buy_upgrade,
get_store_items,
)
from cookie_clicker_bot.constants import (
CAPTURE_CLICK_COUNT,
CAPTURE_MAX_BURSTS,
CLICKS_PER_BURST,
RESULT_LABEL,
RUN_DURATION_SECONDS,
SCREENSHOT_BUY_UPGRADE,
SCREENSHOT_CLICK_COOKIE,
SCREENSHOT_OPEN_GAME,
SCREENSHOT_READ_COUNT,
SCREENSHOT_RESULT,
STEPS_RECORD_FILE,
)
from cookie_clicker_bot.domain.store_item import StoreItem, choose_upgrade
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]
_logger = logging.getLogger(__name__)
class StepUnreachableError(RuntimeError):
"""@brief Raised when a lecture step cannot be reached or does not succeed."""
def _parse_arguments(argv: Sequence[str] | None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
prog="capture_steps",
description="Play the five lecture steps and save a screenshot after each.",
)
parser.add_argument(
"--out", type=Path, required=True, help="folder for the screenshots"
)
parser.add_argument(
"--duration",
type=float,
default=RUN_DURATION_SECONDS,
help="seconds of the final run (default: %(default)s)",
)
return parser.parse_args(argv)
def _click_until_affordable(game: SeleniumGame) -> StoreItem:
"""@brief Click in bursts until a product can be bought, within a fixed bound."""
for _ in range(CAPTURE_MAX_BURSTS):
item = choose_upgrade(get_store_items(game))
if item is not None:
return item
click_cookie(game, CLICKS_PER_BURST)
raise StepUnreachableError(
f"no product became affordable within {CAPTURE_MAX_BURSTS} bursts of clicks"
)
def _buy_first_product(game: SeleniumGame) -> StoreItem:
item = _click_until_affordable(game)
if not buy_upgrade(game, item):
raise StepUnreachableError(
f"buying product {item.index} did not raise the owned amount"
)
return item
def _write_record(
path: Path, count: int, item: StoreItem, cps: str, duration: float
) -> None:
record = {
"cookie_count": count,
"bought": {"index": item.index, "price": item.price},
"cps": cps,
"duration_seconds": duration,
}
path.write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8", newline="\n")
def _capture(game: SeleniumGame, out: Path, duration: float) -> str:
"""@brief Play the five steps, saving a screenshot after each; return the cps."""
open_game(game)
game.save_screenshot(out / SCREENSHOT_OPEN_GAME)
select_language(game)
click_cookie(game, CAPTURE_CLICK_COUNT)
game.save_screenshot(out / SCREENSHOT_CLICK_COOKIE)
count = get_cookie_count(game)
_logger.info("Read %d cookies", count)
game.save_screenshot(out / SCREENSHOT_READ_COUNT)
item = _buy_first_product(game)
game.save_screenshot(out / SCREENSHOT_BUY_UPGRADE)
cps = str(run_bot(game, SystemClock(), duration))
game.save_screenshot(out / SCREENSHOT_RESULT)
_write_record(out / STEPS_RECORD_FILE, count, item, cps, duration)
return cps
def main(
argv: Sequence[str] | None = None,
driver_factory: DriverFactory = create_chrome_driver,
) -> int:
"""@brief Capture one screenshot per lecture step and print the result.
@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 StepUnreachableError When a step cannot be reached.
"""
arguments = _parse_arguments(argv)
game = SeleniumGame(driver_factory(True))
try:
cps = _capture(game, arguments.out, arguments.duration)
print(f"{RESULT_LABEL} : {cps}")
finally:
game.close()
return 0
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
raise SystemExit(main())
+108 -2
View File
@@ -4,14 +4,24 @@
No browser and no network: the fake driver records what the adapter asks for.
"""
from pathlib import Path
from typing import cast
import pytest
from selenium.common.exceptions import NoSuchElementException
from selenium.common.exceptions import (
ElementClickInterceptedException,
NoSuchElementException,
StaleElementReferenceException,
)
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from cookie_clicker_bot.infrastructure.selenium_game import SeleniumGame
from cookie_clicker_bot.constants import CHROME_BIN_ENV, CHROMEDRIVER_ENV, CLICK_RETRIES
from cookie_clicker_bot.infrastructure.selenium_game import (
SeleniumGame,
build_chrome_options,
build_chrome_service,
)
class FakeElement:
@@ -23,6 +33,7 @@ class FakeElement:
self.text = text
self.attributes = attributes or {}
self.clicks = 0
self.failures: list[Exception] = []
def get_attribute(self, name: str) -> str | None:
return self.attributes.get(name)
@@ -34,6 +45,8 @@ class FakeElement:
return True
def click(self) -> None:
if self.failures:
raise self.failures.pop(0)
self.clicks += 1
@@ -45,16 +58,24 @@ class FakeDriver:
self.visited: list[str] = []
self.lookups: list[tuple[str, str]] = []
self.quit_calls = 0
self.screenshots: list[str] = []
self.lookup_failures: list[Exception] = []
def get(self, url: str) -> None:
self.visited.append(url)
def find_element(self, by: str, value: str) -> FakeElement:
self.lookups.append((by, value))
if self.lookup_failures:
raise self.lookup_failures.pop(0)
if value not in self.elements:
raise NoSuchElementException(value)
return self.elements[value]
def save_screenshot(self, filename: str) -> bool:
self.screenshots.append(filename)
return True
def quit(self) -> None:
self.quit_calls += 1
@@ -118,6 +139,91 @@ def test_read_attribute_returns_empty_string_when_absent() -> None:
assert make_game(driver).read_attribute("product0", "class") == ""
def test_click_element_retries_after_a_stale_element() -> None:
element = FakeElement()
element.failures = [StaleElementReferenceException()] * CLICK_RETRIES
driver = FakeDriver({"bigCookie": element})
make_game(driver).click_element("bigCookie")
assert element.clicks == 1
assert len(driver.lookups) == CLICK_RETRIES + 1
def test_click_element_retries_after_an_intercepted_click() -> None:
element = FakeElement()
element.failures = [ElementClickInterceptedException()]
make_game(FakeDriver({"bigCookie": element})).click_element("bigCookie")
assert element.clicks == 1
def test_click_element_raises_after_the_retry_limit() -> None:
element = FakeElement()
element.failures = [StaleElementReferenceException()] * (CLICK_RETRIES + 1)
driver = FakeDriver({"bigCookie": element})
with pytest.raises(StaleElementReferenceException):
make_game(driver).click_element("bigCookie")
assert element.clicks == 0
assert len(driver.lookups) == CLICK_RETRIES + 1
def test_read_text_retries_after_a_stale_element() -> None:
driver = FakeDriver({"cookies": FakeElement("5")})
driver.lookup_failures = [StaleElementReferenceException()] * CLICK_RETRIES
assert make_game(driver).read_text("cookies") == "5"
def test_read_attribute_raises_after_the_retry_limit() -> None:
driver = FakeDriver({"product0": FakeElement()})
driver.lookup_failures = [StaleElementReferenceException()] * (CLICK_RETRIES + 1)
with pytest.raises(StaleElementReferenceException):
make_game(driver).read_attribute("product0", "class")
def test_save_screenshot_creates_the_folder_and_writes_the_file(
tmp_path: Path,
) -> None:
driver = FakeDriver({})
target = tmp_path / "nested" / "shot.png"
make_game(driver).save_screenshot(target)
assert driver.screenshots == [str(target)]
assert target.parent.is_dir()
def test_chrome_options_use_the_binary_from_the_environment() -> None:
options = build_chrome_options(True, {CHROME_BIN_ENV: "/usr/bin/chromium"})
assert options.binary_location == "/usr/bin/chromium"
assert "--headless=new" in options.arguments
assert "--no-sandbox" in options.arguments
def test_chrome_options_leave_the_binary_alone_without_the_variable() -> None:
options = build_chrome_options(False, {})
assert options.binary_location == ""
assert options.arguments == []
def test_chrome_service_uses_the_driver_from_the_environment() -> None:
service = build_chrome_service({CHROMEDRIVER_ENV: "/usr/bin/chromedriver"})
assert service is not None
assert service.path == "/usr/bin/chromedriver"
def test_chrome_service_is_absent_without_the_variable() -> None:
assert build_chrome_service({}) is None
def test_close_quits_the_driver() -> None:
driver = FakeDriver({})
+8
View File
@@ -13,10 +13,14 @@ class FakeGame:
texts: dict[str, str] | None = None,
attributes: dict[tuple[str, str], str] | None = None,
on_click: Callable[[str], None] | None = None,
text_sequences: dict[str, list[str]] | None = None,
) -> None:
self.texts: dict[str, str] = texts or {}
self.attributes: dict[tuple[str, str], str] = attributes or {}
self.on_click = on_click
## Texts returned one per read, before the plain text applies.
self.text_sequences: dict[str, list[str]] = text_sequences or {}
self.reads: dict[str, int] = {}
self.opened: list[str] = []
self.waited: list[str] = []
self.clicked: list[str] = []
@@ -34,6 +38,10 @@ class FakeGame:
self.on_click(element_id)
def read_text(self, element_id: str) -> str:
self.reads[element_id] = self.reads.get(element_id, 0) + 1
queued = self.text_sequences.get(element_id)
if queued:
return queued.pop(0)
return self.texts[element_id]
def read_attribute(self, element_id: str, attribute: str) -> str:
+184
View File
@@ -0,0 +1,184 @@
"""@file
@brief Tests of the screenshot capture entry point; no browser is started.
"""
import json
from decimal import Decimal
from pathlib import Path
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 (
CAPTURE_CLICK_COUNT,
CAPTURE_MAX_BURSTS,
CLICKS_PER_BURST,
RUN_DURATION_SECONDS,
)
from cookie_clicker_bot.domain.store_item import StoreItem
from cookie_clicker_bot.presentation import capture_steps
STEP_FILES = [
"step-1-open-game.png",
"step-2-click-cookie.png",
"step-3-read-count.png",
"step-4-buy-upgrade.png",
"step-5-result.png",
]
class FakeDriver:
def __init__(self, events: list[str]) -> None:
self.events = events
self.quit_calls = 0
def save_screenshot(self, filename: str) -> bool:
Path(filename).write_bytes(b"png")
self.events.append(f"shot:{Path(filename).name}")
return True
def quit(self) -> None:
self.quit_calls += 1
class Script:
"""Stands in for the use cases and the driver factory and records the order."""
def __init__(self) -> None:
self.events: list[str] = []
self.driver = FakeDriver(self.events)
self.headless: list[bool] = []
self.durations: list[float] = []
self.clicks: list[int] = []
self.affordable_after_bursts = 2
self.store_reads = 0
self.purchase_works = True
def driver_factory(self, headless: bool) -> WebDriver:
self.headless.append(headless)
return cast(WebDriver, self.driver)
def open_game(self, game: GamePort) -> None:
self.events.append("open")
def select_language(self, game: GamePort) -> None:
self.events.append("language")
def click_cookie(self, game: GamePort, times: int = 1) -> None:
self.clicks.append(times)
self.events.append(f"click:{times}")
def get_cookie_count(self, game: GamePort) -> int:
self.events.append("count")
return 42
def get_store_items(self, game: GamePort) -> list[StoreItem]:
self.store_reads += 1
affordable = len(self.clicks) - 1 >= self.affordable_after_bursts
return [StoreItem(3, 100, affordable)]
def buy_upgrade(self, game: GamePort, item: StoreItem) -> bool:
self.events.append(f"buy:{item.index}")
return self.purchase_works
def run_bot(self, game: GamePort, clock: Clock, duration: float) -> Decimal:
self.events.append("run")
self.durations.append(duration)
return Decimal("12.5")
@pytest.fixture
def script(monkeypatch: pytest.MonkeyPatch) -> Script:
s = Script()
for name in (
"open_game",
"select_language",
"click_cookie",
"get_cookie_count",
"get_store_items",
"buy_upgrade",
"run_bot",
):
monkeypatch.setattr(capture_steps, name, getattr(s, name))
return s
def test_main_saves_one_screenshot_per_step_in_order(
script: Script, tmp_path: Path
) -> None:
assert capture_steps.main(["--out", str(tmp_path)], script.driver_factory) == 0
shots = [e for e in script.events if e.startswith("shot:")]
assert shots == ["shot:" + name for name in STEP_FILES]
assert all((tmp_path / name).read_bytes() == b"png" for name in STEP_FILES)
assert script.events[:5] == [
"open",
"shot:step-1-open-game.png",
"language",
f"click:{CAPTURE_CLICK_COUNT}",
"shot:step-2-click-cookie.png",
]
def test_main_runs_headless_closes_and_prints_the_result(
script: Script, tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
capture_steps.main(["--out", str(tmp_path)], script.driver_factory)
assert script.headless == [True]
assert script.driver.quit_calls == 1
assert capsys.readouterr().out == "Result : 12.5\n"
assert script.durations == [RUN_DURATION_SECONDS]
def test_main_writes_the_steps_record(script: Script, tmp_path: Path) -> None:
capture_steps.main(
["--out", str(tmp_path), "--duration", "30"], script.driver_factory
)
record = json.loads((tmp_path / "steps.json").read_text(encoding="utf-8"))
assert record == {
"cookie_count": 42,
"bought": {"index": 3, "price": 100},
"cps": "12.5",
"duration_seconds": 30.0,
}
def test_main_creates_the_output_folder(script: Script, tmp_path: Path) -> None:
out = tmp_path / "nested" / "shots"
capture_steps.main(["--out", str(out)], script.driver_factory)
assert (out / STEP_FILES[0]).exists()
def test_main_clicks_in_bursts_until_a_product_is_affordable(
script: Script, tmp_path: Path
) -> None:
capture_steps.main(["--out", str(tmp_path)], script.driver_factory)
assert script.clicks == [CAPTURE_CLICK_COUNT] + [CLICKS_PER_BURST] * 2
def test_main_raises_when_nothing_becomes_affordable(
script: Script, tmp_path: Path
) -> None:
script.affordable_after_bursts = CAPTURE_MAX_BURSTS + 10
with pytest.raises(capture_steps.StepUnreachableError, match="affordable"):
capture_steps.main(["--out", str(tmp_path)], script.driver_factory)
assert script.store_reads == CAPTURE_MAX_BURSTS
assert script.driver.quit_calls == 1
assert not (tmp_path / "step-4-buy-upgrade.png").exists()
def test_main_raises_when_the_purchase_does_not_go_through(
script: Script, tmp_path: Path
) -> None:
script.purchase_works = False
with pytest.raises(capture_steps.StepUnreachableError, match="owned amount"):
capture_steps.main(["--out", str(tmp_path)], script.driver_factory)
assert script.driver.quit_calls == 1
assert not (tmp_path / "steps.json").exists()
def test_main_requires_the_out_argument(script: Script) -> None:
with pytest.raises(SystemExit):
capture_steps.main([], script.driver_factory)
+3
View File
@@ -14,6 +14,7 @@ from cookie_clicker_bot.constants import (
COOKIE_COUNTER_ID,
CPS_ID,
PRODUCT_ID_PREFIX,
PRODUCT_OWNED_ID_PREFIX,
PRODUCT_PRICE_ID_PREFIX,
RUN_DURATION_SECONDS,
STORE_CHECK_INTERVAL_SECONDS,
@@ -31,6 +32,7 @@ def make_game(cookies: int = 100, price: int = 15) -> FakeGame:
COOKIE_COUNTER_ID: f"{cookies} cookies",
f"{PRODUCT_PRICE_ID_PREFIX}0": str(price),
CPS_ID: "per second: 4.5",
f"{PRODUCT_OWNED_ID_PREFIX}0": "",
},
attributes={(PRODUCT, CLASS_ATTRIBUTE): "product unlocked enabled"},
)
@@ -38,6 +40,7 @@ def make_game(cookies: int = 100, price: int = 15) -> FakeGame:
def spend(element_id: str) -> None:
if element_id == PRODUCT:
game.texts[COOKIE_COUNTER_ID] = f"{cookies - price} cookies"
game.texts[f"{PRODUCT_OWNED_ID_PREFIX}0"] = "1"
game.attributes[(PRODUCT, CLASS_ATTRIBUTE)] = "product unlocked disabled"
game.on_click = spend
+63 -7
View File
@@ -15,7 +15,9 @@ from cookie_clicker_bot.constants import (
CLASS_ATTRIBUTE,
COOKIE_COUNTER_ID,
CPS_ID,
OWNED_CHECK_ATTEMPTS,
PRODUCT_ID_PREFIX,
PRODUCT_OWNED_ID_PREFIX,
PRODUCT_PRICE_ID_PREFIX,
)
from cookie_clicker_bot.domain.errors import UnreadableNumberError
@@ -65,23 +67,77 @@ def test_get_store_items_propagates_an_unreadable_price() -> None:
get_store_items(game)
def test_buy_upgrade_returns_true_when_the_cookies_drop() -> None:
game = FakeGame(texts={COOKIE_COUNTER_ID: "100 cookies"})
def test_buy_upgrade_returns_true_when_the_owned_amount_grows() -> None:
game = FakeGame(texts={f"{PRODUCT_OWNED_ID_PREFIX}3": "2"})
def spend(element_id: str) -> None:
game.texts[COOKIE_COUNTER_ID] = "85 cookies"
def buy(element_id: str) -> None:
game.texts[f"{PRODUCT_OWNED_ID_PREFIX}3"] = "3"
game.on_click = spend
game.on_click = buy
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"})
def test_buy_upgrade_treats_an_empty_owned_text_as_zero() -> None:
game = FakeGame(texts={f"{PRODUCT_OWNED_ID_PREFIX}0": ""})
def buy(element_id: str) -> None:
game.texts[f"{PRODUCT_OWNED_ID_PREFIX}0"] = "1"
game.on_click = buy
assert buy_upgrade(game, StoreItem(0, 15, True)) is True
def test_buy_upgrade_returns_false_when_the_owned_amount_is_unchanged() -> None:
game = FakeGame(texts={f"{PRODUCT_OWNED_ID_PREFIX}3": "2"})
assert buy_upgrade(game, StoreItem(3, 15, True)) is False
assert game.clicked == [f"{PRODUCT_ID_PREFIX}3"]
def _owned_game(after: list[str]) -> FakeGame:
"""Owned text "2" before the click, then the given texts, one per read."""
owned = f"{PRODUCT_OWNED_ID_PREFIX}3"
return FakeGame(texts={owned: "2"}, text_sequences={owned: ["2", *after]})
def test_buy_upgrade_waits_for_a_late_redraw() -> None:
game = _owned_game(["2", "3"])
assert buy_upgrade(game, StoreItem(3, 15, True)) is True
assert game.reads[f"{PRODUCT_OWNED_ID_PREFIX}3"] == 3
assert game.clicked == [f"{PRODUCT_ID_PREFIX}3"]
def test_buy_upgrade_gives_up_after_the_attempt_limit() -> None:
game = _owned_game([])
assert buy_upgrade(game, StoreItem(3, 15, True)) is False
assert game.reads[f"{PRODUCT_OWNED_ID_PREFIX}3"] == 1 + OWNED_CHECK_ATTEMPTS
assert game.clicked == [f"{PRODUCT_ID_PREFIX}3"]
def test_buy_upgrade_accepts_a_change_on_the_last_attempt() -> None:
game = _owned_game(["2"] * (OWNED_CHECK_ATTEMPTS - 1) + ["3"])
assert buy_upgrade(game, StoreItem(3, 15, True)) is True
def test_buy_upgrade_rejects_a_change_one_attempt_too_late() -> None:
game = _owned_game(["2"] * OWNED_CHECK_ATTEMPTS + ["3"])
assert buy_upgrade(game, StoreItem(3, 15, True)) is False
def test_buy_upgrade_ignores_the_cookie_count() -> None:
game = FakeGame(
texts={f"{PRODUCT_OWNED_ID_PREFIX}3": "", COOKIE_COUNTER_ID: "100 cookies"}
)
game.on_click = lambda _id: game.texts.update({COOKIE_COUNTER_ID: "90 cookies"})
assert buy_upgrade(game, StoreItem(3, 15, True)) is False
def test_buy_upgrade_propagates_an_unreadable_owned_amount() -> None:
game = FakeGame(texts={f"{PRODUCT_OWNED_ID_PREFIX}3": "???"})
with pytest.raises(UnreadableNumberError):
buy_upgrade(game, StoreItem(3, 15, True))
@pytest.mark.parametrize(
("text", "expected"),
[