Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5e9e0b737 | ||
|
|
9a9ea53cc1 | ||
|
|
9592dd0ea0 | ||
|
|
6b68dd29d6 | ||
|
|
cb8e5edfbf | ||
|
|
1f0a84a4e9 | ||
|
|
aa129cb5d3 | ||
|
|
68c439137f | ||
|
|
853aacf999 |
@@ -0,0 +1,13 @@
|
||||
.git
|
||||
.venv
|
||||
.env
|
||||
.claude
|
||||
.agents
|
||||
framework
|
||||
docs
|
||||
runs
|
||||
tests
|
||||
**/__pycache__
|
||||
.pytest_cache
|
||||
.mypy_cache
|
||||
.ruff_cache
|
||||
@@ -1,2 +1,173 @@
|
||||
# 048-the-coockie-factory
|
||||
# 🚀 Cookie Clicker Bot
|
||||
|
||||
A Selenium bot that plays [Cookie Clicker](https://ozh.github.io/cookieclicker/) for five minutes: it clicks the big cookie, buys upgrades and reports its cookies per second (CPS). It is the day 48 project of Udemy's *100 Days of Code: The Complete Python Pro Bootcamp*, written to be read, run and compared by other course participants.
|
||||
|
||||
## 📚 Table of Contents
|
||||
|
||||
- [Overview](#-overview)
|
||||
- [Requirements](#-requirements)
|
||||
- [Setup](#-setup)
|
||||
- [Run](#-run)
|
||||
- [Tests](#-tests)
|
||||
- [License](#-license)
|
||||
- [Links](#-links)
|
||||
- [Appendix: Project documents](#-appendix-project-documents)
|
||||
|
||||
## 🧭 Overview
|
||||
|
||||
The bot follows the five steps of the lecture. Every step is one function with the assignment's name, and every step has a screenshot taken by the bot itself in a headless Chromium inside Docker.
|
||||
|
||||
| Step | Function | What it does |
|
||||
| --- | --- | --- |
|
||||
| 1 | `open_game`, `select_language` | Opens the game and picks English |
|
||||
| 2 | `click_cookie` | Clicks the big cookie |
|
||||
| 3 | `get_cookie_count` | Reads the number of cookies |
|
||||
| 4 | `get_store_items`, `choose_upgrade`, `buy_upgrade` | Buys the most expensive upgrade it can afford |
|
||||
| 5 | `run_bot`, `get_cps` | Plays for 300 seconds and reports the CPS |
|
||||
|
||||
### Lecture step screenshots
|
||||
|
||||
**Step 1: open the game and choose a language**
|
||||
|
||||

|
||||
|
||||
**Step 2: click the big cookie**
|
||||
|
||||

|
||||
|
||||
**Step 3: read the cookie count**
|
||||
|
||||

|
||||
|
||||
**Step 4: buy the first upgrade**
|
||||
|
||||

|
||||
|
||||
**Step 5: the five-minute result**
|
||||
|
||||

|
||||
|
||||
In the run that made these screenshots the bot reached **16.6 cookies per second** after 300 seconds. Your number will differ: it depends on the speed of your machine and the network.
|
||||
|
||||
### Parts of the repository
|
||||
|
||||
| Folder | Holds |
|
||||
| --- | --- |
|
||||
| `src/cookie_clicker_bot/domain/` | Game rules without Selenium: number parsing and the purchase choice |
|
||||
| `src/cookie_clicker_bot/application/` | The lecture step functions and the ports they need |
|
||||
| `src/cookie_clicker_bot/infrastructure/` | The Selenium adapter and the clock |
|
||||
| `src/cookie_clicker_bot/presentation/` | The command line entry points |
|
||||
| `src/cookie_clicker_bot/constants.py` | All constants: address, element ids, timings |
|
||||
| `tests/` | pytest tests that need no browser |
|
||||
| `docker/` | The Docker image used for headless runs and screenshots |
|
||||
| `docs/` | Project documents, screenshots and the wiki |
|
||||
|
||||
The only runtime dependency is `selenium`; a bot cannot drive a browser without it (see ADR-0001 in `docs/adr/`).
|
||||
|
||||
## 📋 Requirements
|
||||
|
||||
- Python 3.13 or newer.
|
||||
- Google Chrome (Selenium Manager downloads the matching driver). Not needed to run the tests.
|
||||
- Docker, only to repeat the headless run and the screenshots.
|
||||
- Doxygen, only to build the API documentation.
|
||||
|
||||
## 🛠️ Setup
|
||||
|
||||
Create a local virtual environment, upgrade `pip` and install the project with its development tools.
|
||||
|
||||
Windows (PowerShell):
|
||||
|
||||
```powershell
|
||||
python -m venv .venv
|
||||
.venv\Scripts\Activate.ps1
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
Linux and macOS:
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
Leave the environment with `deactivate`. The `.venv` folder is ignored by git.
|
||||
|
||||
## ▶️ Run
|
||||
|
||||
With the virtual environment active, start the bot. A Chrome window opens and plays for five minutes, then the bot prints the CPS and closes the browser:
|
||||
|
||||
```bash
|
||||
python -m cookie_clicker_bot
|
||||
```
|
||||
|
||||
Useful options:
|
||||
|
||||
```bash
|
||||
python -m cookie_clicker_bot --headless
|
||||
python -m cookie_clicker_bot --duration 60
|
||||
```
|
||||
|
||||
To repeat the headless run and the screenshots without touching your desktop, use Docker. The screenshots and a `steps.json` summary land in `runs/` (the ones in `docs/images/` came from such a run):
|
||||
|
||||
```bash
|
||||
docker build -t cookie-clicker-bot -f docker/Dockerfile .
|
||||
docker run --rm -v "$(pwd)/runs:/runs" cookie-clicker-bot --out /runs
|
||||
```
|
||||
|
||||
To build the API documentation from the Doxygen comments, run `doxygen Doxyfile`; the result is in `docs/api/html/`.
|
||||
|
||||
## 🧪 Tests
|
||||
|
||||
The tests use fakes for the game and the clock, so they need neither Chrome nor the network:
|
||||
|
||||
```bash
|
||||
python -m pytest
|
||||
python -m ruff check src tests
|
||||
python -m mypy src tests
|
||||
```
|
||||
|
||||
## 📄 License
|
||||
|
||||
GNU Affero General Public License v3.0, see [LICENSE](LICENSE).
|
||||
|
||||
## 🔗 Links
|
||||
|
||||
- [Repository][repository]
|
||||
- [Documentation][documentation]
|
||||
- [Issue tracker][issues]
|
||||
|
||||
## 📎 Appendix: Project documents
|
||||
|
||||
The documents a newcomer needs, in reading order.
|
||||
|
||||
| Document | What it tells you |
|
||||
| --- | --- |
|
||||
| [AGENTS.md][agents] | The rules AI agents follow in this repository |
|
||||
| [Business Case][BC-001] | Why the project exists, its scope and success criteria |
|
||||
| [Stakeholder Analysis][SA-001] | Who is involved; the stakeholder IDs used for owners and reviewers |
|
||||
| [Project Plan][PP-001] | The gateways and their schedule |
|
||||
| [Milestones][milestones] | One document per gateway: Go/No-Go criteria and tasks |
|
||||
| [ADR-0001][ADR-0001] | Why Selenium is the one runtime dependency |
|
||||
| [Artifact registry][registry] | Where each artifact type lives, the next version, the PO language and domain |
|
||||
| [Traceability matrix][TM-001] | How the artifacts link and when each was last reviewed |
|
||||
| [Framework README][framework-readme] | The framework this project uses |
|
||||
| [Framework changelog][framework-changelog] | What changed in each framework release |
|
||||
|
||||
---
|
||||
|
||||
[repository]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory
|
||||
[documentation]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/wiki
|
||||
[issues]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/issues
|
||||
[agents]: ./AGENTS.md
|
||||
[BC-001]: ./docs/business-case.md
|
||||
[SA-001]: ./docs/stakeholder-analysis.md
|
||||
[PP-001]: ./docs/project-plan.md
|
||||
[milestones]: ./docs/milestones/
|
||||
[ADR-0001]: ./docs/adr/adr-0001-selenium-runtime-dependency.md
|
||||
[registry]: ./docs/artifact-registry.md
|
||||
[TM-001]: ./docs/sqa/traceability-matrix.md
|
||||
[framework-readme]: ./framework/README.md
|
||||
[framework-changelog]: ./framework/CHANGELOG.md
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Headless Chromium for the Cookie Clicker bot. GUI runs never use the host desktop.
|
||||
FROM python:3.13-slim-bookworm
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends chromium chromium-driver fonts-liberation \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV CHROME_BIN=/usr/bin/chromium \
|
||||
CHROMEDRIVER=/usr/bin/chromedriver \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml README.md LICENSE ./
|
||||
COPY src ./src
|
||||
RUN python -m pip install --upgrade pip \
|
||||
&& python -m pip install .
|
||||
|
||||
RUN useradd --create-home bot \
|
||||
&& mkdir /runs \
|
||||
&& chown bot /runs
|
||||
USER bot
|
||||
|
||||
VOLUME ["/runs"]
|
||||
ENTRYPOINT ["python", "-m", "cookie_clicker_bot.presentation.capture_steps"]
|
||||
CMD ["--out", "/runs"]
|
||||
@@ -17,7 +17,7 @@ document of a type. `Primary File` may contain a glob (e.g.
|
||||
| MIL | Milestone / Gateway | docs/milestones/mil-*.md | 006 |
|
||||
| ADR | Architecture Decision Record | docs/adr/adr-*.md | 0002 |
|
||||
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
|
||||
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 011 |
|
||||
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 014 |
|
||||
|
||||
## Languages
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
INFO:__main__:Read 15 cookies
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 18 cookies
|
||||
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:6 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 100 cookies
|
||||
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:11 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 115 cookies
|
||||
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:16 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 133 cookies
|
||||
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:23 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 20 cookies
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 23 cookies
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 27 cookies
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 31 cookies
|
||||
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:28 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 35 cookies
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 40 cookies
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:33 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 46 cookies
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 53 cookies
|
||||
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:39 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 61 cookies
|
||||
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:45 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 153 cookies
|
||||
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:51 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 70 cookies
|
||||
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:56 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 175 cookies
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:62 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 81 cookies
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:67 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 93 cookies
|
||||
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:72 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 107 cookies
|
||||
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:78 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 123 cookies
|
||||
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:83 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 141 cookies
|
||||
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:89 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 162 cookies
|
||||
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:95 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:101 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 202 cookies
|
||||
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:107 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 186 cookies
|
||||
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:112 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:117 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 232 cookies
|
||||
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:123 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 214 cookies
|
||||
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:129 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:134 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 267 cookies
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:140 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:146 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 306 cookies
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:151 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:157 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 246 cookies
|
||||
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:162 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:167 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 352 cookies
|
||||
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:172 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:178 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 283 cookies
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:184 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:190 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 325 cookies
|
||||
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:195 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:201 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 405 cookies
|
||||
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:207 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:213 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 374 cookies
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:218 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:223 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:229 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 466 cookies
|
||||
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:234 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:240 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 430 cookies
|
||||
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 0 did not raise the owned amount
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:246 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:251 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:256 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 536 cookies
|
||||
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:262 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:268 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:273 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 0 for 494 cookies
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:278 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:283 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:289 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Bought product 1 for 616 cookies
|
||||
WARNING:cookie_clicker_bot.application.use_cases.run_bot:Purchase of product 1 did not raise the owned amount
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:294 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:299 of 300 seconds played
|
||||
INFO:cookie_clicker_bot.application.use_cases.run_bot:Run finished at 16.6 cookies per second
|
||||
Result : 16.6
|
||||
exit 0
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 663 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,62 @@
|
||||
# SQA Review Record
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-011 |
|
||||
| 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 S01 | Initial version | [68c4391] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: source of milestone MIL-002 (open_game, select_language, click_cookie, get_cookie_count, parser, Selenium adapter) ([MIL-002])
|
||||
- Checklist used: [QC-PY-001]
|
||||
- Scope: full review
|
||||
- Language and domain: n/a (technical type)
|
||||
- Language reviewer: none
|
||||
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S01`)
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | snake_case modules and functions, PascalCase classes, UPPER_SNAKE constants. |
|
||||
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose: parse_game_number, wait_for_element, ELEMENT_TIMEOUT_SECONDS. |
|
||||
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff check` and `ruff format --check` clean; no suppressions. |
|
||||
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | All signatures annotated, including -> None; mypy strict passes. |
|
||||
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No bare except; ValueError and UnreadableNumberError are raised with a message. |
|
||||
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults; no builtin shadowed. |
|
||||
| 7 | Files, locks and connections are managed with context managers | N-A | The browser is released by SeleniumGame.close(); the composition root in MIL-003 calls it in a finally block. |
|
||||
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Public modules, classes and functions have Doxygen docstrings (@brief, @param, @return). |
|
||||
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | N-A | No logging yet; added with the run loop in MIL-003. |
|
||||
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | Plain technical tasks; no Design Class Diagram exists or is required. |
|
||||
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 33 tests (unit and integration) run offline with a fake game and a fake driver; positive, negative, boundary and edge cases covered. |
|
||||
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | mypy --strict: no issues in 22 files; no Any. |
|
||||
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | selenium is declared and is the only runtime dependency. |
|
||||
| 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 | URL, ids and number words are defined once in constants.py. |
|
||||
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | domain imports only constants; application imports domain and its own port; only infrastructure/selenium_game.py imports selenium; no 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 | GamePort is a narrow Protocol (4 methods); SeleniumGame receives its driver (dependency inversion); each module has one purpose. |
|
||||
| 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 | domain, application (ports, use_cases), infrastructure and presentation under src/; unit, integration and e2e under tests/. |
|
||||
| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | All names English, snake_case, no spaces. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go - All Mandatory criteria pass or are not applicable; the live-game behaviour is verified in MIL-004. Stand-in review, not independent.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| None | S01 | - |
|
||||
|
||||
---
|
||||
|
||||
[MIL-002]: ../../milestones/mil-002-bot-core-open-and-click.md
|
||||
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
|
||||
[68c4391]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/commit/68c439137f13299e20cf6271e392365d9f5371c4
|
||||
@@ -0,0 +1,62 @@
|
||||
# SQA Review Record
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-012 |
|
||||
| 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 S01 | Initial version | [cb8e5ed] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: source of milestone MIL-003 (store, choose_upgrade, buy_upgrade, get_cps, run_bot, main) ([MIL-003])
|
||||
- Checklist used: [QC-PY-001]
|
||||
- Scope: full review
|
||||
- Language and domain: n/a (technical type)
|
||||
- Language reviewer: none
|
||||
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S01`)
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | PEP 8 casing throughout. |
|
||||
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose (choose_upgrade, STORE_CHECK_INTERVAL_SECONDS). |
|
||||
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff check` clean, `ruff format` applied, no suppressions. |
|
||||
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | All signatures annotated; mypy --strict passes on 33 files. |
|
||||
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | Specific exceptions (ValueError, UnreadableNumberError); a failed purchase is logged and stops the buying loop instead of being swallowed. |
|
||||
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults, no shadowed builtins. |
|
||||
| 7 | Files, locks and connections are managed with context managers | Pass | The browser is released in a finally block in cli.main (tested when run_bot raises). |
|
||||
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Doxygen docstrings on all public items. |
|
||||
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | Progress goes through logging; the only print is the program result `Result : <cps>` in the command line entry point; no secrets are logged. |
|
||||
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | Plain technical tasks; no Design Class Diagram exists or is required. |
|
||||
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 74 offline tests with a fake game and a fake clock; no sleeps, no network, no browser; positive, negative, boundary, edge, error handling and timing cases. |
|
||||
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | mypy --strict clean; no Any. |
|
||||
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | selenium remains the only runtime dependency. |
|
||||
| 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 | Ids, class names, burst size and intervals live in constants.py; decimal parsing is shared by parse_game_number. |
|
||||
| 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 only constants and errors; application imports domain and ports; selenium only in infrastructure; the CLI is the composition root. |
|
||||
| 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 | Clock and GamePort are narrow Protocols; run_bot receives both (dependency inversion); one purpose per module. |
|
||||
| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | Layers under src/ and unit and integration tests under tests/. |
|
||||
| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | All names English, snake_case, no spaces. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go - All Mandatory criteria pass or are not applicable; behaviour against the live game is verified in MIL-004. Stand-in review, not independent.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| None | S01 | - |
|
||||
|
||||
---
|
||||
|
||||
[MIL-003]: ../../milestones/mil-003-upgrades-and-five-minute-run.md
|
||||
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
|
||||
[cb8e5ed]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/commit/cb8e5edfbf5acd1d0553568c11be7bbb4c5e5e1c
|
||||
@@ -0,0 +1,62 @@
|
||||
# SQA Review Record
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-013 |
|
||||
| CrossReference | [MIL-004], [QC-PY-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [9a9ea53] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: source of milestone MIL-004 (capture_steps, Chrome environment support, retry, owned-amount purchase check) and docker/Dockerfile ([MIL-004])
|
||||
- Checklist used: [QC-PY-001]
|
||||
- Scope: full review
|
||||
- Language and domain: n/a (technical type)
|
||||
- Language reviewer: none
|
||||
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S01`)
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | PEP 8 casing throughout. |
|
||||
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose (StepUnreachableError, build_chrome_options, OWNED_CHECK_ATTEMPTS). |
|
||||
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff check` and `ruff format --check` clean, no suppressions. |
|
||||
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | All signatures annotated; mypy --strict passes on 35 files. |
|
||||
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | StepUnreachableError is raised and not swallowed; stale-element retries re-raise the last error after the limit. |
|
||||
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults, no shadowed builtins. |
|
||||
| 7 | Files, locks and connections are managed with context managers | Pass | The browser is released in a finally block in cli.main and capture_steps.main. |
|
||||
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Doxygen docstrings on all public items. |
|
||||
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | Progress goes through logging; print is used only for the program result; no secrets in output. |
|
||||
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | Plain technical tasks; no Design Class Diagram exists or is required. |
|
||||
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 99 offline tests (fake game, fake clock, fake driver); the live behaviour was checked in Docker: two full 300 second runs reported 15.7 and 16.6 cookies per second, the last one is in the README. |
|
||||
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | mypy --strict clean; no Any. |
|
||||
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | selenium remains the only runtime dependency; the Docker image installs only chromium for the browser. |
|
||||
| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | Environment variable names, step file names and limits live in constants.py. |
|
||||
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | selenium is imported only in infrastructure; presentation modules are composition roots. |
|
||||
| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | Ports stay narrow; capture_steps uses SeleniumGame directly because screenshots are documentation tooling (decision recorded in the hand-over). |
|
||||
| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | Layers under src/, unit, integration and e2e under tests/. |
|
||||
| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | All names English, no spaces; Dockerfile is a fixed tool name. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go - All Mandatory criteria pass or are not applicable. GUI runs were done headless in a Docker container; no GUI was started on the host desktop. The Docker run showed that about 40 percent of the purchase attempts fail because the store flags are read just before the game redraws; the bot logs and retries at the next store check (noted for the wiki). Stand-in review, not independent.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| None | S01 | - |
|
||||
|
||||
---
|
||||
|
||||
[MIL-004]: ../../milestones/mil-004-screenshots-and-readme.md
|
||||
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
|
||||
[9a9ea53]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/commit/9a9ea53cc17d0d4acb258855088ddf45b48b26ea
|
||||
@@ -9,8 +9,8 @@
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | <reviewer S-ID> | Initial version | [96a9a78] |
|
||||
| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Added ADR-0001, RC-009 and RC-010 | [ad3334f] |
|
||||
| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Added RC-012 (MIL-003 source review) | [cb8e5ed] |
|
||||
| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Added RC-013 (MIL-004 source review) | [9a9ea53] |
|
||||
|
||||
---
|
||||
|
||||
@@ -28,9 +28,9 @@ updated whenever an artifact instance is created or reviewed.
|
||||
| [SA-001] | SA | en | it | [BC-001] | [PP-001] | [RC-002] |
|
||||
| [PP-001] | PP | en | it | [BC-001], [SA-001] | [MIL-001], [MIL-002], [MIL-003], [MIL-004], [MIL-005] | [RC-003] |
|
||||
| [MIL-001] | MIL | en | it | [PP-001] | [MIL-002], [ADR-0001] | [RC-004], [RC-010] |
|
||||
| [MIL-002] | MIL | en | it | [MIL-001] | [MIL-003] | [RC-005] |
|
||||
| [MIL-003] | MIL | en | it | [MIL-002] | [MIL-004] | [RC-006] |
|
||||
| [MIL-004] | MIL | en | it | [MIL-003] | [MIL-005] | [RC-007] |
|
||||
| [MIL-002] | MIL | en | it | [MIL-001] | [MIL-003] | [RC-005], [RC-011] |
|
||||
| [MIL-003] | MIL | en | it | [MIL-002] | [MIL-004] | [RC-006], [RC-012] |
|
||||
| [MIL-004] | MIL | en | it | [MIL-003] | [MIL-005] | [RC-007], [RC-013] |
|
||||
| [MIL-005] | MIL | en | it | [MIL-004] | - | [RC-008] |
|
||||
| [ADR-0001] | ADR | - | - | [BC-001], [SA-001], [MIL-001] | - | [RC-009] |
|
||||
|
||||
@@ -71,7 +71,10 @@ updated whenever an artifact instance is created or reviewed.
|
||||
[RC-006]: ./reviews/rc-006-mil-003-upgrades-and-five-minute-run.md
|
||||
[RC-007]: ./reviews/rc-007-mil-004-screenshots-and-readme.md
|
||||
[RC-008]: ./reviews/rc-008-mil-005-wiki-and-release.md
|
||||
[96a9a78]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/commit/96a9a78f010ce8ae3fce8d84602317d8b5808a13
|
||||
[RC-009]: ./reviews/rc-009-adr-0001-selenium-runtime-dependency.md
|
||||
[RC-010]: ./reviews/rc-010-mil-001-source.md
|
||||
[ad3334f]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/commit/ad3334fa13c4261c3d034fbffcc7b43fe95fbb31
|
||||
[RC-011]: ./reviews/rc-011-mil-002-source.md
|
||||
[RC-012]: ./reviews/rc-012-mil-003-source.md
|
||||
[cb8e5ed]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/commit/cb8e5edfbf5acd1d0553568c11be7bbb4c5e5e1c
|
||||
[RC-013]: ./reviews/rc-013-mil-004-source.md
|
||||
[9a9ea53]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/commit/9a9ea53cc17d0d4acb258855088ddf45b48b26ea
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""@file
|
||||
@brief Lets `python -m cookie_clicker_bot` start the bot.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from cookie_clicker_bot.presentation.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s"
|
||||
)
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,3 @@
|
||||
"""@file
|
||||
@brief Ports: what the application needs from the outside world.
|
||||
"""
|
||||
@@ -0,0 +1,15 @@
|
||||
"""@file
|
||||
@brief The port through which the bot tells the time.
|
||||
"""
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class Clock(Protocol):
|
||||
"""@brief A monotonic clock. The system clock implements it."""
|
||||
|
||||
def monotonic(self) -> float:
|
||||
"""@brief Return seconds from a fixed but arbitrary start, never going back.
|
||||
@return The seconds.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,42 @@
|
||||
"""@file
|
||||
@brief The port through which the bot sees and operates the game.
|
||||
"""
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class GamePort(Protocol):
|
||||
"""@brief A running game page. The Selenium adapter implements it."""
|
||||
|
||||
def open_page(self, url: str) -> None:
|
||||
"""@brief Load the page at the address.
|
||||
@param url The address of the game.
|
||||
"""
|
||||
...
|
||||
|
||||
def wait_for_element(self, element_id: str) -> None:
|
||||
"""@brief Block until the element is on the page.
|
||||
@param element_id The HTML id of the element.
|
||||
"""
|
||||
...
|
||||
|
||||
def click_element(self, element_id: str) -> None:
|
||||
"""@brief Click the element the way a user does.
|
||||
@param element_id The HTML id of the element.
|
||||
"""
|
||||
...
|
||||
|
||||
def read_text(self, element_id: str) -> str:
|
||||
"""@brief Return the visible text of the element.
|
||||
@param element_id The HTML id of the element.
|
||||
@return The text as shown on the page.
|
||||
"""
|
||||
...
|
||||
|
||||
def read_attribute(self, element_id: str, attribute: str) -> str:
|
||||
"""@brief Return an attribute of the element.
|
||||
@param element_id The HTML id of the element.
|
||||
@param attribute The attribute name, for example "class".
|
||||
@return The value, or an empty string when the attribute is absent.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,3 @@
|
||||
"""@file
|
||||
@brief Use cases of the bot, one function per lecture step.
|
||||
"""
|
||||
@@ -0,0 +1,51 @@
|
||||
"""@file
|
||||
@brief Lecture steps 1 to 3: open the game, click the cookie, read the count.
|
||||
"""
|
||||
|
||||
from cookie_clicker_bot.application.ports.game_port import GamePort
|
||||
from cookie_clicker_bot.constants import (
|
||||
BIG_COOKIE_ID,
|
||||
COOKIE_COUNTER_ID,
|
||||
GAME_URL,
|
||||
LANGUAGE_BUTTON_PREFIX,
|
||||
LANGUAGE_CODE,
|
||||
)
|
||||
from cookie_clicker_bot.domain.number_parsing import parse_game_number
|
||||
|
||||
|
||||
def open_game(game: GamePort) -> None:
|
||||
"""@brief Load the game and wait until it asks for a language.
|
||||
@param game The game page to operate.
|
||||
"""
|
||||
game.open_page(GAME_URL)
|
||||
game.wait_for_element(LANGUAGE_BUTTON_PREFIX + LANGUAGE_CODE)
|
||||
|
||||
|
||||
def select_language(game: GamePort) -> None:
|
||||
"""@brief Choose the language and wait until the big cookie is playable.
|
||||
@param game The game page to operate.
|
||||
"""
|
||||
game.click_element(LANGUAGE_BUTTON_PREFIX + LANGUAGE_CODE)
|
||||
game.wait_for_element(BIG_COOKIE_ID)
|
||||
|
||||
|
||||
def click_cookie(game: GamePort, times: int = 1) -> None:
|
||||
"""@brief Click the big cookie.
|
||||
@param game The game page to operate.
|
||||
@param times How many clicks to make, zero or more.
|
||||
@throws ValueError When times is negative.
|
||||
"""
|
||||
if times < 0:
|
||||
raise ValueError(f"times must not be negative, got {times}")
|
||||
for _ in range(times):
|
||||
game.click_element(BIG_COOKIE_ID)
|
||||
|
||||
|
||||
def get_cookie_count(game: GamePort) -> int:
|
||||
"""@brief Read how many cookies the bakery has now.
|
||||
@param game The game page to read.
|
||||
@return The number of cookies, rounded to a whole number.
|
||||
@throws UnreadableNumberError When the counter shows no number.
|
||||
"""
|
||||
first_line = game.read_text(COOKIE_COUNTER_ID).strip().splitlines()[0]
|
||||
return parse_game_number(first_line)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""@file
|
||||
@brief Lecture step 5: play for a set time, buying upgrades, then report the rate.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
|
||||
from cookie_clicker_bot.application.ports.clock import Clock
|
||||
from cookie_clicker_bot.application.ports.game_port import GamePort
|
||||
from cookie_clicker_bot.application.use_cases.bot_core import click_cookie
|
||||
from cookie_clicker_bot.application.use_cases.store import (
|
||||
buy_upgrade,
|
||||
get_cps,
|
||||
get_store_items,
|
||||
)
|
||||
from cookie_clicker_bot.constants import (
|
||||
CLICKS_PER_BURST,
|
||||
RUN_DURATION_SECONDS,
|
||||
STORE_CHECK_INTERVAL_SECONDS,
|
||||
)
|
||||
from cookie_clicker_bot.domain.store_item import choose_upgrade
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _buy_while_affordable(game: GamePort) -> int:
|
||||
"""@brief Buy the best affordable product until none is left or a purchase fails."""
|
||||
bought = 0
|
||||
while (item := choose_upgrade(get_store_items(game))) is not None:
|
||||
if not buy_upgrade(game, item):
|
||||
_logger.warning(
|
||||
"Purchase of product %d did not raise the owned amount", item.index
|
||||
)
|
||||
break
|
||||
bought += 1
|
||||
_logger.info("Bought product %d for %d cookies", item.index, item.price)
|
||||
return bought
|
||||
|
||||
|
||||
def run_bot(
|
||||
game: GamePort,
|
||||
clock: Clock,
|
||||
duration_seconds: float = RUN_DURATION_SECONDS,
|
||||
) -> Decimal:
|
||||
"""@brief Click the cookie and buy upgrades until the time is up.
|
||||
|
||||
One loop: a burst of clicks, then, every store check interval, a visit to
|
||||
the store that buys the most expensive affordable product until none is.
|
||||
|
||||
@param game The game page to operate.
|
||||
@param clock The clock that measures the run.
|
||||
@param duration_seconds How long to play, more than zero.
|
||||
@return The cookies per second at the end of the run.
|
||||
@throws ValueError When duration_seconds is zero or negative.
|
||||
"""
|
||||
if duration_seconds <= 0:
|
||||
raise ValueError(f"duration_seconds must be positive, got {duration_seconds}")
|
||||
start = clock.monotonic()
|
||||
next_store_check = start + STORE_CHECK_INTERVAL_SECONDS
|
||||
now = start
|
||||
while now - start < duration_seconds:
|
||||
click_cookie(game, CLICKS_PER_BURST)
|
||||
now = clock.monotonic()
|
||||
if now >= next_store_check:
|
||||
_buy_while_affordable(game)
|
||||
next_store_check = now + STORE_CHECK_INTERVAL_SECONDS
|
||||
_logger.info("%.0f of %s seconds played", now - start, duration_seconds)
|
||||
cps = get_cps(game)
|
||||
_logger.info("Run finished at %s cookies per second", cps)
|
||||
return cps
|
||||
@@ -0,0 +1,76 @@
|
||||
"""@file
|
||||
@brief Lecture step 4: read the store, buy a product and read the cookies per second.
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from cookie_clicker_bot.application.ports.game_port import GamePort
|
||||
from cookie_clicker_bot.constants import (
|
||||
CLASS_ATTRIBUTE,
|
||||
CPS_ID,
|
||||
CPS_SEPARATOR,
|
||||
ENABLED_CLASS,
|
||||
OWNED_CHECK_ATTEMPTS,
|
||||
PRODUCT_COUNT,
|
||||
PRODUCT_ID_PREFIX,
|
||||
PRODUCT_OWNED_ID_PREFIX,
|
||||
PRODUCT_PRICE_ID_PREFIX,
|
||||
UNLOCKED_CLASS,
|
||||
)
|
||||
from cookie_clicker_bot.domain.number_parsing import (
|
||||
parse_game_decimal,
|
||||
parse_game_number,
|
||||
)
|
||||
from cookie_clicker_bot.domain.store_item import StoreItem
|
||||
|
||||
|
||||
def get_store_items(game: GamePort) -> list[StoreItem]:
|
||||
"""@brief Read the products that are visible in the store.
|
||||
@param game The game page to read.
|
||||
@return One item per unlocked product, in store order.
|
||||
@throws UnreadableNumberError When a price shows no number.
|
||||
"""
|
||||
items: list[StoreItem] = []
|
||||
for index in range(PRODUCT_COUNT):
|
||||
classes = game.read_attribute(
|
||||
PRODUCT_ID_PREFIX + str(index), CLASS_ATTRIBUTE
|
||||
).split()
|
||||
if UNLOCKED_CLASS not in classes:
|
||||
continue
|
||||
price = parse_game_number(game.read_text(PRODUCT_PRICE_ID_PREFIX + str(index)))
|
||||
items.append(StoreItem(index, price, ENABLED_CLASS in classes))
|
||||
return items
|
||||
|
||||
|
||||
def _read_owned(game: GamePort, index: int) -> int:
|
||||
"""@brief Read how many of a product is owned; the game shows none as empty."""
|
||||
text = game.read_text(PRODUCT_OWNED_ID_PREFIX + str(index)).strip()
|
||||
return parse_game_number(text) if text else 0
|
||||
|
||||
|
||||
def buy_upgrade(game: GamePort, item: StoreItem) -> bool:
|
||||
"""@brief Click a product and check that the purchase went through.
|
||||
|
||||
The game redraws the page a moment after the click, so the owned amount is
|
||||
read again, up to OWNED_CHECK_ATTEMPTS times, until it has grown.
|
||||
|
||||
@param game The game page to operate.
|
||||
@param item The product to buy.
|
||||
@return True when the owned amount of the product grew after the click.
|
||||
@throws UnreadableNumberError When the owned amount shows no number.
|
||||
"""
|
||||
before = _read_owned(game, item.index)
|
||||
game.click_element(PRODUCT_ID_PREFIX + str(item.index))
|
||||
return any(
|
||||
_read_owned(game, item.index) > before for _ in range(OWNED_CHECK_ATTEMPTS)
|
||||
)
|
||||
|
||||
|
||||
def get_cps(game: GamePort) -> Decimal:
|
||||
"""@brief Read the cookies per second the bakery makes now.
|
||||
@param game The game page to read.
|
||||
@return The rate, with its decimals.
|
||||
@throws UnreadableNumberError When the line shows no number.
|
||||
"""
|
||||
text = game.read_text(CPS_ID)
|
||||
return parse_game_decimal(text.partition(CPS_SEPARATOR)[2])
|
||||
@@ -1,7 +1,5 @@
|
||||
"""@file
|
||||
@brief Constants shared by every layer of the bot.
|
||||
|
||||
Game specific values (URL, element ids) are added with the code that uses them.
|
||||
"""
|
||||
|
||||
## Seconds the bot plays before it reads the cookies per second (lecture step 5).
|
||||
@@ -12,3 +10,102 @@ STORE_CHECK_INTERVAL_SECONDS: int = 5
|
||||
|
||||
## Seconds to wait for an element before the bot gives up.
|
||||
ELEMENT_TIMEOUT_SECONDS: int = 20
|
||||
|
||||
## Address of the game the bot plays.
|
||||
GAME_URL: str = "https://ozh.github.io/cookieclicker/"
|
||||
|
||||
## Element id prefix of the language buttons shown on the first launch.
|
||||
LANGUAGE_BUTTON_PREFIX: str = "langSelect-"
|
||||
|
||||
## Language the bot chooses (the game's own code for English).
|
||||
LANGUAGE_CODE: str = "EN"
|
||||
|
||||
## Element id of the big cookie that is clicked to earn cookies.
|
||||
BIG_COOKIE_ID: str = "bigCookie"
|
||||
|
||||
## Element id of the counter that shows the cookies and the cookies per second.
|
||||
COOKIE_COUNTER_ID: str = "cookies"
|
||||
|
||||
## Element id of the line that shows the cookies per second.
|
||||
CPS_ID: str = "cookiesPerSecond"
|
||||
|
||||
## Number of products in the store.
|
||||
PRODUCT_COUNT: int = 20
|
||||
|
||||
## Element id prefix of a store product; the product index follows.
|
||||
PRODUCT_ID_PREFIX: str = "product"
|
||||
|
||||
## Element id prefix of the price of a store product; the product index follows.
|
||||
PRODUCT_PRICE_ID_PREFIX: str = "productPrice"
|
||||
|
||||
## Element id prefix of the owned amount of a store product; the product index follows.
|
||||
PRODUCT_OWNED_ID_PREFIX: str = "productOwned"
|
||||
|
||||
## Reads of the owned amount after a purchase click, as the game redraws late.
|
||||
OWNED_CHECK_ATTEMPTS: int = 20
|
||||
|
||||
## Times an action on the page is repeated after a stale or blocked element.
|
||||
CLICK_RETRIES: int = 3
|
||||
|
||||
## Attribute that holds the CSS classes of an element.
|
||||
CLASS_ATTRIBUTE: str = "class"
|
||||
|
||||
## Class of a product that is visible in the store.
|
||||
UNLOCKED_CLASS: str = "unlocked"
|
||||
|
||||
## Class of a product the player can afford.
|
||||
ENABLED_CLASS: str = "enabled"
|
||||
|
||||
## Clicks on the big cookie between two looks at the clock.
|
||||
CLICKS_PER_BURST: int = 20
|
||||
|
||||
## Separator between the label and the amount in the cookies per second line.
|
||||
CPS_SEPARATOR: str = ":"
|
||||
|
||||
## Label printed before the cookies per second at the end of the run.
|
||||
RESULT_LABEL: str = "Result"
|
||||
|
||||
## Environment variable that names the Chrome binary to start.
|
||||
CHROME_BIN_ENV: str = "CHROME_BIN"
|
||||
|
||||
## Environment variable that names the chromedriver executable to use.
|
||||
CHROMEDRIVER_ENV: str = "CHROMEDRIVER"
|
||||
|
||||
## Screenshot file name of lecture step 1, the game opened at the language prompt.
|
||||
SCREENSHOT_OPEN_GAME: str = "step-1-open-game.png"
|
||||
|
||||
## Screenshot file name of lecture step 2, after the first clicks on the cookie.
|
||||
SCREENSHOT_CLICK_COOKIE: str = "step-2-click-cookie.png"
|
||||
|
||||
## Screenshot file name of lecture step 3, after the cookie count was read.
|
||||
SCREENSHOT_READ_COUNT: str = "step-3-read-count.png"
|
||||
|
||||
## Screenshot file name of lecture step 4, after an upgrade was bought.
|
||||
SCREENSHOT_BUY_UPGRADE: str = "step-4-buy-upgrade.png"
|
||||
|
||||
## Screenshot file name of lecture step 5, after the full run.
|
||||
SCREENSHOT_RESULT: str = "step-5-result.png"
|
||||
|
||||
## File name of the record of what each capture step read and did.
|
||||
STEPS_RECORD_FILE: str = "steps.json"
|
||||
|
||||
## Clicks on the big cookie in lecture step 2 of the capture.
|
||||
CAPTURE_CLICK_COUNT: int = 15
|
||||
|
||||
## Most bursts of clicks the capture makes while it waits for an affordable product.
|
||||
CAPTURE_MAX_BURSTS: int = 200
|
||||
|
||||
## Number words the game prints for large amounts, with their value.
|
||||
NUMBER_SCALES: dict[str, int] = {
|
||||
"thousand": 10**3,
|
||||
"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,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"""@file
|
||||
@brief Errors raised by the game rules.
|
||||
"""
|
||||
|
||||
|
||||
class UnreadableNumberError(ValueError):
|
||||
"""@brief Raised when text from the game does not contain a readable number."""
|
||||
@@ -0,0 +1,38 @@
|
||||
"""@file
|
||||
@brief Turns the amounts the game prints ("1.234 million") into numbers.
|
||||
"""
|
||||
|
||||
import re
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
from cookie_clicker_bot.constants import NUMBER_SCALES
|
||||
from cookie_clicker_bot.domain.errors import UnreadableNumberError
|
||||
|
||||
_AMOUNT = re.compile(r"^\s*(\d[\d,]*(?:\.\d+)?)(?:\s+([a-z]+))?", re.IGNORECASE)
|
||||
|
||||
|
||||
def parse_game_decimal(text: str) -> Decimal:
|
||||
"""@brief Read the first amount in a text printed by the game, keeping decimals.
|
||||
|
||||
The game groups thousands with commas ("12,345") and writes large amounts
|
||||
with a number word ("1.234 million"). Any other word after the number, such
|
||||
as "cookies", is ignored.
|
||||
|
||||
@param text The text, for example "0.1", "1.234 million" or "12,345 cookies".
|
||||
@return The exact amount.
|
||||
@throws UnreadableNumberError When the text does not start with a number.
|
||||
"""
|
||||
match = _AMOUNT.match(text)
|
||||
if match is None:
|
||||
raise UnreadableNumberError(f"No number at the start of {text!r}")
|
||||
value = Decimal(match.group(1).replace(",", ""))
|
||||
return value * NUMBER_SCALES.get((match.group(2) or "").lower(), 1)
|
||||
|
||||
|
||||
def parse_game_number(text: str) -> int:
|
||||
"""@brief Read the first amount in a text printed by the game as a whole number.
|
||||
@param text The text, for example "1.234 million" or "12,345 cookies".
|
||||
@return The amount, rounded to a whole number.
|
||||
@throws UnreadableNumberError When the text does not start with a number.
|
||||
"""
|
||||
return int(parse_game_decimal(text).to_integral_value(rounding=ROUND_HALF_UP))
|
||||
@@ -0,0 +1,34 @@
|
||||
"""@file
|
||||
@brief A product in the store and the rule that picks which one to buy.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StoreItem:
|
||||
"""@brief A product that is visible in the store.
|
||||
|
||||
@param index Position of the product in the store, from zero.
|
||||
@param price Cookies the product costs now.
|
||||
@param is_affordable True when the player has enough cookies to buy it.
|
||||
"""
|
||||
|
||||
index: int
|
||||
price: int
|
||||
is_affordable: bool
|
||||
|
||||
|
||||
def choose_upgrade(items: Sequence[StoreItem]) -> StoreItem | None:
|
||||
"""@brief Pick the most expensive product the player can afford.
|
||||
|
||||
A tie in price goes to the product with the highest index.
|
||||
|
||||
@param items The visible products.
|
||||
@return The product to buy, or None when nothing is affordable.
|
||||
"""
|
||||
affordable = [item for item in items if item.is_affordable]
|
||||
if not affordable:
|
||||
return None
|
||||
return max(affordable, key=lambda item: (item.price, item.index))
|
||||
@@ -0,0 +1,175 @@
|
||||
"""@file
|
||||
@brief Selenium adapter for the GamePort, and the factory for the Chrome driver.
|
||||
"""
|
||||
|
||||
import os
|
||||
from collections.abc import Callable, Mapping
|
||||
from pathlib import Path
|
||||
|
||||
from selenium import webdriver
|
||||
from selenium.common.exceptions import (
|
||||
ElementClickInterceptedException,
|
||||
StaleElementReferenceException,
|
||||
)
|
||||
from selenium.webdriver.chrome.service import Service
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.remote.webdriver import WebDriver
|
||||
from selenium.webdriver.support import expected_conditions
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
|
||||
from cookie_clicker_bot.constants import (
|
||||
CHROME_BIN_ENV,
|
||||
CHROMEDRIVER_ENV,
|
||||
CLICK_RETRIES,
|
||||
ELEMENT_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
_HEADLESS_ARGUMENTS: tuple[str, ...] = (
|
||||
"--headless=new",
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--window-size=1280,1024",
|
||||
)
|
||||
|
||||
_RETRYABLE_ERRORS = (StaleElementReferenceException, ElementClickInterceptedException)
|
||||
|
||||
|
||||
def _retry_on_page_change[T](action: Callable[[], T]) -> T:
|
||||
"""@brief Run the action again when the game re-renders the page under it.
|
||||
|
||||
The action finds its element itself, so each attempt works on a fresh one.
|
||||
|
||||
@param action Finds the element and acts on it.
|
||||
@return What the action returns.
|
||||
@throws StaleElementReferenceException When every attempt found a stale element.
|
||||
@throws ElementClickInterceptedException When every attempt was blocked.
|
||||
"""
|
||||
attempts_left = CLICK_RETRIES
|
||||
while True:
|
||||
try:
|
||||
return action()
|
||||
except _RETRYABLE_ERRORS:
|
||||
if attempts_left == 0:
|
||||
raise
|
||||
attempts_left -= 1
|
||||
|
||||
|
||||
class SeleniumGame:
|
||||
"""@brief Operates the game page through a Selenium web driver."""
|
||||
|
||||
def __init__(self, driver: WebDriver) -> None:
|
||||
"""@brief Create the adapter.
|
||||
@param driver The web driver to operate; the caller owns its creation.
|
||||
"""
|
||||
self._driver = driver
|
||||
|
||||
def open_page(self, url: str) -> None:
|
||||
"""@brief Load the page at the address.
|
||||
@param url The address of the game.
|
||||
"""
|
||||
self._driver.get(url)
|
||||
|
||||
def wait_for_element(self, element_id: str) -> None:
|
||||
"""@brief Block until the element is on the page.
|
||||
@param element_id The HTML id of the element.
|
||||
@throws selenium.common.exceptions.TimeoutException When it never appears.
|
||||
"""
|
||||
_retry_on_page_change(
|
||||
lambda: WebDriverWait(self._driver, ELEMENT_TIMEOUT_SECONDS).until(
|
||||
expected_conditions.presence_of_element_located((By.ID, element_id))
|
||||
)
|
||||
)
|
||||
|
||||
def click_element(self, element_id: str) -> None:
|
||||
"""@brief Click the element with a real click, never a script click.
|
||||
|
||||
The game rate-limits synthetic clicks, so the click must come from the
|
||||
browser the way a user's does.
|
||||
|
||||
@param element_id The HTML id of the element.
|
||||
@throws selenium.common.exceptions.TimeoutException When it never
|
||||
becomes clickable.
|
||||
@throws StaleElementReferenceException When the element stays stale
|
||||
after CLICK_RETRIES retries.
|
||||
"""
|
||||
_retry_on_page_change(lambda: self._click_once(element_id))
|
||||
|
||||
def _click_once(self, element_id: str) -> None:
|
||||
element = WebDriverWait(self._driver, ELEMENT_TIMEOUT_SECONDS).until(
|
||||
expected_conditions.element_to_be_clickable((By.ID, element_id))
|
||||
)
|
||||
element.click()
|
||||
|
||||
def read_text(self, element_id: str) -> str:
|
||||
"""@brief Return the visible text of the element.
|
||||
@param element_id The HTML id of the element.
|
||||
@return The text as shown on the page.
|
||||
"""
|
||||
return _retry_on_page_change(
|
||||
lambda: self._driver.find_element(By.ID, element_id).text
|
||||
)
|
||||
|
||||
def read_attribute(self, element_id: str, attribute: str) -> str:
|
||||
"""@brief Return an attribute of the element.
|
||||
@param element_id The HTML id of the element.
|
||||
@param attribute The attribute name, for example "class".
|
||||
@return The value, or an empty string when the attribute is absent.
|
||||
"""
|
||||
value = _retry_on_page_change(
|
||||
lambda: self._driver.find_element(By.ID, element_id).get_attribute(
|
||||
attribute
|
||||
)
|
||||
)
|
||||
return value or ""
|
||||
|
||||
def save_screenshot(self, path: Path) -> None:
|
||||
"""@brief Save a PNG of the page, creating the parent folder when needed.
|
||||
@param path The file to write.
|
||||
"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._driver.save_screenshot(str(path))
|
||||
|
||||
def close(self) -> None:
|
||||
"""@brief Quit the browser and end the driver session."""
|
||||
self._driver.quit()
|
||||
|
||||
|
||||
def build_chrome_options(
|
||||
headless: bool, env: Mapping[str, str]
|
||||
) -> webdriver.ChromeOptions:
|
||||
"""@brief Build the Chrome options.
|
||||
@param headless True to run without a window, for a container or CI.
|
||||
@param env The environment; CHROME_BIN names the Chrome binary when set.
|
||||
@return The options for the Chrome driver.
|
||||
"""
|
||||
options = webdriver.ChromeOptions()
|
||||
if headless:
|
||||
for argument in _HEADLESS_ARGUMENTS:
|
||||
options.add_argument(argument)
|
||||
chrome_binary = env.get(CHROME_BIN_ENV)
|
||||
if chrome_binary:
|
||||
options.binary_location = chrome_binary
|
||||
return options
|
||||
|
||||
|
||||
def build_chrome_service(env: Mapping[str, str]) -> Service | None:
|
||||
"""@brief Build the chromedriver service when the environment names one.
|
||||
@param env The environment; CHROMEDRIVER names the executable when set.
|
||||
@return The service, or None to let Selenium Manager find the driver.
|
||||
"""
|
||||
driver_path = env.get(CHROMEDRIVER_ENV)
|
||||
if not driver_path:
|
||||
return None
|
||||
return Service(executable_path=driver_path)
|
||||
|
||||
|
||||
def create_chrome_driver(headless: bool = False) -> WebDriver:
|
||||
"""@brief Build a Chrome web driver.
|
||||
@param headless True to run without a window, for a container or CI.
|
||||
@return The started Chrome driver.
|
||||
"""
|
||||
options = build_chrome_options(headless, os.environ)
|
||||
service = build_chrome_service(os.environ)
|
||||
if service is None:
|
||||
return webdriver.Chrome(options=options)
|
||||
return webdriver.Chrome(options=options, service=service)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""@file
|
||||
@brief The Clock port backed by the operating system.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
|
||||
class SystemClock:
|
||||
"""@brief Reads the real monotonic clock."""
|
||||
|
||||
def monotonic(self) -> float:
|
||||
"""@brief Return seconds from a fixed but arbitrary start, never going back.
|
||||
@return The seconds.
|
||||
"""
|
||||
return time.monotonic()
|
||||
@@ -0,0 +1,152 @@
|
||||
"""@file
|
||||
@brief Entry point that plays the five lecture steps and saves a screenshot after each.
|
||||
|
||||
Run it as `python -m cookie_clicker_bot.presentation.capture_steps --out DIR`.
|
||||
It is a documentation tool, so it uses the Selenium adapter directly and always
|
||||
runs headless.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Callable, Sequence
|
||||
from pathlib import Path
|
||||
|
||||
from selenium.webdriver.remote.webdriver import WebDriver
|
||||
|
||||
from cookie_clicker_bot.application.use_cases.bot_core import (
|
||||
click_cookie,
|
||||
get_cookie_count,
|
||||
open_game,
|
||||
select_language,
|
||||
)
|
||||
from cookie_clicker_bot.application.use_cases.run_bot import run_bot
|
||||
from cookie_clicker_bot.application.use_cases.store import (
|
||||
buy_upgrade,
|
||||
get_store_items,
|
||||
)
|
||||
from cookie_clicker_bot.constants import (
|
||||
CAPTURE_CLICK_COUNT,
|
||||
CAPTURE_MAX_BURSTS,
|
||||
CLICKS_PER_BURST,
|
||||
RESULT_LABEL,
|
||||
RUN_DURATION_SECONDS,
|
||||
SCREENSHOT_BUY_UPGRADE,
|
||||
SCREENSHOT_CLICK_COOKIE,
|
||||
SCREENSHOT_OPEN_GAME,
|
||||
SCREENSHOT_READ_COUNT,
|
||||
SCREENSHOT_RESULT,
|
||||
STEPS_RECORD_FILE,
|
||||
)
|
||||
from cookie_clicker_bot.domain.store_item import StoreItem, choose_upgrade
|
||||
from cookie_clicker_bot.infrastructure.selenium_game import (
|
||||
SeleniumGame,
|
||||
create_chrome_driver,
|
||||
)
|
||||
from cookie_clicker_bot.infrastructure.system_clock import SystemClock
|
||||
|
||||
DriverFactory = Callable[[bool], WebDriver]
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StepUnreachableError(RuntimeError):
|
||||
"""@brief Raised when a lecture step cannot be reached or does not succeed."""
|
||||
|
||||
|
||||
def _parse_arguments(argv: Sequence[str] | None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="capture_steps",
|
||||
description="Play the five lecture steps and save a screenshot after each.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--out", type=Path, required=True, help="folder for the screenshots"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--duration",
|
||||
type=float,
|
||||
default=RUN_DURATION_SECONDS,
|
||||
help="seconds of the final run (default: %(default)s)",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def _click_until_affordable(game: SeleniumGame) -> StoreItem:
|
||||
"""@brief Click in bursts until a product can be bought, within a fixed bound."""
|
||||
for _ in range(CAPTURE_MAX_BURSTS):
|
||||
item = choose_upgrade(get_store_items(game))
|
||||
if item is not None:
|
||||
return item
|
||||
click_cookie(game, CLICKS_PER_BURST)
|
||||
raise StepUnreachableError(
|
||||
f"no product became affordable within {CAPTURE_MAX_BURSTS} bursts of clicks"
|
||||
)
|
||||
|
||||
|
||||
def _buy_first_product(game: SeleniumGame) -> StoreItem:
|
||||
item = _click_until_affordable(game)
|
||||
if not buy_upgrade(game, item):
|
||||
raise StepUnreachableError(
|
||||
f"buying product {item.index} did not raise the owned amount"
|
||||
)
|
||||
return item
|
||||
|
||||
|
||||
def _write_record(
|
||||
path: Path, count: int, item: StoreItem, cps: str, duration: float
|
||||
) -> None:
|
||||
record = {
|
||||
"cookie_count": count,
|
||||
"bought": {"index": item.index, "price": item.price},
|
||||
"cps": cps,
|
||||
"duration_seconds": duration,
|
||||
}
|
||||
path.write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8", newline="\n")
|
||||
|
||||
|
||||
def _capture(game: SeleniumGame, out: Path, duration: float) -> str:
|
||||
"""@brief Play the five steps, saving a screenshot after each; return the cps."""
|
||||
open_game(game)
|
||||
game.save_screenshot(out / SCREENSHOT_OPEN_GAME)
|
||||
|
||||
select_language(game)
|
||||
click_cookie(game, CAPTURE_CLICK_COUNT)
|
||||
game.save_screenshot(out / SCREENSHOT_CLICK_COOKIE)
|
||||
|
||||
count = get_cookie_count(game)
|
||||
_logger.info("Read %d cookies", count)
|
||||
game.save_screenshot(out / SCREENSHOT_READ_COUNT)
|
||||
|
||||
item = _buy_first_product(game)
|
||||
game.save_screenshot(out / SCREENSHOT_BUY_UPGRADE)
|
||||
|
||||
cps = str(run_bot(game, SystemClock(), duration))
|
||||
game.save_screenshot(out / SCREENSHOT_RESULT)
|
||||
|
||||
_write_record(out / STEPS_RECORD_FILE, count, item, cps, duration)
|
||||
return cps
|
||||
|
||||
|
||||
def main(
|
||||
argv: Sequence[str] | None = None,
|
||||
driver_factory: DriverFactory = create_chrome_driver,
|
||||
) -> int:
|
||||
"""@brief Capture one screenshot per lecture step and print the result.
|
||||
@param argv The command line arguments, or None to read them from the process.
|
||||
@param driver_factory Builds the web driver; replaced in tests so no browser starts.
|
||||
@return The process exit code, zero on success.
|
||||
@throws StepUnreachableError When a step cannot be reached.
|
||||
"""
|
||||
arguments = _parse_arguments(argv)
|
||||
game = SeleniumGame(driver_factory(True))
|
||||
try:
|
||||
cps = _capture(game, arguments.out, arguments.duration)
|
||||
print(f"{RESULT_LABEL} : {cps}")
|
||||
finally:
|
||||
game.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,57 @@
|
||||
"""@file
|
||||
@brief Command line entry point and composition root of the bot.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from collections.abc import Callable, Sequence
|
||||
|
||||
from selenium.webdriver.remote.webdriver import WebDriver
|
||||
|
||||
from cookie_clicker_bot.application.use_cases.bot_core import open_game, select_language
|
||||
from cookie_clicker_bot.application.use_cases.run_bot import run_bot
|
||||
from cookie_clicker_bot.constants import RESULT_LABEL, RUN_DURATION_SECONDS
|
||||
from cookie_clicker_bot.infrastructure.selenium_game import (
|
||||
SeleniumGame,
|
||||
create_chrome_driver,
|
||||
)
|
||||
from cookie_clicker_bot.infrastructure.system_clock import SystemClock
|
||||
|
||||
DriverFactory = Callable[[bool], WebDriver]
|
||||
|
||||
|
||||
def _parse_arguments(argv: Sequence[str] | None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="cookie_clicker_bot", description="Play Cookie Clicker."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--headless", action="store_true", help="run Chrome without a window"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--duration",
|
||||
type=float,
|
||||
default=RUN_DURATION_SECONDS,
|
||||
help="seconds to play (default: %(default)s)",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(
|
||||
argv: Sequence[str] | None = None,
|
||||
driver_factory: DriverFactory = create_chrome_driver,
|
||||
) -> int:
|
||||
"""@brief Start the browser, run the bot, print the result and close the browser.
|
||||
@param argv The command line arguments, or None to read them from the process.
|
||||
@param driver_factory Builds the web driver; replaced in tests so no browser starts.
|
||||
@return The process exit code, zero on success.
|
||||
@throws ValueError When the duration is zero or negative.
|
||||
"""
|
||||
arguments = _parse_arguments(argv)
|
||||
game = SeleniumGame(driver_factory(arguments.headless))
|
||||
try:
|
||||
open_game(game)
|
||||
select_language(game)
|
||||
cps = run_bot(game, SystemClock(), arguments.duration)
|
||||
print(f"{RESULT_LABEL} : {cps}")
|
||||
finally:
|
||||
game.close()
|
||||
return 0
|
||||
@@ -0,0 +1,232 @@
|
||||
"""@file
|
||||
@brief Tests of the Selenium adapter against a hand-written fake driver.
|
||||
|
||||
No browser and no network: the fake driver records what the adapter asks for.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from selenium.common.exceptions import (
|
||||
ElementClickInterceptedException,
|
||||
NoSuchElementException,
|
||||
StaleElementReferenceException,
|
||||
)
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.remote.webdriver import WebDriver
|
||||
|
||||
from cookie_clicker_bot.constants import CHROME_BIN_ENV, CHROMEDRIVER_ENV, CLICK_RETRIES
|
||||
from cookie_clicker_bot.infrastructure.selenium_game import (
|
||||
SeleniumGame,
|
||||
build_chrome_options,
|
||||
build_chrome_service,
|
||||
)
|
||||
|
||||
|
||||
class FakeElement:
|
||||
"""@brief An element that counts its real clicks."""
|
||||
|
||||
def __init__(
|
||||
self, text: str = "", attributes: dict[str, str | None] | None = None
|
||||
) -> None:
|
||||
self.text = text
|
||||
self.attributes = attributes or {}
|
||||
self.clicks = 0
|
||||
self.failures: list[Exception] = []
|
||||
|
||||
def get_attribute(self, name: str) -> str | None:
|
||||
return self.attributes.get(name)
|
||||
|
||||
def is_displayed(self) -> bool:
|
||||
return True
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
return True
|
||||
|
||||
def click(self) -> None:
|
||||
if self.failures:
|
||||
raise self.failures.pop(0)
|
||||
self.clicks += 1
|
||||
|
||||
|
||||
class FakeDriver:
|
||||
"""@brief A driver with elements by id; it records its calls."""
|
||||
|
||||
def __init__(self, elements: dict[str, FakeElement]) -> None:
|
||||
self.elements = elements
|
||||
self.visited: list[str] = []
|
||||
self.lookups: list[tuple[str, str]] = []
|
||||
self.quit_calls = 0
|
||||
self.screenshots: list[str] = []
|
||||
self.lookup_failures: list[Exception] = []
|
||||
|
||||
def get(self, url: str) -> None:
|
||||
self.visited.append(url)
|
||||
|
||||
def find_element(self, by: str, value: str) -> FakeElement:
|
||||
self.lookups.append((by, value))
|
||||
if self.lookup_failures:
|
||||
raise self.lookup_failures.pop(0)
|
||||
if value not in self.elements:
|
||||
raise NoSuchElementException(value)
|
||||
return self.elements[value]
|
||||
|
||||
def save_screenshot(self, filename: str) -> bool:
|
||||
self.screenshots.append(filename)
|
||||
return True
|
||||
|
||||
def quit(self) -> None:
|
||||
self.quit_calls += 1
|
||||
|
||||
|
||||
def make_game(driver: FakeDriver) -> SeleniumGame:
|
||||
return SeleniumGame(cast(WebDriver, driver))
|
||||
|
||||
|
||||
def test_open_page_loads_the_url() -> None:
|
||||
driver = FakeDriver({})
|
||||
|
||||
make_game(driver).open_page("https://example.test/")
|
||||
|
||||
assert driver.visited == ["https://example.test/"]
|
||||
|
||||
|
||||
def test_wait_for_element_looks_up_by_id() -> None:
|
||||
driver = FakeDriver({"bigCookie": FakeElement()})
|
||||
|
||||
make_game(driver).wait_for_element("bigCookie")
|
||||
|
||||
assert driver.lookups == [(By.ID, "bigCookie")]
|
||||
|
||||
|
||||
def test_read_text_returns_the_element_text() -> None:
|
||||
driver = FakeDriver({"cookies": FakeElement("12 cookies")})
|
||||
|
||||
assert make_game(driver).read_text("cookies") == "12 cookies"
|
||||
assert driver.lookups == [(By.ID, "cookies")]
|
||||
|
||||
|
||||
def test_click_element_makes_one_real_click() -> None:
|
||||
element = FakeElement()
|
||||
driver = FakeDriver({"bigCookie": element})
|
||||
|
||||
make_game(driver).click_element("bigCookie")
|
||||
|
||||
assert element.clicks == 1
|
||||
assert all(by == By.ID for by, _ in driver.lookups)
|
||||
|
||||
|
||||
def test_read_text_raises_when_element_is_missing() -> None:
|
||||
game = make_game(FakeDriver({}))
|
||||
|
||||
with pytest.raises(NoSuchElementException):
|
||||
game.read_text("missing")
|
||||
|
||||
|
||||
def test_read_attribute_returns_the_attribute_value() -> None:
|
||||
driver = FakeDriver(
|
||||
{"product0": FakeElement(attributes={"class": "product unlocked"})}
|
||||
)
|
||||
|
||||
assert make_game(driver).read_attribute("product0", "class") == "product unlocked"
|
||||
assert driver.lookups == [(By.ID, "product0")]
|
||||
|
||||
|
||||
def test_read_attribute_returns_empty_string_when_absent() -> None:
|
||||
driver = FakeDriver({"product0": FakeElement()})
|
||||
|
||||
assert make_game(driver).read_attribute("product0", "class") == ""
|
||||
|
||||
|
||||
def test_click_element_retries_after_a_stale_element() -> None:
|
||||
element = FakeElement()
|
||||
element.failures = [StaleElementReferenceException()] * CLICK_RETRIES
|
||||
driver = FakeDriver({"bigCookie": element})
|
||||
|
||||
make_game(driver).click_element("bigCookie")
|
||||
|
||||
assert element.clicks == 1
|
||||
assert len(driver.lookups) == CLICK_RETRIES + 1
|
||||
|
||||
|
||||
def test_click_element_retries_after_an_intercepted_click() -> None:
|
||||
element = FakeElement()
|
||||
element.failures = [ElementClickInterceptedException()]
|
||||
|
||||
make_game(FakeDriver({"bigCookie": element})).click_element("bigCookie")
|
||||
|
||||
assert element.clicks == 1
|
||||
|
||||
|
||||
def test_click_element_raises_after_the_retry_limit() -> None:
|
||||
element = FakeElement()
|
||||
element.failures = [StaleElementReferenceException()] * (CLICK_RETRIES + 1)
|
||||
driver = FakeDriver({"bigCookie": element})
|
||||
|
||||
with pytest.raises(StaleElementReferenceException):
|
||||
make_game(driver).click_element("bigCookie")
|
||||
|
||||
assert element.clicks == 0
|
||||
assert len(driver.lookups) == CLICK_RETRIES + 1
|
||||
|
||||
|
||||
def test_read_text_retries_after_a_stale_element() -> None:
|
||||
driver = FakeDriver({"cookies": FakeElement("5")})
|
||||
driver.lookup_failures = [StaleElementReferenceException()] * CLICK_RETRIES
|
||||
|
||||
assert make_game(driver).read_text("cookies") == "5"
|
||||
|
||||
|
||||
def test_read_attribute_raises_after_the_retry_limit() -> None:
|
||||
driver = FakeDriver({"product0": FakeElement()})
|
||||
driver.lookup_failures = [StaleElementReferenceException()] * (CLICK_RETRIES + 1)
|
||||
|
||||
with pytest.raises(StaleElementReferenceException):
|
||||
make_game(driver).read_attribute("product0", "class")
|
||||
|
||||
|
||||
def test_save_screenshot_creates_the_folder_and_writes_the_file(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
driver = FakeDriver({})
|
||||
target = tmp_path / "nested" / "shot.png"
|
||||
|
||||
make_game(driver).save_screenshot(target)
|
||||
|
||||
assert driver.screenshots == [str(target)]
|
||||
assert target.parent.is_dir()
|
||||
|
||||
|
||||
def test_chrome_options_use_the_binary_from_the_environment() -> None:
|
||||
options = build_chrome_options(True, {CHROME_BIN_ENV: "/usr/bin/chromium"})
|
||||
|
||||
assert options.binary_location == "/usr/bin/chromium"
|
||||
assert "--headless=new" in options.arguments
|
||||
assert "--no-sandbox" in options.arguments
|
||||
|
||||
|
||||
def test_chrome_options_leave_the_binary_alone_without_the_variable() -> None:
|
||||
options = build_chrome_options(False, {})
|
||||
|
||||
assert options.binary_location == ""
|
||||
assert options.arguments == []
|
||||
|
||||
|
||||
def test_chrome_service_uses_the_driver_from_the_environment() -> None:
|
||||
service = build_chrome_service({CHROMEDRIVER_ENV: "/usr/bin/chromedriver"})
|
||||
|
||||
assert service is not None
|
||||
assert service.path == "/usr/bin/chromedriver"
|
||||
|
||||
|
||||
def test_chrome_service_is_absent_without_the_variable() -> None:
|
||||
assert build_chrome_service({}) is None
|
||||
|
||||
|
||||
def test_close_quits_the_driver() -> None:
|
||||
driver = FakeDriver({})
|
||||
|
||||
make_game(driver).close()
|
||||
|
||||
assert driver.quit_calls == 1
|
||||
@@ -0,0 +1,64 @@
|
||||
"""@file
|
||||
@brief Fakes of the ports for tests that must not start a browser or sleep.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
class FakeGame:
|
||||
"""@brief Records what the bot does and answers reads from dictionaries."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
texts: dict[str, str] | None = None,
|
||||
attributes: dict[tuple[str, str], str] | None = None,
|
||||
on_click: Callable[[str], None] | None = None,
|
||||
text_sequences: dict[str, list[str]] | None = None,
|
||||
) -> None:
|
||||
self.texts: dict[str, str] = texts or {}
|
||||
self.attributes: dict[tuple[str, str], str] = attributes or {}
|
||||
self.on_click = on_click
|
||||
## Texts returned one per read, before the plain text applies.
|
||||
self.text_sequences: dict[str, list[str]] = text_sequences or {}
|
||||
self.reads: dict[str, int] = {}
|
||||
self.opened: list[str] = []
|
||||
self.waited: list[str] = []
|
||||
self.clicked: list[str] = []
|
||||
self.close_calls = 0
|
||||
|
||||
def open_page(self, url: str) -> None:
|
||||
self.opened.append(url)
|
||||
|
||||
def wait_for_element(self, element_id: str) -> None:
|
||||
self.waited.append(element_id)
|
||||
|
||||
def click_element(self, element_id: str) -> None:
|
||||
self.clicked.append(element_id)
|
||||
if self.on_click is not None:
|
||||
self.on_click(element_id)
|
||||
|
||||
def read_text(self, element_id: str) -> str:
|
||||
self.reads[element_id] = self.reads.get(element_id, 0) + 1
|
||||
queued = self.text_sequences.get(element_id)
|
||||
if queued:
|
||||
return queued.pop(0)
|
||||
return self.texts[element_id]
|
||||
|
||||
def read_attribute(self, element_id: str, attribute: str) -> str:
|
||||
return self.attributes.get((element_id, attribute), "")
|
||||
|
||||
def close(self) -> None:
|
||||
self.close_calls += 1
|
||||
|
||||
|
||||
class FakeClock:
|
||||
"""@brief A clock that moves forward by a fixed step on every reading."""
|
||||
|
||||
def __init__(self, step: float = 1.0) -> None:
|
||||
self.now = 0.0
|
||||
self.step = step
|
||||
|
||||
def monotonic(self) -> float:
|
||||
reading = self.now
|
||||
self.now += self.step
|
||||
return reading
|
||||
@@ -0,0 +1,66 @@
|
||||
"""@file
|
||||
@brief Tests of lecture steps 1 to 3 against a fake game.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from cookie_clicker_bot.application.use_cases.bot_core import (
|
||||
click_cookie,
|
||||
get_cookie_count,
|
||||
open_game,
|
||||
select_language,
|
||||
)
|
||||
from cookie_clicker_bot.constants import (
|
||||
BIG_COOKIE_ID,
|
||||
COOKIE_COUNTER_ID,
|
||||
GAME_URL,
|
||||
LANGUAGE_BUTTON_PREFIX,
|
||||
LANGUAGE_CODE,
|
||||
)
|
||||
from tests.unit.fakes import FakeGame
|
||||
|
||||
LANGUAGE_BUTTON = LANGUAGE_BUTTON_PREFIX + LANGUAGE_CODE
|
||||
|
||||
|
||||
def test_open_game_loads_the_url_and_waits_for_the_language_prompt() -> None:
|
||||
game = FakeGame()
|
||||
open_game(game)
|
||||
assert game.opened == [GAME_URL]
|
||||
assert game.waited == [LANGUAGE_BUTTON]
|
||||
|
||||
|
||||
def test_select_language_clicks_the_button_and_waits_for_the_cookie() -> None:
|
||||
game = FakeGame()
|
||||
select_language(game)
|
||||
assert game.clicked == [LANGUAGE_BUTTON]
|
||||
assert game.waited == [BIG_COOKIE_ID]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("times", [0, 1, 2, 50])
|
||||
def test_click_cookie_clicks_the_requested_number_of_times(times: int) -> None:
|
||||
game = FakeGame()
|
||||
click_cookie(game, times)
|
||||
assert game.clicked == [BIG_COOKIE_ID] * times
|
||||
|
||||
|
||||
def test_click_cookie_defaults_to_one_click() -> None:
|
||||
game = FakeGame()
|
||||
click_cookie(game)
|
||||
assert game.clicked == [BIG_COOKIE_ID]
|
||||
|
||||
|
||||
def test_click_cookie_refuses_a_negative_count() -> None:
|
||||
game = FakeGame()
|
||||
with pytest.raises(ValueError, match="negative"):
|
||||
click_cookie(game, -1)
|
||||
assert game.clicked == []
|
||||
|
||||
|
||||
def test_get_cookie_count_reads_the_first_line_of_the_counter() -> None:
|
||||
game = FakeGame({COOKIE_COUNTER_ID: "12,345 cookies\nper second: 3.5"})
|
||||
assert get_cookie_count(game) == 12345
|
||||
|
||||
|
||||
def test_get_cookie_count_reads_large_amounts_split_over_lines() -> None:
|
||||
game = FakeGame({COOKIE_COUNTER_ID: "1.234 million\ncookies\nper second: 900"})
|
||||
assert get_cookie_count(game) == 1_234_000
|
||||
@@ -0,0 +1,184 @@
|
||||
"""@file
|
||||
@brief Tests of the screenshot capture entry point; no browser is started.
|
||||
"""
|
||||
|
||||
import json
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from selenium.webdriver.remote.webdriver import WebDriver
|
||||
|
||||
from cookie_clicker_bot.application.ports.clock import Clock
|
||||
from cookie_clicker_bot.application.ports.game_port import GamePort
|
||||
from cookie_clicker_bot.constants import (
|
||||
CAPTURE_CLICK_COUNT,
|
||||
CAPTURE_MAX_BURSTS,
|
||||
CLICKS_PER_BURST,
|
||||
RUN_DURATION_SECONDS,
|
||||
)
|
||||
from cookie_clicker_bot.domain.store_item import StoreItem
|
||||
from cookie_clicker_bot.presentation import capture_steps
|
||||
|
||||
STEP_FILES = [
|
||||
"step-1-open-game.png",
|
||||
"step-2-click-cookie.png",
|
||||
"step-3-read-count.png",
|
||||
"step-4-buy-upgrade.png",
|
||||
"step-5-result.png",
|
||||
]
|
||||
|
||||
|
||||
class FakeDriver:
|
||||
def __init__(self, events: list[str]) -> None:
|
||||
self.events = events
|
||||
self.quit_calls = 0
|
||||
|
||||
def save_screenshot(self, filename: str) -> bool:
|
||||
Path(filename).write_bytes(b"png")
|
||||
self.events.append(f"shot:{Path(filename).name}")
|
||||
return True
|
||||
|
||||
def quit(self) -> None:
|
||||
self.quit_calls += 1
|
||||
|
||||
|
||||
class Script:
|
||||
"""Stands in for the use cases and the driver factory and records the order."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.events: list[str] = []
|
||||
self.driver = FakeDriver(self.events)
|
||||
self.headless: list[bool] = []
|
||||
self.durations: list[float] = []
|
||||
self.clicks: list[int] = []
|
||||
self.affordable_after_bursts = 2
|
||||
self.store_reads = 0
|
||||
self.purchase_works = True
|
||||
|
||||
def driver_factory(self, headless: bool) -> WebDriver:
|
||||
self.headless.append(headless)
|
||||
return cast(WebDriver, self.driver)
|
||||
|
||||
def open_game(self, game: GamePort) -> None:
|
||||
self.events.append("open")
|
||||
|
||||
def select_language(self, game: GamePort) -> None:
|
||||
self.events.append("language")
|
||||
|
||||
def click_cookie(self, game: GamePort, times: int = 1) -> None:
|
||||
self.clicks.append(times)
|
||||
self.events.append(f"click:{times}")
|
||||
|
||||
def get_cookie_count(self, game: GamePort) -> int:
|
||||
self.events.append("count")
|
||||
return 42
|
||||
|
||||
def get_store_items(self, game: GamePort) -> list[StoreItem]:
|
||||
self.store_reads += 1
|
||||
affordable = len(self.clicks) - 1 >= self.affordable_after_bursts
|
||||
return [StoreItem(3, 100, affordable)]
|
||||
|
||||
def buy_upgrade(self, game: GamePort, item: StoreItem) -> bool:
|
||||
self.events.append(f"buy:{item.index}")
|
||||
return self.purchase_works
|
||||
|
||||
def run_bot(self, game: GamePort, clock: Clock, duration: float) -> Decimal:
|
||||
self.events.append("run")
|
||||
self.durations.append(duration)
|
||||
return Decimal("12.5")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def script(monkeypatch: pytest.MonkeyPatch) -> Script:
|
||||
s = Script()
|
||||
for name in (
|
||||
"open_game",
|
||||
"select_language",
|
||||
"click_cookie",
|
||||
"get_cookie_count",
|
||||
"get_store_items",
|
||||
"buy_upgrade",
|
||||
"run_bot",
|
||||
):
|
||||
monkeypatch.setattr(capture_steps, name, getattr(s, name))
|
||||
return s
|
||||
|
||||
|
||||
def test_main_saves_one_screenshot_per_step_in_order(
|
||||
script: Script, tmp_path: Path
|
||||
) -> None:
|
||||
assert capture_steps.main(["--out", str(tmp_path)], script.driver_factory) == 0
|
||||
shots = [e for e in script.events if e.startswith("shot:")]
|
||||
assert shots == ["shot:" + name for name in STEP_FILES]
|
||||
assert all((tmp_path / name).read_bytes() == b"png" for name in STEP_FILES)
|
||||
assert script.events[:5] == [
|
||||
"open",
|
||||
"shot:step-1-open-game.png",
|
||||
"language",
|
||||
f"click:{CAPTURE_CLICK_COUNT}",
|
||||
"shot:step-2-click-cookie.png",
|
||||
]
|
||||
|
||||
|
||||
def test_main_runs_headless_closes_and_prints_the_result(
|
||||
script: Script, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
capture_steps.main(["--out", str(tmp_path)], script.driver_factory)
|
||||
assert script.headless == [True]
|
||||
assert script.driver.quit_calls == 1
|
||||
assert capsys.readouterr().out == "Result : 12.5\n"
|
||||
assert script.durations == [RUN_DURATION_SECONDS]
|
||||
|
||||
|
||||
def test_main_writes_the_steps_record(script: Script, tmp_path: Path) -> None:
|
||||
capture_steps.main(
|
||||
["--out", str(tmp_path), "--duration", "30"], script.driver_factory
|
||||
)
|
||||
record = json.loads((tmp_path / "steps.json").read_text(encoding="utf-8"))
|
||||
assert record == {
|
||||
"cookie_count": 42,
|
||||
"bought": {"index": 3, "price": 100},
|
||||
"cps": "12.5",
|
||||
"duration_seconds": 30.0,
|
||||
}
|
||||
|
||||
|
||||
def test_main_creates_the_output_folder(script: Script, tmp_path: Path) -> None:
|
||||
out = tmp_path / "nested" / "shots"
|
||||
capture_steps.main(["--out", str(out)], script.driver_factory)
|
||||
assert (out / STEP_FILES[0]).exists()
|
||||
|
||||
|
||||
def test_main_clicks_in_bursts_until_a_product_is_affordable(
|
||||
script: Script, tmp_path: Path
|
||||
) -> None:
|
||||
capture_steps.main(["--out", str(tmp_path)], script.driver_factory)
|
||||
assert script.clicks == [CAPTURE_CLICK_COUNT] + [CLICKS_PER_BURST] * 2
|
||||
|
||||
|
||||
def test_main_raises_when_nothing_becomes_affordable(
|
||||
script: Script, tmp_path: Path
|
||||
) -> None:
|
||||
script.affordable_after_bursts = CAPTURE_MAX_BURSTS + 10
|
||||
with pytest.raises(capture_steps.StepUnreachableError, match="affordable"):
|
||||
capture_steps.main(["--out", str(tmp_path)], script.driver_factory)
|
||||
assert script.store_reads == CAPTURE_MAX_BURSTS
|
||||
assert script.driver.quit_calls == 1
|
||||
assert not (tmp_path / "step-4-buy-upgrade.png").exists()
|
||||
|
||||
|
||||
def test_main_raises_when_the_purchase_does_not_go_through(
|
||||
script: Script, tmp_path: Path
|
||||
) -> None:
|
||||
script.purchase_works = False
|
||||
with pytest.raises(capture_steps.StepUnreachableError, match="owned amount"):
|
||||
capture_steps.main(["--out", str(tmp_path)], script.driver_factory)
|
||||
assert script.driver.quit_calls == 1
|
||||
assert not (tmp_path / "steps.json").exists()
|
||||
|
||||
|
||||
def test_main_requires_the_out_argument(script: Script) -> None:
|
||||
with pytest.raises(SystemExit):
|
||||
capture_steps.main([], script.driver_factory)
|
||||
@@ -0,0 +1,92 @@
|
||||
"""@file
|
||||
@brief Tests of the command line entry point; no browser is started.
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from selenium.webdriver.remote.webdriver import WebDriver
|
||||
|
||||
from cookie_clicker_bot.application.ports.clock import Clock
|
||||
from cookie_clicker_bot.application.ports.game_port import GamePort
|
||||
from cookie_clicker_bot.constants import RUN_DURATION_SECONDS
|
||||
from cookie_clicker_bot.presentation import cli
|
||||
|
||||
|
||||
class FakeDriver:
|
||||
def __init__(self) -> None:
|
||||
self.quit_calls = 0
|
||||
|
||||
def quit(self) -> None:
|
||||
self.quit_calls += 1
|
||||
|
||||
|
||||
class Recorder:
|
||||
"""Stands in for the use cases and the driver factory and records the calls."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.driver = FakeDriver()
|
||||
self.headless: list[bool] = []
|
||||
self.durations: list[float] = []
|
||||
self.steps: list[str] = []
|
||||
self.error: Exception | None = None
|
||||
|
||||
def driver_factory(self, headless: bool) -> WebDriver:
|
||||
self.headless.append(headless)
|
||||
return cast(WebDriver, self.driver)
|
||||
|
||||
def open_game(self, game: GamePort) -> None:
|
||||
self.steps.append("open")
|
||||
|
||||
def select_language(self, game: GamePort) -> None:
|
||||
self.steps.append("language")
|
||||
|
||||
def run_bot(self, game: GamePort, clock: Clock, duration: float) -> Decimal:
|
||||
self.steps.append("run")
|
||||
self.durations.append(duration)
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return Decimal("12.5")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def recorder(monkeypatch: pytest.MonkeyPatch) -> Recorder:
|
||||
rec = Recorder()
|
||||
monkeypatch.setattr(cli, "open_game", rec.open_game)
|
||||
monkeypatch.setattr(cli, "select_language", rec.select_language)
|
||||
monkeypatch.setattr(cli, "run_bot", rec.run_bot)
|
||||
return rec
|
||||
|
||||
|
||||
def test_main_runs_the_steps_prints_the_result_and_closes(
|
||||
recorder: Recorder, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
assert cli.main([], recorder.driver_factory) == 0
|
||||
assert recorder.steps == ["open", "language", "run"]
|
||||
assert capsys.readouterr().out == "Result : 12.5\n"
|
||||
assert recorder.driver.quit_calls == 1
|
||||
|
||||
|
||||
def test_main_uses_the_default_duration_and_a_visible_browser(
|
||||
recorder: Recorder,
|
||||
) -> None:
|
||||
cli.main([], recorder.driver_factory)
|
||||
assert recorder.durations == [RUN_DURATION_SECONDS]
|
||||
assert recorder.headless == [False]
|
||||
|
||||
|
||||
def test_main_passes_the_headless_flag_and_duration(recorder: Recorder) -> None:
|
||||
cli.main(["--headless", "--duration", "30"], recorder.driver_factory)
|
||||
assert recorder.durations == [30.0]
|
||||
assert recorder.headless == [True]
|
||||
|
||||
|
||||
def test_main_closes_the_browser_when_the_run_fails(
|
||||
recorder: Recorder, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
recorder.error = RuntimeError("boom")
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
cli.main([], recorder.driver_factory)
|
||||
assert recorder.driver.quit_calls == 1
|
||||
assert capsys.readouterr().out == ""
|
||||
@@ -0,0 +1,72 @@
|
||||
"""@file
|
||||
@brief Tests of parse_game_number.
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from cookie_clicker_bot.domain.errors import UnreadableNumberError
|
||||
from cookie_clicker_bot.domain.number_parsing import (
|
||||
parse_game_decimal,
|
||||
parse_game_number,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "expected"),
|
||||
[
|
||||
("0", 0),
|
||||
("1 cookie", 1),
|
||||
("15 cookies", 15),
|
||||
("12,345 cookies", 12345),
|
||||
("1,234,567", 1234567),
|
||||
],
|
||||
)
|
||||
def test_parse_plain_amounts(text: str, expected: int) -> None:
|
||||
assert parse_game_number(text) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "expected"),
|
||||
[
|
||||
("1.234 million", 1_234_000),
|
||||
("2 billion", 2_000_000_000),
|
||||
("1.500 Thousand", 1500),
|
||||
("3 decillion", 3 * 10**33),
|
||||
],
|
||||
)
|
||||
def test_parse_amounts_with_number_word(text: str, expected: int) -> None:
|
||||
assert parse_game_number(text) == expected
|
||||
|
||||
|
||||
def test_parse_ignores_unknown_word_after_the_number() -> None:
|
||||
assert parse_game_number("42 bakers") == 42
|
||||
|
||||
|
||||
def test_parse_rounds_decimals() -> None:
|
||||
assert parse_game_number("7.6 cookies") == 8
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "expected"),
|
||||
[
|
||||
("0.1", Decimal("0.1")),
|
||||
("1,234.5", Decimal("1234.5")),
|
||||
("1.234 million", Decimal("1234000")),
|
||||
("7", Decimal(7)),
|
||||
],
|
||||
)
|
||||
def test_parse_decimal_keeps_the_decimals(text: str, expected: Decimal) -> None:
|
||||
assert parse_game_decimal(text) == expected
|
||||
|
||||
|
||||
def test_parse_decimal_rejects_text_without_a_number() -> None:
|
||||
with pytest.raises(UnreadableNumberError):
|
||||
parse_game_decimal("none")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", ["", "cookies", "-5", "per second: 3"])
|
||||
def test_parse_rejects_text_without_a_leading_number(text: str) -> None:
|
||||
with pytest.raises(UnreadableNumberError):
|
||||
parse_game_number(text)
|
||||
@@ -0,0 +1,110 @@
|
||||
"""@file
|
||||
@brief Tests of the run loop against a fake game and a fake clock.
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from cookie_clicker_bot.application.use_cases.run_bot import run_bot
|
||||
from cookie_clicker_bot.constants import (
|
||||
BIG_COOKIE_ID,
|
||||
CLASS_ATTRIBUTE,
|
||||
CLICKS_PER_BURST,
|
||||
COOKIE_COUNTER_ID,
|
||||
CPS_ID,
|
||||
PRODUCT_ID_PREFIX,
|
||||
PRODUCT_OWNED_ID_PREFIX,
|
||||
PRODUCT_PRICE_ID_PREFIX,
|
||||
RUN_DURATION_SECONDS,
|
||||
STORE_CHECK_INTERVAL_SECONDS,
|
||||
)
|
||||
from cookie_clicker_bot.domain.errors import UnreadableNumberError
|
||||
from tests.unit.fakes import FakeClock, FakeGame
|
||||
|
||||
PRODUCT = f"{PRODUCT_ID_PREFIX}0"
|
||||
|
||||
|
||||
def make_game(cookies: int = 100, price: int = 15) -> FakeGame:
|
||||
"""A store with one affordable product that is spent once, then unaffordable."""
|
||||
game = FakeGame(
|
||||
texts={
|
||||
COOKIE_COUNTER_ID: f"{cookies} cookies",
|
||||
f"{PRODUCT_PRICE_ID_PREFIX}0": str(price),
|
||||
CPS_ID: "per second: 4.5",
|
||||
f"{PRODUCT_OWNED_ID_PREFIX}0": "",
|
||||
},
|
||||
attributes={(PRODUCT, CLASS_ATTRIBUTE): "product unlocked enabled"},
|
||||
)
|
||||
|
||||
def spend(element_id: str) -> None:
|
||||
if element_id == PRODUCT:
|
||||
game.texts[COOKIE_COUNTER_ID] = f"{cookies - price} cookies"
|
||||
game.texts[f"{PRODUCT_OWNED_ID_PREFIX}0"] = "1"
|
||||
game.attributes[(PRODUCT, CLASS_ATTRIBUTE)] = "product unlocked disabled"
|
||||
|
||||
game.on_click = spend
|
||||
return game
|
||||
|
||||
|
||||
def test_run_bot_returns_the_cps() -> None:
|
||||
assert run_bot(make_game(), FakeClock(), 3) == Decimal("4.5")
|
||||
|
||||
|
||||
def test_run_bot_clicks_in_bursts_until_the_duration_is_over() -> None:
|
||||
game = FakeGame(texts={CPS_ID: "per second: 1"})
|
||||
run_bot(game, FakeClock(step=1.0), 3)
|
||||
assert game.clicked == [BIG_COOKIE_ID] * CLICKS_PER_BURST * 3
|
||||
|
||||
|
||||
def test_run_bot_stops_after_the_duration() -> None:
|
||||
clock = FakeClock(step=10.0)
|
||||
game = FakeGame(texts={CPS_ID: "per second: 1"})
|
||||
run_bot(game, clock, 25)
|
||||
assert clock.now == 40.0
|
||||
|
||||
|
||||
def test_run_bot_does_not_visit_the_store_before_the_interval() -> None:
|
||||
game = make_game()
|
||||
run_bot(game, FakeClock(step=1.0), STORE_CHECK_INTERVAL_SECONDS - 1)
|
||||
assert PRODUCT not in game.clicked
|
||||
|
||||
|
||||
def test_run_bot_buys_at_the_interval() -> None:
|
||||
game = make_game()
|
||||
run_bot(game, FakeClock(step=1.0), STORE_CHECK_INTERVAL_SECONDS + 2)
|
||||
assert game.clicked.count(PRODUCT) == 1
|
||||
|
||||
|
||||
def test_run_bot_stops_buying_when_nothing_is_affordable() -> None:
|
||||
game = make_game()
|
||||
run_bot(game, FakeClock(step=1.0), STORE_CHECK_INTERVAL_SECONDS * 3)
|
||||
assert game.clicked.count(PRODUCT) == 1
|
||||
|
||||
|
||||
def test_run_bot_stops_buying_when_a_purchase_fails() -> None:
|
||||
game = make_game()
|
||||
game.on_click = None
|
||||
run_bot(game, FakeClock(step=1.0), STORE_CHECK_INTERVAL_SECONDS + 1)
|
||||
assert game.clicked.count(PRODUCT) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("duration", [0, -1])
|
||||
def test_run_bot_rejects_a_non_positive_duration(duration: float) -> None:
|
||||
game = FakeGame()
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
run_bot(game, FakeClock(), duration)
|
||||
assert game.clicked == []
|
||||
|
||||
|
||||
def test_run_bot_propagates_an_unreadable_cps() -> None:
|
||||
game = FakeGame(texts={CPS_ID: "nothing"})
|
||||
with pytest.raises(UnreadableNumberError):
|
||||
run_bot(game, FakeClock(), 1)
|
||||
|
||||
|
||||
def test_run_duration_default_is_the_constant() -> None:
|
||||
clock = FakeClock(step=100.0)
|
||||
game = FakeGame(texts={CPS_ID: "per second: 1"})
|
||||
run_bot(game, clock)
|
||||
assert clock.now == 100.0 * (RUN_DURATION_SECONDS // 100 + 1)
|
||||
@@ -0,0 +1,156 @@
|
||||
"""@file
|
||||
@brief Tests of get_store_items, buy_upgrade and get_cps against a fake game.
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from cookie_clicker_bot.application.use_cases.store import (
|
||||
buy_upgrade,
|
||||
get_cps,
|
||||
get_store_items,
|
||||
)
|
||||
from cookie_clicker_bot.constants import (
|
||||
CLASS_ATTRIBUTE,
|
||||
COOKIE_COUNTER_ID,
|
||||
CPS_ID,
|
||||
OWNED_CHECK_ATTEMPTS,
|
||||
PRODUCT_ID_PREFIX,
|
||||
PRODUCT_OWNED_ID_PREFIX,
|
||||
PRODUCT_PRICE_ID_PREFIX,
|
||||
)
|
||||
from cookie_clicker_bot.domain.errors import UnreadableNumberError
|
||||
from cookie_clicker_bot.domain.store_item import StoreItem
|
||||
from tests.unit.fakes import FakeGame
|
||||
|
||||
|
||||
def make_store(classes: dict[int, str], prices: dict[int, str]) -> FakeGame:
|
||||
attributes = {
|
||||
(f"{PRODUCT_ID_PREFIX}{i}", CLASS_ATTRIBUTE): c for i, c in classes.items()
|
||||
}
|
||||
texts = {f"{PRODUCT_PRICE_ID_PREFIX}{i}": p for i, p in prices.items()}
|
||||
return FakeGame(texts=texts, attributes=attributes)
|
||||
|
||||
|
||||
def test_get_store_items_skips_locked_products() -> None:
|
||||
game = make_store(
|
||||
{0: "product unlocked enabled", 1: "product locked disabled", 2: "product"},
|
||||
{0: "15", 1: "100", 2: "1,100"},
|
||||
)
|
||||
assert get_store_items(game) == [StoreItem(0, 15, True)]
|
||||
|
||||
|
||||
def test_get_store_items_reads_price_and_affordability() -> None:
|
||||
game = make_store(
|
||||
{
|
||||
0: "product unlocked enabled",
|
||||
1: "product unlocked disabled",
|
||||
2: "product unlocked disabled",
|
||||
},
|
||||
{0: "15", 1: "1,100", 2: "1.5 million"},
|
||||
)
|
||||
assert get_store_items(game) == [
|
||||
StoreItem(0, 15, True),
|
||||
StoreItem(1, 1100, False),
|
||||
StoreItem(2, 1_500_000, False),
|
||||
]
|
||||
|
||||
|
||||
def test_get_store_items_is_empty_when_nothing_is_unlocked() -> None:
|
||||
assert get_store_items(FakeGame()) == []
|
||||
|
||||
|
||||
def test_get_store_items_propagates_an_unreadable_price() -> None:
|
||||
game = make_store({0: "product unlocked"}, {0: "???"})
|
||||
with pytest.raises(UnreadableNumberError):
|
||||
get_store_items(game)
|
||||
|
||||
|
||||
def test_buy_upgrade_returns_true_when_the_owned_amount_grows() -> None:
|
||||
game = FakeGame(texts={f"{PRODUCT_OWNED_ID_PREFIX}3": "2"})
|
||||
|
||||
def buy(element_id: str) -> None:
|
||||
game.texts[f"{PRODUCT_OWNED_ID_PREFIX}3"] = "3"
|
||||
|
||||
game.on_click = buy
|
||||
assert buy_upgrade(game, StoreItem(3, 15, True)) is True
|
||||
assert game.clicked == [f"{PRODUCT_ID_PREFIX}3"]
|
||||
|
||||
|
||||
def test_buy_upgrade_treats_an_empty_owned_text_as_zero() -> None:
|
||||
game = FakeGame(texts={f"{PRODUCT_OWNED_ID_PREFIX}0": ""})
|
||||
|
||||
def buy(element_id: str) -> None:
|
||||
game.texts[f"{PRODUCT_OWNED_ID_PREFIX}0"] = "1"
|
||||
|
||||
game.on_click = buy
|
||||
assert buy_upgrade(game, StoreItem(0, 15, True)) is True
|
||||
|
||||
|
||||
def test_buy_upgrade_returns_false_when_the_owned_amount_is_unchanged() -> None:
|
||||
game = FakeGame(texts={f"{PRODUCT_OWNED_ID_PREFIX}3": "2"})
|
||||
assert buy_upgrade(game, StoreItem(3, 15, True)) is False
|
||||
assert game.clicked == [f"{PRODUCT_ID_PREFIX}3"]
|
||||
|
||||
|
||||
def _owned_game(after: list[str]) -> FakeGame:
|
||||
"""Owned text "2" before the click, then the given texts, one per read."""
|
||||
owned = f"{PRODUCT_OWNED_ID_PREFIX}3"
|
||||
return FakeGame(texts={owned: "2"}, text_sequences={owned: ["2", *after]})
|
||||
|
||||
|
||||
def test_buy_upgrade_waits_for_a_late_redraw() -> None:
|
||||
game = _owned_game(["2", "3"])
|
||||
assert buy_upgrade(game, StoreItem(3, 15, True)) is True
|
||||
assert game.reads[f"{PRODUCT_OWNED_ID_PREFIX}3"] == 3
|
||||
assert game.clicked == [f"{PRODUCT_ID_PREFIX}3"]
|
||||
|
||||
|
||||
def test_buy_upgrade_gives_up_after_the_attempt_limit() -> None:
|
||||
game = _owned_game([])
|
||||
assert buy_upgrade(game, StoreItem(3, 15, True)) is False
|
||||
assert game.reads[f"{PRODUCT_OWNED_ID_PREFIX}3"] == 1 + OWNED_CHECK_ATTEMPTS
|
||||
assert game.clicked == [f"{PRODUCT_ID_PREFIX}3"]
|
||||
|
||||
|
||||
def test_buy_upgrade_accepts_a_change_on_the_last_attempt() -> None:
|
||||
game = _owned_game(["2"] * (OWNED_CHECK_ATTEMPTS - 1) + ["3"])
|
||||
assert buy_upgrade(game, StoreItem(3, 15, True)) is True
|
||||
|
||||
|
||||
def test_buy_upgrade_rejects_a_change_one_attempt_too_late() -> None:
|
||||
game = _owned_game(["2"] * OWNED_CHECK_ATTEMPTS + ["3"])
|
||||
assert buy_upgrade(game, StoreItem(3, 15, True)) is False
|
||||
|
||||
|
||||
def test_buy_upgrade_ignores_the_cookie_count() -> None:
|
||||
game = FakeGame(
|
||||
texts={f"{PRODUCT_OWNED_ID_PREFIX}3": "", COOKIE_COUNTER_ID: "100 cookies"}
|
||||
)
|
||||
game.on_click = lambda _id: game.texts.update({COOKIE_COUNTER_ID: "90 cookies"})
|
||||
assert buy_upgrade(game, StoreItem(3, 15, True)) is False
|
||||
|
||||
|
||||
def test_buy_upgrade_propagates_an_unreadable_owned_amount() -> None:
|
||||
game = FakeGame(texts={f"{PRODUCT_OWNED_ID_PREFIX}3": "???"})
|
||||
with pytest.raises(UnreadableNumberError):
|
||||
buy_upgrade(game, StoreItem(3, 15, True))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "expected"),
|
||||
[
|
||||
("per second: 0.1", Decimal("0.1")),
|
||||
("per second: 1,234.5", Decimal("1234.5")),
|
||||
("per second: 1.234 million", Decimal("1234000")),
|
||||
("per second: 0", Decimal(0)),
|
||||
],
|
||||
)
|
||||
def test_get_cps_reads_the_amount_after_the_colon(text: str, expected: Decimal) -> None:
|
||||
assert get_cps(FakeGame(texts={CPS_ID: text})) == expected
|
||||
|
||||
|
||||
def test_get_cps_rejects_a_line_without_amount() -> None:
|
||||
with pytest.raises(UnreadableNumberError):
|
||||
get_cps(FakeGame(texts={CPS_ID: "per second"}))
|
||||
@@ -0,0 +1,48 @@
|
||||
"""@file
|
||||
@brief Tests of the purchase strategy choose_upgrade.
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
|
||||
import pytest
|
||||
|
||||
from cookie_clicker_bot.domain.store_item import StoreItem, choose_upgrade
|
||||
|
||||
|
||||
def test_choose_upgrade_picks_the_most_expensive_affordable_item() -> None:
|
||||
items = [StoreItem(0, 15, True), StoreItem(1, 100, True), StoreItem(2, 1100, False)]
|
||||
assert choose_upgrade(items) == StoreItem(1, 100, True)
|
||||
|
||||
|
||||
def test_choose_upgrade_returns_none_when_nothing_is_affordable() -> None:
|
||||
assert choose_upgrade([StoreItem(0, 15, False), StoreItem(1, 100, False)]) is None
|
||||
|
||||
|
||||
def test_choose_upgrade_returns_none_for_an_empty_store() -> None:
|
||||
assert choose_upgrade([]) is None
|
||||
|
||||
|
||||
def test_choose_upgrade_returns_the_only_item_when_it_is_affordable() -> None:
|
||||
item = StoreItem(0, 15, True)
|
||||
assert choose_upgrade([item]) == item
|
||||
|
||||
|
||||
def test_choose_upgrade_prefers_the_highest_index_on_equal_price() -> None:
|
||||
items = [StoreItem(3, 50, True), StoreItem(7, 50, True), StoreItem(5, 50, True)]
|
||||
assert choose_upgrade(items) == StoreItem(7, 50, True)
|
||||
|
||||
|
||||
def test_choose_upgrade_trusts_the_affordable_flag_at_price_boundary() -> None:
|
||||
items = [StoreItem(0, 15, True), StoreItem(1, 16, False)]
|
||||
assert choose_upgrade(items) == StoreItem(0, 15, True)
|
||||
|
||||
|
||||
def test_choose_upgrade_ignores_the_order_of_the_items() -> None:
|
||||
items = [StoreItem(2, 500, True), StoreItem(0, 15, True), StoreItem(1, 100, True)]
|
||||
assert choose_upgrade(items) == StoreItem(2, 500, True)
|
||||
|
||||
|
||||
def test_store_item_is_immutable() -> None:
|
||||
item = StoreItem(0, 15, True)
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
item.price = 1 # type: ignore[misc]
|
||||
Reference in New Issue
Block a user