Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d933074f2 | ||
|
|
97215c4137 | ||
|
|
e28096100b | ||
|
|
860421493d | ||
|
|
4de05013ff | ||
|
|
f6007e9b91 | ||
|
|
fa8d132fec | ||
|
|
c4a51c37de | ||
|
|
0c33168a76 |
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.venv
|
||||
.env
|
||||
__pycache__
|
||||
*.pyc
|
||||
.pytest_cache
|
||||
docs/doxygen
|
||||
framework
|
||||
.claude
|
||||
.agents
|
||||
+37
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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."""
|
||||
@@ -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
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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://")
|
||||
@@ -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=())
|
||||
@@ -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")
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user