Compare commits

..
Author SHA1 Message Date
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
Tirsvad 860421493d Resolve pending commit link for RC-015 2026-10-28 17:22:21 +08:00
Tirsvad 4de05013ff Add click bot: open the game, dismiss the language, click the cookie
Game port, game loop, Selenium adapter and Chromium factory with fake-driver
tests, command-line options for duration, headless mode and screenshots, and
the screenshots of lecture steps 1 and 2 taken in the Docker container.
Code review RC-015.

Closes #14
Closes #15
Closes #16
Closes #17

Task: MIL-003#1
Task: MIL-003#2
Task: MIL-003#3
Task: MIL-003#4
2026-10-28 17:22:16 +08:00
Tirsvad f6007e9b91 Merge pull request 'MIL-002: project scaffold and tooling' (#37) from mil-002-scaffold into main
TirSystem/git-workflows: Sync GitHub mirror metadata / sync-metadata (push) Successful in 6s
2026-10-28 10:15:43 +01:00
Tirsvad fa8d132fec Resolve pending commit link for RC-014 2026-10-28 17:15:35 +08:00
Tirsvad c4a51c37de Add project scaffold, tooling, README and container image
pyproject.toml (Python 3.13, Selenium only at runtime), src and tests layout,
constants.py, Python .gitignore, Doxyfile, README from the framework template
with .venv instructions, Dockerfile for headless Chromium, code review RC-014.

Closes #8
Closes #9
Closes #10
Closes #11
Closes #12
Closes #13

Task: MIL-002#1
Task: MIL-002#2
Task: MIL-002#3
Task: MIL-002#4
Task: MIL-002#5
Task: MIL-002#6
2026-10-28 17:15:31 +08:00
Tirsvad 0c33168a76 Merge pull request 'MIL-001: inception baseline' (#36) from g1-inception-baseline into main
TirSystem/git-workflows: Sync GitHub mirror metadata / sync-metadata (push) Successful in 10s
2026-10-28 10:09:49 +01:00
33 changed files with 1744 additions and 2 deletions
+10
View File
@@ -0,0 +1,10 @@
.git
.venv
.env
__pycache__
*.pyc
.pytest_cache
docs/doxygen
framework
.claude
.agents
+37
View File
@@ -3,3 +3,40 @@
# AI prompt log (the repository is public)
/docs/ai-prompts/
# Python
__pycache__/
*.py[cod]
*.egg-info/
build/
dist/
.eggs/
# Virtual environments
.venv/
venv/
env/
# Tests and coverage
.pytest_cache/
.coverage
.coverage.*
htmlcov/
coverage.xml
# Type checker and linter caches
.mypy_cache/
.ruff_cache/
# Doxygen output
/docs/doxygen/
# Browser run artefacts (the lecture screenshots in docs/images are kept)
/output/
*.log
# Editors and operating system
.idea/
.vscode/
.DS_Store
Thumbs.db
+20
View File
@@ -0,0 +1,20 @@
# Headless Chromium for real-browser runs and screenshots (ADR-0003).
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
CHROME_BIN=/usr/bin/chromium \
CHROMEDRIVER_PATH=/usr/bin/chromedriver
RUN apt-get update \
&& apt-get install -y --no-install-recommends chromium chromium-driver fonts-liberation \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY pyproject.toml README.md LICENSE ./
COPY src ./src
RUN python -m pip install --upgrade pip && python -m pip install -e ".[dev]"
COPY tests ./tests
ENTRYPOINT ["python", "-m", "cookie_clicker_bot"]
CMD ["--headless"]
+16
View File
@@ -0,0 +1,16 @@
# Doxygen configuration for the Cookie Clicker bot. Run: doxygen Doxyfile
PROJECT_NAME = "Cookie Clicker Bot"
PROJECT_BRIEF = "Selenium bot that plays Cookie Clicker (Udemy 100 Days of Code, day 48)"
OUTPUT_DIRECTORY = docs/doxygen
INPUT = src
FILE_PATTERNS = *.py
RECURSIVE = YES
EXTRACT_ALL = YES
OPTIMIZE_OUTPUT_JAVA = YES
PYTHON_DOCSTRING = YES
AUTOLINK_SUPPORT = YES
GENERATE_HTML = YES
GENERATE_LATEX = NO
QUIET = YES
WARN_IF_UNDOCUMENTED = YES
WARN_AS_ERROR = NO
+149 -1
View File
@@ -1,2 +1,150 @@
# 048-the-coockie-factory-new
# 🍪 Cookie Clicker Bot
A Selenium bot that plays [Cookie Clicker][game] for you: it clicks the big cookie, buys the most expensive upgrade it can afford every 5 seconds and prints the cookies per second after 5 minutes. It is the solution to day 48 of Udemy's *100 Days of Code: The Complete Python Pro Bootcamp*, written for course participants who want to compare solutions and for visitors who browse for ideas.
## 📚 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:
1. Get to know the game.
2. Click the cookie as fast as possible, after dismissing the language selection.
3. Every 5 seconds buy the most expensive upgrade you can afford.
4. After 5 minutes stop and print `cookies/second`.
5. Tweak the strategy (check interval, buy one or buy all affordable).
The repository is organised like this:
| Folder or file | What it is for |
| --- | --- |
| `src/cookie_clicker_bot/` | The bot. `domain/` holds the rules (parsing, choosing the upgrade) with no Selenium, `application/` runs the game loop, `infrastructure/` drives the browser, `constants.py` holds every constant |
| `tests/` | `pytest` tests; the unit tests need no browser |
| `docs/` | Project documents, wiki pages (English, Danish, Bahasa Malaysia) and the screenshots of the lecture steps |
| `Dockerfile` | A Linux image with headless Chromium for real-browser runs and screenshots |
| `Doxyfile` | Doxygen configuration for the source comments |
## 📋 Requirements
- Python 3.13 or newer.
- Selenium (the only runtime dependency; it is installed by `pip` and finds the browser driver itself).
- Google Chrome or Chromium to run the bot on your own machine.
- `pytest` and `pytest-cov` for the tests (development extra only).
- Docker, only if you want to run the bot or take screenshots without opening a window on your desktop.
- Doxygen, only to build the source documentation.
## 🛠️ Setup
Create a local virtual environment, upgrade `pip` and install the project with the test tools:
```bash
python -m venv .venv
```
Activate it:
```bash
# Linux and macOS
source .venv/bin/activate
```
```powershell
# Windows PowerShell
.venv\Scripts\Activate.ps1
```
```bash
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```
Leave the environment with `deactivate`. The folder `.venv` is ignored by git.
## ▶️ Run
With the environment active:
```bash
cookie-clicker-bot
```
or, without installing the command:
```bash
python -m cookie_clicker_bot
```
To keep your desktop free, run it in the Docker container (headless Chromium):
```bash
docker build -t cookie-clicker-bot .
docker run --rm cookie-clicker-bot
```
## 🧪 Tests
```bash
python -m pytest
```
Coverage of the logic modules is checked with `python -m pytest --cov`. Tests that need a real browser are marked `browser` and run in the Docker container; all other tests need neither a browser nor the network.
Build the source documentation with Doxygen:
```bash
doxygen Doxyfile
```
## 📄 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 |
| [Artifact registry][registry] | Where each artifact type lives, the next version, the PO language and domain |
| [Domain dictionary][DICT-001] | The PO terms and the IT terms |
| [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 |
---
[game]: https://ozh.github.io/cookieclicker/
[license]: ./LICENSE
[repository]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new
[documentation]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/wiki
[issues]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/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/
[registry]: ./docs/artifact-registry.md
[DICT-001]: ./docs/dictionary.md
[TM-001]: ./docs/sqa/traceability-matrix.md
[framework-readme]: ./framework/README.md
[framework-changelog]: ./framework/CHANGELOG.md
+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 | 014 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 017 |
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
| DICT | Domain Dictionary (PO and IT terms) | docs/dictionary.md | 002 |
Binary file not shown.

After

Width:  |  Height:  |  Size: 504 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+62
View File
@@ -0,0 +1,62 @@
# SQA Review Record
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-014 |
| CrossReference | [MIL-002], [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 | [c4a51c3] |
---
## Artifact Under Review
- Instance reviewed: [MIL-002]
- Checklist used: [QC-PY-001] (`QC-PY-001`)
- Scope: full review of the source and tests added by MIL-002: package skeleton, constants.py, cli.py, smoke test
- 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 | N-A | No file or connection is opened in this milestone. |
| 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 | `cli.py` prints a placeholder line only; the result line becomes the program output in MIL-005. |
| 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 | Tests are named for behaviour, independent of order, and need no network or browser. |
| 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-002 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-002]: ../../milestones/mil-002-scaffold.md
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[c4a51c3]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/commit/c4a51c37de39f3997e092caceb710762f70d4314
+62
View File
@@ -0,0 +1,62 @@
# SQA Review Record
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-015 |
| CrossReference | [MIL-003], [QC-PY-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Code review record | [4de0501] |
---
## Artifact Under Review
- Instance reviewed: [MIL-003]
- Checklist used: [QC-PY-001] (`QC-PY-001`)
- Scope: full review of the source and tests added by MIL-003: constants, game port, game loop, Selenium adapter, browser factory, cli and 20 new tests (written with the python-developer agent); a 15 s container run clicked 344 times and produced step-1.png and step-2.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`; the screenshot folder is created with `Path.mkdir`. |
| 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 | 23 tests pass; fakes replace the browser; 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-003 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-003]: ../../milestones/mil-003-click-bot.md
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[4de0501]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/commit/4de05013ff6b204587ce694e40b757c9665cd7fd
+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
+3
View File
@@ -41,6 +41,9 @@ Tracks backward and forward links between artifact instances so that the Busines
| [MIL-005] | Milestone | en | it | [BC-001], [PP-001] | [MIL-006] | RC-011 |
| [MIL-006] | Milestone | en | it | [BC-001], [PP-001] | [MIL-007] | RC-012 |
| [MIL-007] | Milestone | en | it | [BC-001], [PP-001] | - | RC-013 |
| Source and tests of [MIL-002] | Python source code | - | - | [MIL-002], [ADR-0002] | - | RC-014 |
| Source and tests of [MIL-003] | Python source code | - | - | [MIL-003], [ADR-0002] | - | RC-015 |
| Source and tests of [MIL-004] | Python source code | - | - | [MIL-004], [ADR-0002] | - | RC-016 |
## Concern Coverage
+62
View File
@@ -0,0 +1,62 @@
[build-system]
requires = ["setuptools>=75"]
build-backend = "setuptools.build_meta"
[project]
name = "cookie-clicker-bot"
version = "0.1.0"
description = "Selenium bot that plays Cookie Clicker (Udemy 100 Days of Code, day 48)."
readme = "README.md"
requires-python = ">=3.13"
license = { file = "LICENSE" }
dependencies = [
"selenium>=4.25",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3",
"pytest-cov>=6.0",
"ruff>=0.8",
"mypy>=1.13",
]
[project.scripts]
cookie-clicker-bot = "cookie_clicker_bot.cli:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra --strict-markers"
pythonpath = ["src"]
markers = [
"browser: needs a real browser (run in the Docker container)",
]
[tool.coverage.run]
source = ["cookie_clicker_bot"]
omit = ["*/infrastructure/*", "*/cli.py", "*/__main__.py"]
[tool.coverage.report]
fail_under = 80
show_missing = true
[tool.ruff]
line-length = 88
target-version = "py313"
src = ["src", "tests"]
extend-exclude = ["framework", ".claude", ".agents"]
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP", "N", "D", "ANN", "S", "PT"]
ignore = ["D203", "D213", "D205", "D212", "D400", "D415", "D401"]
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["S101", "D"]
[tool.mypy]
strict = true
python_version = "3.13"
files = ["src", "tests"]
+9
View File
@@ -0,0 +1,9 @@
"""@file __init__.py
@brief Selenium bot that plays Cookie Clicker (Udemy 100 Days of Code, day 48).
@mainpage Cookie Clicker Bot
The bot opens the game, clicks the big cookie, buys the most expensive affordable
upgrade every few seconds and prints the cookies per second when the run ends.
The rules live in the domain package, the game loop in the application package
and the Selenium adapter in the infrastructure package.
"""
+8
View File
@@ -0,0 +1,8 @@
"""@file __main__.py
@brief Entry point for `python -m cookie_clicker_bot`.
"""
from cookie_clicker_bot.cli import main
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,3 @@
"""@file __init__.py
@brief Application layer: the game loop, which talks to the game through a port.
"""
@@ -0,0 +1,71 @@
"""@file game_loop.py
@brief The game loop of the bot, written against the game port.
"""
import time
from collections.abc import Callable
from dataclasses import dataclass
from cookie_clicker_bot.application.ports import GamePort
from cookie_clicker_bot.domain.upgrades import Upgrade, choose_most_expensive_affordable
@dataclass(frozen=True, slots=True)
class PlayResult:
"""@brief What a run did."""
## Number of clicks on the big cookie.
clicks: int
## Names of the upgrades bought, in the order they were bought.
purchases: tuple[str, ...]
def click_cookie(game: GamePort) -> None:
"""@brief Click the big cookie once (lecture step 2).
@param game The game to play.
"""
game.click_cookie()
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.
@return The upgrade that was bought, or None when nothing was affordable.
"""
chosen = choose_most_expensive_affordable(game.upgrades(), game.cookie_count())
if chosen is not None:
game.buy(chosen)
return chosen
def click_and_buy(
game: GamePort,
duration_seconds: float,
check_interval_seconds: float,
clock: Callable[[], float] = time.monotonic,
) -> PlayResult:
"""@brief Click the cookie nonstop and check the upgrades at every interval.
@param game The game to play.
@param duration_seconds How long to play.
@param check_interval_seconds Time between two upgrade checks; the first check
comes one interval after the start.
@param clock Monotonic clock in seconds; a test passes a fake one.
@return The number of clicks and the names of the upgrades bought, in order.
"""
start = clock()
deadline = start + duration_seconds
next_check = start + check_interval_seconds
clicks = 0
purchases: list[str] = []
while (now := clock()) < deadline:
click_cookie(game)
clicks += 1
if now >= next_check:
next_check = now + check_interval_seconds
bought = buy_most_expensive_upgrade(game)
if bought is not None:
purchases.append(bought.name)
return PlayResult(clicks=clicks, purchases=tuple(purchases))
@@ -0,0 +1,50 @@
"""@file ports.py
@brief The port through which the game loop talks to the game.
"""
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."""
def open_game(self) -> None:
"""@brief Load the game page."""
def dismiss_language(self) -> None:
"""@brief Pick English on the language selection and wait for the game."""
def click_cookie(self) -> None:
"""@brief Click the big cookie once."""
def cookie_count(self) -> int:
"""@brief Read how many cookies the player has.
@return The cookie count.
"""
...
def upgrades(self) -> list[Upgrade]:
"""@brief Read the upgrades the player can see in the right-hand pane.
@return The visible upgrades, affordable or not, in store order.
"""
...
def buy(self, upgrade: Upgrade) -> None:
"""@brief Buy an upgrade by clicking it.
@param upgrade The upgrade to buy.
"""
def save_screenshot(self, path: Path) -> None:
"""@brief Write a screenshot of the game to a PNG file.
@param path Target file; its folder is created when missing.
"""
def close(self) -> None:
"""@brief Close the browser."""
+102
View File
@@ -0,0 +1,102 @@
"""@file cli.py
@brief Command-line entry point (the composition root of the bot).
"""
import argparse
import logging
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_and_buy
from cookie_clicker_bot.application.ports import GamePort
from cookie_clicker_bot.infrastructure.browser import create_driver
from cookie_clicker_bot.infrastructure.selenium_game import SeleniumGame
logger = logging.getLogger(__name__)
def create_game(headless: bool) -> GamePort:
"""@brief Build the Selenium game.
@param headless True to run Chromium without a window.
@return The game, ready to open.
"""
return SeleniumGame(create_driver(headless))
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
"""@brief Read the command line.
@param argv Arguments without the program name; None reads `sys.argv`.
@return The parsed options.
"""
parser = argparse.ArgumentParser(
prog="cookie-clicker-bot",
description=(
"Plays Cookie Clicker: clicks the big cookie as fast as it can "
"and buys upgrades."
),
)
parser.add_argument(
"--duration",
type=float,
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",
help="run Chromium without a window (the Docker image does this)",
)
parser.add_argument(
"--screenshot-start",
type=Path,
help="write a screenshot of the game before the first click",
)
parser.add_argument(
"--screenshot",
type=Path,
help="write a screenshot of the game when the run ends",
)
return parser.parse_args(argv)
def main(
argv: Sequence[str] | None = None,
game_factory: Callable[[bool], GamePort] = create_game,
) -> int:
"""@brief Start the bot.
@param argv Arguments without the program name; None reads `sys.argv`.
@param game_factory Builds the game; a test passes a fake.
@return Process exit code, 0 when the run finished.
"""
logging.basicConfig(level=logging.INFO, format="%(message)s")
args = parse_args(argv)
game = game_factory(args.headless)
try:
game.open_game()
game.dismiss_language()
if args.screenshot_start is not None:
game.save_screenshot(args.screenshot_start)
result = click_and_buy(game, args.duration, args.interval)
for name in result.purchases:
logger.info("bought %s", name)
logger.info(
"clicked the cookie %d times and bought %d upgrades in %s seconds",
result.clicks,
len(result.purchases),
args.duration,
)
if args.screenshot is not None:
game.save_screenshot(args.screenshot)
finally:
game.close()
return 0
+83
View File
@@ -0,0 +1,83 @@
"""@file constants.py
@brief Every constant of the bot: the game address, selectors, timings and messages.
Selectors of the game page are added here by the milestone that first uses them.
"""
from typing import Final
## Address of the new version of Cookie Clicker on GitHub Pages (ozh/cookieclicker).
GAME_URL: Final[str] = "https://ozh.github.io/cookieclicker/"
## Total playing time of one run, in seconds (lecture step 4: five minutes).
RUN_DURATION_SECONDS: Final[int] = 300
## Seconds between two checks for an affordable upgrade (lecture step 3).
UPGRADE_CHECK_INTERVAL_SECONDS: Final[int] = 5
## Longest time to wait for an element of the page, in seconds.
WAIT_TIMEOUT_SECONDS: Final[int] = 20
## Folder where screenshots of a container run are written.
SCREENSHOT_DIR: Final[str] = "docs/images"
## Id of the button that picks English on the language selection page.
LANGUAGE_ENGLISH_ID: Final[str] = "langSelect-EN"
## Id of the big cookie in the left-hand pane.
BIG_COOKIE_ID: Final[str] = "bigCookie"
## Id of the element that shows the cookie count and the cookies per second.
COOKIE_COUNT_ID: Final[str] = "cookies"
## CSS selector of the products in the right-hand pane that the player can see.
VISIBLE_PRODUCT_SELECTOR: Final[str] = ".product.unlocked"
## CSS selector of the name inside a product.
PRODUCT_NAME_SELECTOR: Final[str] = ".productName"
## CSS selector of the price inside a product.
PRODUCT_PRICE_SELECTOR: Final[str] = ".price"
## Attribute of a product element that holds its id (product0 .. product19).
ID_ATTRIBUTE: Final[str] = "id"
## Text in `#cookies` after which the cookies per second follow; the count ends here.
PER_SECOND_MARKER: Final[str] = "per second"
## The words the game puts after the cookie count (the longer word first).
COOKIE_WORDS: Final[tuple[str, ...]] = ("cookies", "cookie")
## Multiplier of each magnitude word the game writes in numbers (1.4 million).
MAGNITUDE_MULTIPLIERS: Final[dict[str, int]] = {
"thousand": 10**3,
"million": 10**6,
"billion": 10**9,
"trillion": 10**12,
"quadrillion": 10**15,
"quintillion": 10**18,
"sextillion": 10**21,
"septillion": 10**24,
"octillion": 10**27,
"nonillion": 10**30,
"decillion": 10**33,
}
## Seconds between two polls while waiting for an element.
POLL_INTERVAL_SECONDS: Final[float] = 0.2
## Environment variable that names the Chromium binary (set in the Docker image).
CHROME_BIN_ENV: Final[str] = "CHROME_BIN"
## Environment variable that names the Chromium driver (set in the Docker image).
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"
## Chromium arguments needed to run headless inside a container.
HEADLESS_BROWSER_ARGUMENTS: Final[tuple[str, ...]] = (
"--headless=new",
"--no-sandbox",
"--disable-dev-shm-usage",
)
@@ -0,0 +1,3 @@
"""@file __init__.py
@brief Domain layer: the rules of the bot, free of Selenium.
"""
+53
View File
@@ -0,0 +1,53 @@
"""@file parsing.py
@brief Turn the texts of the game page into whole numbers of cookies.
"""
from decimal import Decimal, InvalidOperation
from cookie_clicker_bot import constants
def parse_amount(text: str) -> int:
"""@brief Read an amount such as `1,100`, `15` or `1.4 million`.
@param text The text of a price or a count, with optional thousands commas
and an optional magnitude word.
@return The amount as a whole number.
@throws ValueError When the text is not an amount.
"""
words = text.strip().lower().replace(",", "").split()
if not 1 <= len(words) <= 2:
raise ValueError(_message(text))
multiplier = 1
if len(words) == 2:
multiplier = constants.MAGNITUDE_MULTIPLIERS.get(words[1], 0)
if multiplier == 0:
raise ValueError(_message(text))
try:
number = Decimal(words[0])
except InvalidOperation:
raise ValueError(_message(text)) from None
if not number.is_finite() or number < 0:
raise ValueError(_message(text))
return int(number * multiplier)
def parse_cookie_count(text: str) -> int:
"""@brief Read the cookie count from the whole text of the `#cookies` element.
The text looks like `5,000 cookies` then a line `per second: 0`; a big count
puts the magnitude word on the second line: `1.235`, `million cookies`.
@param text The visible text of the element.
@return The number of cookies.
@throws ValueError When the text holds no count.
"""
count = text.lower().split(constants.PER_SECOND_MARKER)[0]
for word in constants.COOKIE_WORDS:
count = count.replace(word, "")
return parse_amount(" ".join(count.split()))
def _message(text: str) -> str:
"""@brief Build the error message for a text that is not an amount."""
return f"not an amount of cookies: {text!r}"
+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)
@@ -0,0 +1,3 @@
"""@file __init__.py
@brief Infrastructure layer: the Selenium adapter that drives the browser.
"""
@@ -0,0 +1,33 @@
"""@file browser.py
@brief Creates the Chromium driver, headless when asked.
"""
import os
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
from cookie_clicker_bot import constants
def create_driver(headless: bool) -> webdriver.Chrome:
"""@brief Start Chromium.
The binary and the driver come from `CHROME_BIN` and `CHROMEDRIVER_PATH` when
these are set (the Docker image sets them); otherwise Selenium finds them.
@param headless True to run without a window, as in the Docker container.
@return A running Chromium driver.
"""
options = Options()
options.add_argument(f"--window-size={constants.WINDOW_SIZE}")
if headless:
for argument in constants.HEADLESS_BROWSER_ARGUMENTS:
options.add_argument(argument)
binary = os.environ.get(constants.CHROME_BIN_ENV)
if binary:
options.binary_location = binary
driver_path = os.environ.get(constants.CHROMEDRIVER_PATH_ENV)
service = Service(executable_path=driver_path) if driver_path else Service()
return webdriver.Chrome(service=service, options=options)
@@ -0,0 +1,182 @@
"""@file selenium_game.py
@brief Selenium adapter that implements the game port.
"""
import time
from collections.abc import Callable, Sequence
from pathlib import Path
from typing import Protocol, TypeVar
from selenium.common.exceptions import (
ElementClickInterceptedException,
NoSuchElementException,
StaleElementReferenceException,
)
from selenium.webdriver.common.by import By
from cookie_clicker_bot import constants
from cookie_clicker_bot.domain.parsing import parse_amount, parse_cookie_count
from cookie_clicker_bot.domain.upgrades import Upgrade
T = TypeVar("T")
class ElementLike(Protocol):
"""@brief The part of a Selenium element that the adapter uses."""
@property
def text(self) -> str:
"""@brief Visible text of the element."""
...
def click(self) -> None:
"""@brief Click the element."""
def is_displayed(self) -> bool:
"""@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."""
def get(self, url: str) -> None:
"""@brief Load a page."""
def find_element(self, by: str, value: str) -> ElementLike:
"""@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 quit(self) -> None:
"""@brief Close the browser."""
class SeleniumGame:
"""@brief Plays the game page through a Selenium driver."""
def __init__(
self,
driver: DriverLike,
timeout_seconds: float = constants.WAIT_TIMEOUT_SECONDS,
clock: Callable[[], float] = time.monotonic,
sleep: Callable[[float], None] = time.sleep,
) -> None:
"""@brief Wrap a driver.
@param driver The Selenium driver (or a fake in tests).
@param timeout_seconds Longest wait for an element.
@param clock Monotonic clock in seconds.
@param sleep Pause function used between polls.
"""
self._driver = driver
self._timeout = timeout_seconds
self._clock = clock
self._sleep = sleep
def open_game(self) -> None:
"""@brief Load the game page."""
self._driver.get(constants.GAME_URL)
def dismiss_language(self) -> None:
"""@brief Pick English, then wait until the big cookie can be clicked."""
english = self._wait_for(lambda: self._visible(constants.LANGUAGE_ENGLISH_ID))
english.click()
self._wait_for(lambda: self._visible(constants.BIG_COOKIE_ID))
def click_cookie(self) -> None:
"""@brief Click the big cookie once."""
try:
self._driver.find_element(By.ID, constants.BIG_COOKIE_ID).click()
except (ElementClickInterceptedException, StaleElementReferenceException):
# A banner or a re-render covered the cookie for a moment; try again.
return
def cookie_count(self) -> int:
"""@brief Read how many cookies the player has.
@return The cookie count.
"""
element = self._driver.find_element(By.ID, constants.COOKIE_COUNT_ID)
return parse_cookie_count(element.text)
def upgrades(self) -> list[Upgrade]:
"""@brief Read the upgrades the player can see in the right-hand pane.
@return The visible upgrades, affordable or not, in store order.
"""
products = self._driver.find_elements(
By.CSS_SELECTOR, constants.VISIBLE_PRODUCT_SELECTOR
)
return [self._read_upgrade(product) for product in products]
def buy(self, upgrade: Upgrade) -> None:
"""@brief Buy an upgrade by clicking its product.
@param upgrade The upgrade to buy.
"""
try:
self._driver.find_element(By.ID, upgrade.key).click()
except (ElementClickInterceptedException, StaleElementReferenceException):
# The pane re-rendered or is covered for a moment; the next check retries.
return
def save_screenshot(self, path: Path) -> None:
"""@brief Write a screenshot of the game to a PNG file.
@param path Target file; its folder is created when missing.
"""
path.parent.mkdir(parents=True, exist_ok=True)
self._driver.save_screenshot(str(path))
def close(self) -> None:
"""@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 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.
@param locate Function that returns the value, or None while not ready.
@return The value.
@throws TimeoutError When the value is not there within the timeout.
"""
deadline = self._clock() + self._timeout
while True:
found = locate()
if found is not None:
return found
if self._clock() >= deadline:
message = f"the game page was not ready within {self._timeout} s"
raise TimeoutError(message)
self._sleep(constants.POLL_INTERVAL_SECONDS)
+254
View File
@@ -0,0 +1,254 @@
"""@file test_selenium_game.py
@brief Tests of the Selenium adapter against a fake driver (no browser).
"""
from collections.abc import Callable
from pathlib import Path
import pytest
from selenium.common.exceptions import (
ElementClickInterceptedException,
NoSuchElementException,
StaleElementReferenceException,
)
from cookie_clicker_bot import constants
from cookie_clicker_bot.domain.upgrades import Upgrade
from cookie_clicker_bot.infrastructure.selenium_game import SeleniumGame
class FakeElement:
"""An element that records clicks and may raise on click."""
def __init__(
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 = 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:
raise self._click_error
self.clicks += 1
def is_displayed(self) -> bool:
return self._displayed()
class FakeDriver:
"""A driver with elements by id."""
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
def get(self, url: str) -> None:
self.loaded.append(url)
def find_element(self, by: str, value: str) -> FakeElement:
if by != "id" or value not in self.elements:
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
def quit(self) -> None:
self.quit_called = True
class FakeTime:
"""A clock that only moves when the fake sleep is called."""
def __init__(self) -> None:
self.now = 0.0
self.sleeps: list[float] = []
def clock(self) -> float:
return self.now
def sleep(self, seconds: float) -> None:
self.sleeps.append(seconds)
self.now += seconds
def make_game(driver: FakeDriver, fake_time: FakeTime | None = None) -> SeleniumGame:
fake_time = fake_time or FakeTime()
return SeleniumGame(
driver, timeout_seconds=1.0, clock=fake_time.clock, sleep=fake_time.sleep
)
def ready_driver() -> FakeDriver:
return FakeDriver(
{
constants.LANGUAGE_ENGLISH_ID: FakeElement(),
constants.BIG_COOKIE_ID: FakeElement(),
}
)
def test_open_game_loads_the_game_address() -> None:
driver = FakeDriver()
make_game(driver).open_game()
assert driver.loaded == [constants.GAME_URL]
def test_dismiss_language_clicks_english_and_waits_for_the_cookie() -> None:
driver = ready_driver()
make_game(driver).dismiss_language()
assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1
def test_dismiss_language_polls_until_the_button_is_visible() -> None:
fake_time = FakeTime()
driver = ready_driver()
driver.elements[constants.LANGUAGE_ENGLISH_ID] = FakeElement(
displayed=lambda: fake_time.now >= 0.4
)
make_game(driver, fake_time).dismiss_language()
assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1
assert fake_time.sleeps == [constants.POLL_INTERVAL_SECONDS] * 2
def test_dismiss_language_times_out_when_the_button_never_shows() -> None:
fake_time = FakeTime()
with pytest.raises(TimeoutError):
make_game(FakeDriver(), fake_time).dismiss_language()
assert fake_time.now >= 1.0
def test_dismiss_language_times_out_when_the_cookie_never_shows() -> None:
driver = FakeDriver({constants.LANGUAGE_ENGLISH_ID: FakeElement()})
with pytest.raises(TimeoutError):
make_game(driver).dismiss_language()
def test_click_cookie_clicks_the_big_cookie() -> None:
driver = ready_driver()
make_game(driver).click_cookie()
assert driver.elements[constants.BIG_COOKIE_ID].clicks == 1
@pytest.mark.parametrize(
"error",
[ElementClickInterceptedException(), StaleElementReferenceException()],
)
def test_click_cookie_ignores_a_covered_or_stale_cookie(error: Exception) -> None:
driver = FakeDriver({constants.BIG_COOKIE_ID: FakeElement(click_error=error)})
make_game(driver).click_cookie()
def make_product(key: str, name: str, price: str) -> FakeElement:
return FakeElement(
attributes={"id": key},
children={
constants.PRODUCT_NAME_SELECTOR: FakeElement(text=name),
constants.PRODUCT_PRICE_SELECTOR: FakeElement(text=price),
},
)
def test_cookie_count_reads_the_cookie_counter() -> None:
driver = FakeDriver(
{
constants.COOKIE_COUNT_ID: FakeElement(
text="1.235\nmillion cookies\nper second: 0"
)
}
)
assert make_game(driver).cookie_count() == 1_235_000
def test_upgrades_reads_the_visible_products() -> None:
driver = FakeDriver()
driver.groups[constants.VISIBLE_PRODUCT_SELECTOR] = [
make_product("product0", "Cursor", "15"),
make_product("product2", "Farm", "1,100"),
]
assert make_game(driver).upgrades() == [
Upgrade("product0", "Cursor", 15),
Upgrade("product2", "Farm", 1100),
]
def test_upgrades_is_empty_when_no_product_is_visible() -> None:
assert make_game(FakeDriver()).upgrades() == []
def test_buy_clicks_the_product_with_the_upgrade_key() -> None:
driver = FakeDriver({"product1": FakeElement()})
make_game(driver).buy(Upgrade("product1", "Grandma", 100))
assert driver.elements["product1"].clicks == 1
@pytest.mark.parametrize(
"error",
[ElementClickInterceptedException(), StaleElementReferenceException()],
)
def test_buy_ignores_a_covered_or_stale_product(error: Exception) -> None:
driver = FakeDriver({"product1": FakeElement(click_error=error)})
make_game(driver).buy(Upgrade("product1", "Grandma", 100))
def test_save_screenshot_creates_the_folder_and_calls_the_driver(
tmp_path: Path,
) -> None:
driver = FakeDriver()
target = tmp_path / "images" / "shot.png"
make_game(driver).save_screenshot(target)
assert target.parent.is_dir()
assert driver.screenshots == [str(target)]
def test_close_quits_the_driver() -> None:
driver = FakeDriver()
make_game(driver).close()
assert driver.quit_called
+131
View File
@@ -0,0 +1,131 @@
"""@file test_cli.py
@brief Tests of the command line and the run sequence with a fake game.
"""
from pathlib import Path
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:
self.calls: list[str] = []
self.screenshots: list[Path] = []
self._fail_on_open = fail_on_open
def open_game(self) -> None:
self.calls.append("open_game")
if self._fail_on_open:
message = "boom"
raise RuntimeError(message)
def dismiss_language(self) -> None:
self.calls.append("dismiss_language")
def click_cookie(self) -> None:
self.calls.append("click_cookie")
def cookie_count(self) -> int:
return 0
def upgrades(self) -> list[Upgrade]:
return []
def buy(self, upgrade: Upgrade) -> None:
self.calls.append("buy")
def save_screenshot(self, path: Path) -> None:
self.calls.append("save_screenshot")
self.screenshots.append(path)
def close(self) -> None:
self.calls.append("close")
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
def test_parse_args_reads_every_option() -> None:
args = parse_args(
[
"--headless",
"--duration",
"10",
"--interval",
"2.5",
"--screenshot-start",
"a.png",
"--screenshot",
"b.png",
]
)
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")
def test_main_opens_dismisses_and_closes() -> None:
game = RecordingGame()
code = main(["--duration", "0"], lambda _headless: game)
assert code == 0
assert game.calls == ["open_game", "dismiss_language", "close"]
def test_main_passes_headless_to_the_factory() -> None:
seen: list[bool] = []
def factory(headless: bool) -> GamePort:
seen.append(headless)
return RecordingGame()
main(["--headless", "--duration", "0"], factory)
assert seen == [True]
def test_main_takes_both_screenshots_when_asked() -> None:
game = RecordingGame()
main(
["--duration", "0", "--screenshot-start", "s.png", "--screenshot", "e.png"],
lambda _headless: game,
)
assert game.screenshots == [Path("s.png"), Path("e.png")]
assert game.calls[-1] == "close"
def test_main_takes_no_screenshot_by_default() -> None:
game = RecordingGame()
main(["--duration", "0"], lambda _headless: game)
assert game.screenshots == []
def test_main_closes_the_game_when_an_error_is_raised() -> None:
game = RecordingGame(fail_on_open=True)
with pytest.raises(RuntimeError, match="boom"):
main(["--duration", "0"], lambda _headless: game)
assert game.calls == ["open_game", "close"]
+17
View File
@@ -0,0 +1,17 @@
"""@file test_constants.py
@brief Smoke test: the assignment's timings are the ones in constants.py.
"""
from cookie_clicker_bot import constants
def test_run_lasts_five_minutes() -> None:
assert constants.RUN_DURATION_SECONDS == 5 * 60
def test_upgrades_are_checked_every_five_seconds() -> None:
assert constants.UPGRADE_CHECK_INTERVAL_SECONDS == 5
def test_game_address_is_https() -> None:
assert constants.GAME_URL.startswith("https://")
+134
View File
@@ -0,0 +1,134 @@
"""@file test_game_loop.py
@brief Tests of the game loop with a fake game and a fake clock.
"""
from pathlib import Path
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 with a cookie count and upgrades; each click takes one second."""
def __init__(
self,
cookies: int = 0,
upgrades: list[Upgrade] | None = None,
clock: FakeClock | None = None,
) -> None:
self.clicks = 0
self.cookies = cookies
self.available = upgrades or []
self.bought: list[Upgrade] = []
self.buy_times: list[float] = []
self._clock = clock
def open_game(self) -> None:
pass
def dismiss_language(self) -> None:
pass
def click_cookie(self) -> None:
self.clicks += 1
if self._clock is not None:
self._clock.now += 1.0
def cookie_count(self) -> int:
return self.cookies
def upgrades(self) -> list[Upgrade]:
return self.available
def buy(self, upgrade: Upgrade) -> None:
self.bought.append(upgrade)
if self._clock is not None:
self.buy_times.append(self._clock.now)
def save_screenshot(self, path: Path) -> None:
pass
def close(self) -> None:
pass
def test_click_cookie_delegates_to_the_game() -> None:
game = FakeGame()
click_cookie(game)
assert game.clicks == 1
def test_buy_most_expensive_upgrade_buys_the_best_affordable() -> None:
game = FakeGame(cookies=105, upgrades=[CURSOR, GRANDMA])
bought = buy_most_expensive_upgrade(game)
assert bought == GRANDMA
assert game.bought == [GRANDMA]
def test_buy_most_expensive_upgrade_buys_nothing_when_too_poor() -> None:
game = FakeGame(cookies=10, upgrades=[CURSOR, GRANDMA])
assert buy_most_expensive_upgrade(game) is None
assert game.bought == []
def test_click_and_buy_checks_the_upgrades_every_interval() -> None:
clock = FakeClock()
game = FakeGame(cookies=105, upgrades=[CURSOR, GRANDMA], clock=clock)
result = click_and_buy(game, 12, 5, clock)
assert result == PlayResult(clicks=12, purchases=("Grandma", "Grandma"))
# The checks fall in the iterations that start at 5 s and 10 s; each click then
# moves the fake clock on by one second.
assert game.buy_times == [6.0, 11.0]
def test_click_and_buy_does_not_check_before_the_first_interval() -> None:
clock = FakeClock()
game = FakeGame(cookies=105, upgrades=[GRANDMA], clock=clock)
result = click_and_buy(game, 4, 5, clock)
assert result == PlayResult(clicks=4, purchases=())
assert game.bought == []
def test_click_and_buy_records_only_real_purchases() -> None:
clock = FakeClock()
game = FakeGame(cookies=10, upgrades=[CURSOR, GRANDMA], clock=clock)
result = click_and_buy(game, 11, 5, clock)
assert result.clicks == 11
assert result.purchases == ()
def test_click_and_buy_does_not_click_when_duration_is_zero() -> None:
clock = FakeClock()
game = FakeGame(clock=clock)
assert click_and_buy(game, 0, 5, clock) == PlayResult(clicks=0, purchases=())
+56
View File
@@ -0,0 +1,56 @@
"""@file test_parsing.py
@brief Tests of the parsing of prices and cookie counts.
"""
import pytest
from cookie_clicker_bot.domain.parsing import parse_amount, parse_cookie_count
@pytest.mark.parametrize(
("text", "expected"),
[
("15", 15),
("0", 0),
("1,100", 1100),
("130,000", 130000),
(" 100 ", 100),
("1.4 million", 1_400_000),
("20 million", 20_000_000),
("1.235 million", 1_235_000),
("3.5 Billion", 3_500_000_000),
("2 thousand", 2000),
("1 decillion", 10**33),
],
)
def test_parse_amount_reads_prices(text: str, expected: int) -> None:
assert parse_amount(text) == expected
@pytest.mark.parametrize(
"text", ["", " ", "abc", "1.4 gazillion", "1 2 3", "-5", "nan", "million"]
)
def test_parse_amount_rejects_garbage(text: str) -> None:
with pytest.raises(ValueError, match="not an amount of cookies"):
parse_amount(text)
@pytest.mark.parametrize(
("text", "expected"),
[
("120 cookies\nper second: 0", 120),
("5,000 cookies\nper second: 0", 5000),
("1 cookie\nper second: 0", 1),
("0 cookies\nper second: 0", 0),
("1.235\nmillion cookies\nper second: 0", 1_235_000),
("3.500\nbillion cookies\nper second: 12.5", 3_500_000_000),
("120 cookies", 120),
],
)
def test_parse_cookie_count_reads_the_count_element(text: str, expected: int) -> None:
assert parse_cookie_count(text) == expected
def test_parse_cookie_count_rejects_text_without_a_count() -> None:
with pytest.raises(ValueError, match="not an amount of cookies"):
parse_cookie_count("per second: 0")
+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