Compare commits

...
Author SHA1 Message Date
Tirsvad 8865608459 Merge pull request 'MIL-007: release, plan links and final checks' (#43) from mil-007-release into main
TirSystem/git-workflows: Sync GitHub mirror metadata / sync-metadata (push) Successful in 7s
2026-10-28 11:11:02 +01:00
Tirsvad 2b654dbb4d Close the release: link the milestones in the plan, final checks
Project Plan gateway schedule links the synced milestones and the open issues
are resolved. The wiki was published to the git host after the merge of the
documentation, and a clean clone installed, tested and ran the help command.

Closes #34
Closes #35
2026-10-28 18:10:53 +08:00
Tirsvad edb97de543 Merge pull request 'MIL-007: README, Doxygen and wiki in three languages' (#42) from mil-007-documentation into main
TirSystem/git-workflows: Publish wiki / publish-wiki (push) Successful in 28s
TirSystem/git-workflows: Sync GitHub mirror metadata / sync-metadata (push) Successful in 8s
2026-10-28 11:07:38 +01:00
Tirsvad f2b81f44c5 Add README screenshots and the wiki in English, Danish and Bahasa Malaysia
README with the five lecture step screenshots and the option table, Doxygen
builds without warnings, wiki pages for the home page and the five steps in
three languages (screenshots and what each step taught), sidebar and footer.

Closes #29
Closes #30
Closes #31
Closes #32
Closes #33
2026-10-28 18:07:30 +08:00
Tirsvad 1bfa8f6c4d Merge pull request 'MIL-006: strategy options and comparison' (#41) from mil-006-strategy into main
TirSystem/git-workflows: Publish wiki / publish-wiki (push) Successful in 9s
TirSystem/git-workflows: Sync GitHub mirror metadata / sync-metadata (push) Successful in 6s
2026-10-28 11:04:10 +01:00
Tirsvad 7c2f6002fa Resolve pending commit link for RC-018 2026-10-28 18:03:58 +08:00
Tirsvad afcb7d35a8 Add strategy options: check interval, buy all affordable
BuyStrategy with a pure buy-all plan, validated --interval, --buy-all,
a five attempt retry for reads that go stale (found in a real run), the
comparison of two batches of 300 s container runs and the screenshot of
lecture step 5. Code review RC-018.

Closes #26
Closes #27
Closes #28

Task: MIL-006#1
Task: MIL-006#2
Task: MIL-006#3
2026-10-28 18:03:55 +08:00
Tirsvad 9764da1def Merge pull request 'MIL-005: five minute run and cookies per second' (#40) from mil-005-timed-run into main
TirSystem/git-workflows: Publish wiki / publish-wiki (push) Successful in 7s
TirSystem/git-workflows: Sync GitHub mirror metadata / sync-metadata (push) Successful in 6s
2026-10-28 10:48:37 +01:00
Tirsvad 3f611ab584 Resolve pending commit link for RC-017 2026-10-28 17:48:28 +08:00
Tirsvad e3155de9b5 Add timed run: cookies per second after five minutes
Result parsing and formatting, one stdout result line, elapsed time, handling
of stale elements and of clicks blocked by the cookie banner (found in real
container runs), the baseline result and the screenshot of lecture step 4.
Code review RC-017.

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

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

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

Task: MIL-004#1
Task: MIL-004#2
Task: MIL-004#3
Task: MIL-004#4
2026-10-28 17:28:09 +08:00
Tirsvad e28096100b Merge pull request 'MIL-003: open the game and click the cookie' (#38) from mil-003-click-bot into main
TirSystem/git-workflows: Sync GitHub mirror metadata / sync-metadata (push) Successful in 6s
2026-10-28 10:22:31 +01:00
Tirsvad 860421493d Resolve pending commit link for RC-015 2026-10-28 17:22:21 +08:00
Tirsvad 4de05013ff Add click bot: open the game, dismiss the language, click the cookie
Game port, game loop, Selenium adapter and Chromium factory with fake-driver
tests, command-line options for duration, headless mode and screenshots, and
the screenshots of lecture steps 1 and 2 taken in the Docker container.
Code review RC-015.

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

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

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

Task: MIL-002#1
Task: MIL-002#2
Task: MIL-002#3
Task: MIL-002#4
Task: MIL-002#5
Task: MIL-002#6
2026-10-28 17:15:31 +08:00
Tirsvad 0c33168a76 Merge pull request 'MIL-001: inception baseline' (#36) from g1-inception-baseline into main
TirSystem/git-workflows: Sync GitHub mirror metadata / sync-metadata (push) Successful in 10s
2026-10-28 10:09:49 +01:00
61 changed files with 3388 additions and 197 deletions
+10
View File
@@ -0,0 +1,10 @@
.git
.venv
.env
__pycache__
*.pyc
.pytest_cache
docs/doxygen
framework
.claude
.agents
+37
View File
@@ -3,3 +3,40 @@
# AI prompt log (the repository is public)
/docs/ai-prompts/
# Python
__pycache__/
*.py[cod]
*.egg-info/
build/
dist/
.eggs/
# Virtual environments
.venv/
venv/
env/
# Tests and coverage
.pytest_cache/
.coverage
.coverage.*
htmlcov/
coverage.xml
# Type checker and linter caches
.mypy_cache/
.ruff_cache/
# Doxygen output
/docs/doxygen/
# Browser run artefacts (the lecture screenshots in docs/images are kept)
/output/
*.log
# Editors and operating system
.idea/
.vscode/
.DS_Store
Thumbs.db
+20
View File
@@ -0,0 +1,20 @@
# Headless Chromium for real-browser runs and screenshots (ADR-0003).
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
CHROME_BIN=/usr/bin/chromium \
CHROMEDRIVER_PATH=/usr/bin/chromedriver
RUN apt-get update \
&& apt-get install -y --no-install-recommends chromium chromium-driver fonts-liberation \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY pyproject.toml README.md LICENSE ./
COPY src ./src
RUN python -m pip install --upgrade pip && python -m pip install -e ".[dev]"
COPY tests ./tests
ENTRYPOINT ["python", "-m", "cookie_clicker_bot"]
CMD ["--headless"]
+16
View File
@@ -0,0 +1,16 @@
# Doxygen configuration for the Cookie Clicker bot. Run: doxygen Doxyfile
PROJECT_NAME = "Cookie Clicker Bot"
PROJECT_BRIEF = "Selenium bot that plays Cookie Clicker (Udemy 100 Days of Code, day 48)"
OUTPUT_DIRECTORY = docs/doxygen
INPUT = src
FILE_PATTERNS = *.py
RECURSIVE = YES
EXTRACT_ALL = YES
OPTIMIZE_OUTPUT_JAVA = YES
PYTHON_DOCSTRING = YES
AUTOLINK_SUPPORT = YES
GENERATE_HTML = YES
GENERATE_LATEX = NO
QUIET = YES
WARN_IF_UNDOCUMENTED = YES
WARN_AS_ERROR = NO
+173 -1
View File
@@ -1,2 +1,174 @@
# 048-the-coockie-factory-new
# 🍪 Cookie Clicker Bot
A Selenium bot that plays [Cookie Clicker][game] for you: it clicks the big cookie, buys the most expensive upgrade it can afford every 5 seconds and prints the cookies per second after 5 minutes. It is the solution to day 48 of Udemy's *100 Days of Code: The Complete Python Pro Bootcamp*, written for course participants who want to compare solutions and for visitors who browse for ideas.
## 📚 Table of Contents
- [Overview](#-overview)
- [Requirements](#-requirements)
- [Setup](#-setup)
- [Run](#-run)
- [Tests](#-tests)
- [License](#-license)
- [Links](#-links)
- [Appendix: Project documents](#-appendix-project-documents)
## 🧭 Overview
The bot follows the five steps of the lecture:
1. Get to know the game.
2. Click the cookie as fast as possible, after dismissing the language selection.
3. Every 5 seconds buy the most expensive upgrade you can afford.
4. After 5 minutes stop and print `cookies/second`.
5. Tweak the strategy (check interval, buy one or buy all affordable).
### The five steps in pictures
| Step | Screenshot |
| --- | --- |
| 1. Get to know the game | ![Step 1: the game after the language was chosen](docs/images/step-1.png) |
| 2. Click the cookie | ![Step 2: after 15 seconds of clicking](docs/images/step-2.png) |
| 3. Buy the most expensive affordable upgrade | ![Step 3: after 45 seconds of clicking and buying](docs/images/step-3.png) |
| 4. Stop after 5 minutes, print cookies/second | ![Step 4: after the 5 minute run, 18.9 per second](docs/images/step-4.png) |
| 5. Tweak the strategy | ![Step 5: the best run, checking every 10 seconds](docs/images/step-5.png) |
What each step taught is explained in the [wiki][documentation], in English, Danish and Bahasa Malaysia (the same pages are in `docs/wiki/`).
The repository is organised like this:
| Folder or file | What it is for |
| --- | --- |
| `src/cookie_clicker_bot/` | The bot. `domain/` holds the rules (parsing, choosing the upgrade) with no Selenium, `application/` runs the game loop, `infrastructure/` drives the browser, `constants.py` holds every constant |
| `tests/` | `pytest` tests; the unit tests need no browser |
| `docs/` | Project documents, wiki pages (English, Danish, Bahasa Malaysia) and the screenshots of the lecture steps |
| `Dockerfile` | A Linux image with headless Chromium for real-browser runs and screenshots |
| `Doxyfile` | Doxygen configuration for the source comments |
## 📋 Requirements
- Python 3.13 or newer.
- Selenium (the only runtime dependency; it is installed by `pip` and finds the browser driver itself).
- Google Chrome or Chromium to run the bot on your own machine.
- `pytest` and `pytest-cov` for the tests (development extra only).
- Docker, only if you want to run the bot or take screenshots without opening a window on your desktop.
- Doxygen, only to build the source documentation.
## 🛠️ Setup
Create a local virtual environment, upgrade `pip` and install the project with the test tools:
```bash
python -m venv .venv
```
Activate it:
```bash
# Linux and macOS
source .venv/bin/activate
```
```powershell
# Windows PowerShell
.venv\Scripts\Activate.ps1
```
```bash
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```
Leave the environment with `deactivate`. The folder `.venv` is ignored by git.
## ▶️ Run
With the environment active:
```bash
cookie-clicker-bot
```
or, without installing the command:
```bash
python -m cookie_clicker_bot
```
The bot plays for 5 minutes, then prints one line on standard output, for example `cookies/second: 18.9`. What it did (clicks, purchases, time) is logged on standard error. Options:
| Option | What it does | Default |
| --- | --- | --- |
| `--duration SECONDS` | How long to play | 300 |
| `--interval SECONDS` | Time between upgrade checks (greater than 0) | 5 |
| `--buy-all` | Buy every affordable upgrade, most expensive first, instead of one | off |
| `--headless` | Run Chromium without a window | off (on in Docker) |
| `--screenshot PATH` | Save a screenshot when the run ends | none |
| `--screenshot-start PATH` | Save a screenshot before the first click | none |
To keep your desktop free, run it in the Docker container (headless Chromium). Options go after the image name:
```bash
docker build -t cookie-clicker-bot .
docker run --rm cookie-clicker-bot
docker run --rm cookie-clicker-bot --headless --interval 10
```
## 🧪 Tests
```bash
python -m pytest
```
Coverage of the logic modules is checked with `python -m pytest --cov`. Tests that need a real browser are marked `browser` and run in the Docker container; all other tests need neither a browser nor the network.
Build the source documentation with Doxygen:
```bash
doxygen Doxyfile
```
## 📄 License
GNU Affero General Public License v3.0, see [LICENSE][license].
## 🔗 Links
- [Repository][repository]
- [Documentation][documentation]
- [Issue tracker][issues]
## 📎 Appendix: Project documents
The documents a newcomer needs, in reading order.
| Document | What it tells you |
| --- | --- |
| [AGENTS.md][agents] | The rules AI agents follow in this repository |
| [Business Case][BC-001] | Why the project exists, its scope and success criteria |
| [Stakeholder Analysis][SA-001] | Who is involved; the stakeholder IDs used for owners and reviewers |
| [Project Plan][PP-001] | The gateways and their schedule |
| [Milestones][milestones] | One document per gateway: Go/No-Go criteria and tasks |
| [Artifact registry][registry] | Where each artifact type lives, the next version, the PO language and domain |
| [Domain dictionary][DICT-001] | The PO terms and the IT terms |
| [Traceability matrix][TM-001] | How the artifacts link and when each was last reviewed |
| [Framework README][framework-readme] | The framework this project uses |
| [Framework changelog][framework-changelog] | What changed in each framework release |
---
[game]: https://ozh.github.io/cookieclicker/
[license]: ./LICENSE
[repository]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new
[documentation]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/wiki
[issues]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/issues
[agents]: ./AGENTS.md
[BC-001]: ./docs/business-case.md
[SA-001]: ./docs/stakeholder-analysis.md
[PP-001]: ./docs/project-plan.md
[milestones]: ./docs/milestones/
[registry]: ./docs/artifact-registry.md
[DICT-001]: ./docs/dictionary.md
[TM-001]: ./docs/sqa/traceability-matrix.md
[framework-readme]: ./framework/README.md
[framework-changelog]: ./framework/CHANGELOG.md
+1 -1
View File
@@ -16,7 +16,7 @@ document of a type. `Primary File` may contain a glob (e.g.
| PP | Project Plan | docs/project-plan.md | 002 |
| MIL | Milestone / Gateway | docs/milestones/*.md | 008 |
| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 014 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 019 |
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
| DICT | Domain Dictionary (PO and IT terms) | docs/dictionary.md | 002 |
Binary file not shown.

After

Width:  |  Height:  |  Size: 504 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+113 -108
View File
@@ -1,108 +1,113 @@
# Project Plan: Cookie Clicker Bot
## Metadata
| Key | Value |
| --- | --- |
| ID | PP-001 |
| CrossReference | [BC-001], [SA-001] |
| Language | en |
| Domain | it |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | S01 | Initial version: seven gateways; accepted by S01 in chat | [cadc5ef] |
---
## Purpose
This plan schedules seven gateways (`MIL-001` to `MIL-007`) that take the Cookie Clicker bot from an accepted baseline to a documented, tested and published repository. The Business Case sets no deadline, so the window 2026-10-28 to 2026-11-04 is a planning assumption of this plan. Each gateway ends in a decision and a deliverable and is delivered by one pull request on its own branch.
## Planning Assumptions
- Week 1 starts 2026-10-28; the plan ends by 2026-11-04 (a planning assumption, see Open Issues).
- Phase length: one day per gateway, except the baseline and documentation, which are longer. Gateways 003 to 006 follow the Udemy lecture steps 1 to 5.
- One branch and one pull request per gateway, each closing the issues of its tasks.
- The user stories and use case steps of the journey are skipped for this project (ADR-0001), so no gateway lists stories.
- Autonomous mode: S01 accepts this plan in chat; the AI acts as stand-in reviewer and records it in each `RC-*`.
## Gateway Schedule
| Gateway | Document | Window | Decision date | Owner | Stories | Main deliverable | Milestone |
| --- | --- | --- | --- | --- | --- | --- | --- |
| Baseline and repository set-up | [MIL-001] | 2026-10-28 | 2026-10-28 | S01 | none (documents) | Accepted baseline, ADRs, repository description and topics | |
| Project scaffold and tooling | [MIL-002] | 2026-10-28 to 2026-10-29 | 2026-10-29 | S01 | none (technical tasks) | `pyproject.toml`, layout, `.gitignore`, Doxyfile, README, Docker image | |
| Lecture steps 1 and 2 | [MIL-003] | 2026-10-29 to 2026-10-30 | 2026-10-30 | S01 | none (technical tasks) | Open game, dismiss language, click cookie, screenshots 1 and 2 | |
| Lecture step 3 | [MIL-004] | 2026-10-30 to 2026-10-31 | 2026-10-31 | S01 | none (technical tasks) | Parsing, selection rule, purchase every 5 s, screenshot 3 | |
| Lecture step 4 | [MIL-005] | 2026-10-31 to 2026-11-01 | 2026-11-01 | S01 | none (technical tasks) | 5 minute run, cookies per second, screenshot 4 | |
| Lecture step 5 | [MIL-006] | 2026-11-01 to 2026-11-02 | 2026-11-02 | S01 | none (technical tasks) | Strategy options, comparison, screenshot 5 | |
| Documentation, wiki and release | [MIL-007] | 2026-11-02 to 2026-11-04 | 2026-11-04 | S01 | none (documentation) | README, Doxygen, wiki in en, da and ms, final checks | |
```plantuml
@startgantt
Project starts 2026-10-28
[Baseline] starts 2026-10-28 and ends 2026-10-28
[Scaffold] starts 2026-10-28 and ends 2026-10-29
[Steps 1 and 2] starts 2026-10-29 and ends 2026-10-30
[Step 3] starts 2026-10-30 and ends 2026-10-31
[Step 4] starts 2026-10-31 and ends 2026-11-01
[Step 5] starts 2026-11-01 and ends 2026-11-02
[Documentation] starts 2026-11-02 and ends 2026-11-04
[Baseline Go/No-Go] happens 2026-10-28
[Scaffold Go/No-Go] happens 2026-10-29
[Steps 1 and 2 Go/No-Go] happens 2026-10-30
[Step 3 Go/No-Go] happens 2026-10-31
[Step 4 Go/No-Go] happens 2026-11-01
[Step 5 Go/No-Go] happens 2026-11-02
[Release Go/No-Go] happens 2026-11-04
@endgantt
```
## Scope Coverage
| Business Case scope item | Gateway |
| --- | --- |
| Selenium bot for the game, with the assignment's function names | MIL-003, MIL-004, MIL-005 |
| Lecture steps 1 to 5 | MIL-003 (1 and 2), MIL-004 (3), MIL-005 (4), MIL-006 (5) |
| Python project files: `src/`, `tests/`, `docs/`, `pyproject.toml`, `constants.py`, `.gitignore`, Doxyfile, README | MIL-002, MIL-007 |
| Unit tests with `pytest` | MIL-002 to MIL-006 |
| One screenshot per lecture step in README and wiki | MIL-003 to MIL-006 (capture), MIL-007 (use) |
| Wiki in en, da and ms | MIL-007 |
| Repository description and topics | MIL-001 |
## Dependencies
```
MIL-001 → MIL-002 → MIL-003 → MIL-004 → MIL-005 → MIL-006 → MIL-007
```
A No-Go on a gateway stops all later gateways until it is fixed and re-reviewed. The end date then moves by the days spent on the rework; S01 decides a move of the end date.
## Plan Risks
| Risk | Impact | Mitigation |
| --- | --- | --- |
| The game changes its page structure (Business Case risk: page changes) | Steps 1 to 4 need rework | Selectors only in `constants.py`; the first task records the page as seen |
| The headless container cannot run the game well | Screenshots and the 5 minute run are late | Try the container in MIL-002; pin Chromium; fall back to a shorter duration constant for tests only |
| Stand-in review misses a defect | Defect reaches `main` | `RC-*` marks the review as not independent; S01 reviews the final summary |
| Translations take longer than planned | MIL-007 slips | Write English first; da and ms follow page by page; no extra pages |
## Open Issues
- Target repository: the user names `https://git.tirsystem.com/Tirsvad-Udemy-100_days_of_code`, but `origin` is `Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new`. The plan uses `origin` until S01 states otherwise in chat.
- The Business Case sets no deadline; the window 2026-10-28 to 2026-11-04 is this plan's own assumption.
- No QC checklist exists for the Project Plan acceptance beyond `QC-PP-001`; S01's acceptance of the plan in chat accepts the row.
---
[BC-001]: ./business-case.md
[SA-001]: ./stakeholder-analysis.md
[MIL-001]: ./milestones/mil-001-baseline.md
[MIL-002]: ./milestones/mil-002-scaffold.md
[MIL-003]: ./milestones/mil-003-click-bot.md
[MIL-004]: ./milestones/mil-004-buy-upgrades.md
[MIL-005]: ./milestones/mil-005-timed-run.md
[MIL-006]: ./milestones/mil-006-strategy.md
[MIL-007]: ./milestones/mil-007-documentation.md
[cadc5ef]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/commit/cadc5eff21d9afc09799d4bc2e3f6ac17ab2612a
# Project Plan: Cookie Clicker Bot
## Metadata
| Key | Value |
| --- | --- |
| ID | PP-001 |
| CrossReference | [BC-001], [SA-001] |
| Language | en |
| Domain | it |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | S01 | Initial version: seven gateways; accepted by S01 in chat | [cadc5ef] |
---
## Purpose
This plan schedules seven gateways (`MIL-001` to `MIL-007`) that take the Cookie Clicker bot from an accepted baseline to a documented, tested and published repository. The Business Case sets no deadline, so the window 2026-10-28 to 2026-11-04 is a planning assumption of this plan. Each gateway ends in a decision and a deliverable and is delivered by one pull request on its own branch.
## Planning Assumptions
- Week 1 starts 2026-10-28; the plan ends by 2026-11-04 (a planning assumption, see Open Issues).
- Phase length: one day per gateway, except the baseline and documentation, which are longer. Gateways 003 to 006 follow the Udemy lecture steps 1 to 5.
- One branch and one pull request per gateway, each closing the issues of its tasks.
- The user stories and use case steps of the journey are skipped for this project (ADR-0001), so no gateway lists stories.
- Autonomous mode: S01 accepts this plan in chat; the AI acts as stand-in reviewer and records it in each `RC-*`.
## Gateway Schedule
| Gateway | Document | Window | Decision date | Owner | Stories | Main deliverable | Milestone |
| --- | --- | --- | --- | --- | --- | --- | --- |
| Baseline and repository set-up | [MIL-001] | 2026-10-28 | 2026-10-28 | S01 | none (documents) | Accepted baseline, ADRs, repository description and topics | [Milestone 1] |
| Project scaffold and tooling | [MIL-002] | 2026-10-28 to 2026-10-29 | 2026-10-29 | S01 | none (technical tasks) | `pyproject.toml`, layout, `.gitignore`, Doxyfile, README, Docker image | [Milestone 2] |
| Lecture steps 1 and 2 | [MIL-003] | 2026-10-29 to 2026-10-30 | 2026-10-30 | S01 | none (technical tasks) | Open game, dismiss language, click cookie, screenshots 1 and 2 | [Milestone 3] |
| Lecture step 3 | [MIL-004] | 2026-10-30 to 2026-10-31 | 2026-10-31 | S01 | none (technical tasks) | Parsing, selection rule, purchase every 5 s, screenshot 3 | [Milestone 4] |
| Lecture step 4 | [MIL-005] | 2026-10-31 to 2026-11-01 | 2026-11-01 | S01 | none (technical tasks) | 5 minute run, cookies per second, screenshot 4 | [Milestone 5] |
| Lecture step 5 | [MIL-006] | 2026-11-01 to 2026-11-02 | 2026-11-02 | S01 | none (technical tasks) | Strategy options, comparison, screenshot 5 | [Milestone 6] |
| Documentation, wiki and release | [MIL-007] | 2026-11-02 to 2026-11-04 | 2026-11-04 | S01 | none (documentation) | README, Doxygen, wiki in en, da and ms, final checks | [Milestone 7] |
```plantuml
@startgantt
Project starts 2026-10-28
[Baseline] starts 2026-10-28 and ends 2026-10-28
[Scaffold] starts 2026-10-28 and ends 2026-10-29
[Steps 1 and 2] starts 2026-10-29 and ends 2026-10-30
[Step 3] starts 2026-10-30 and ends 2026-10-31
[Step 4] starts 2026-10-31 and ends 2026-11-01
[Step 5] starts 2026-11-01 and ends 2026-11-02
[Documentation] starts 2026-11-02 and ends 2026-11-04
[Baseline Go/No-Go] happens 2026-10-28
[Scaffold Go/No-Go] happens 2026-10-29
[Steps 1 and 2 Go/No-Go] happens 2026-10-30
[Step 3 Go/No-Go] happens 2026-10-31
[Step 4 Go/No-Go] happens 2026-11-01
[Step 5 Go/No-Go] happens 2026-11-02
[Release Go/No-Go] happens 2026-11-04
@endgantt
```
## Scope Coverage
| Business Case scope item | Gateway |
| --- | --- |
| Selenium bot for the game, with the assignment's function names | MIL-003, MIL-004, MIL-005 |
| Lecture steps 1 to 5 | MIL-003 (1 and 2), MIL-004 (3), MIL-005 (4), MIL-006 (5) |
| Python project files: `src/`, `tests/`, `docs/`, `pyproject.toml`, `constants.py`, `.gitignore`, Doxyfile, README | MIL-002, MIL-007 |
| Unit tests with `pytest` | MIL-002 to MIL-006 |
| One screenshot per lecture step in README and wiki | MIL-003 to MIL-006 (capture), MIL-007 (use) |
| Wiki in en, da and ms | MIL-007 |
| Repository description and topics | MIL-001 |
## Dependencies
```
MIL-001 → MIL-002 → MIL-003 → MIL-004 → MIL-005 → MIL-006 → MIL-007
```
A No-Go on a gateway stops all later gateways until it is fixed and re-reviewed. The end date then moves by the days spent on the rework; S01 decides a move of the end date.
## Plan Risks
| Risk | Impact | Mitigation |
| --- | --- | --- |
| The game changes its page structure (Business Case risk: page changes) | Steps 1 to 4 need rework | Selectors only in `constants.py`; the first task records the page as seen |
| The headless container cannot run the game well | Screenshots and the 5 minute run are late | Try the container in MIL-002; pin Chromium; fall back to a shorter duration constant for tests only |
| Stand-in review misses a defect | Defect reaches `main` | `RC-*` marks the review as not independent; S01 reviews the final summary |
| Translations take longer than planned | MIL-007 slips | Write English first; da and ms follow page by page; no extra pages |
## Open Issues
- Resolved: S01 accepted the plan in chat on 2026-10-28; the target repository is `origin`, as S01 confirmed.
---
[BC-001]: ./business-case.md
[SA-001]: ./stakeholder-analysis.md
[MIL-001]: ./milestones/mil-001-baseline.md
[MIL-002]: ./milestones/mil-002-scaffold.md
[MIL-003]: ./milestones/mil-003-click-bot.md
[MIL-004]: ./milestones/mil-004-buy-upgrades.md
[MIL-005]: ./milestones/mil-005-timed-run.md
[MIL-006]: ./milestones/mil-006-strategy.md
[MIL-007]: ./milestones/mil-007-documentation.md
[cadc5ef]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/commit/cadc5eff21d9afc09799d4bc2e3f6ac17ab2612a
[Milestone 1]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/milestone/290
[Milestone 2]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/milestone/291
[Milestone 3]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/milestone/292
[Milestone 4]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/milestone/293
[Milestone 5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/milestone/294
[Milestone 6]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/milestone/295
[Milestone 7]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/milestone/296
+62
View File
@@ -0,0 +1,62 @@
# SQA Review Record
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-014 |
| CrossReference | [MIL-002], [QC-PY-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Code review record | [c4a51c3] |
---
## Artifact Under Review
- Instance reviewed: [MIL-002]
- Checklist used: [QC-PY-001] (`QC-PY-001`)
- Scope: full review of the source and tests added by MIL-002: package skeleton, constants.py, cli.py, smoke test
- Language and domain: n/a
- Language reviewer: none
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S02`)
## Checklist Results
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | ruff (N rules) passes; names follow PEP 8 casing. |
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names use the terms of DICT-001 (cookie, upgrade, bot); no single-letter names outside comprehensions. |
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff format` and `ruff check` pass with the rules in `pyproject.toml`; no suppressions. |
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | `mypy --strict` passes; every signature is annotated. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No bare `except`; ruff B and S rules pass. |
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults; ruff B006 and A rules pass. |
| 7 | Files, locks and connections are managed with context managers | N-A | No file or connection is opened in this milestone. |
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Public modules, classes and functions have Doxygen docstrings with `@brief`. |
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | `cli.py` prints a placeholder line only; the result line becomes the program output in MIL-005. |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | No Design Class Diagram exists (ADR-0001 skips use case design); ADR-0002 records the layout instead. |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | Tests are named for behaviour, independent of order, and need no network or browser. |
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy --strict` passes; no `Any`. |
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `pyproject.toml` lists Selenium as the only runtime dependency; dev extras are test and lint tools. |
| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | Constants live only in `constants.py`; rules are defined once. |
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `domain` imports no Selenium; `application` depends on a port; no import cycles. |
| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | Modules have one reason to change; the game is behind a narrow port. |
| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | Layers `domain`, `application`, `infrastructure` under `src/`, `unit` and `integration` under `tests/`; no `presentation` package by ADR-0002. |
| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | English names, snake_case files, no spaces. |
## Overall Verdict
Go — the code of MIL-002 meets every Mandatory criterion; `ruff`, `mypy --strict` and `pytest` were run and pass. The review is not independent: the AI wrote and reviewed it as stand-in for S02, and S01 can challenge it in the end-of-run summary.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| None | S01 | - |
---
[MIL-002]: ../../milestones/mil-002-scaffold.md
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[c4a51c3]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/commit/c4a51c37de39f3997e092caceb710762f70d4314
+62
View File
@@ -0,0 +1,62 @@
# SQA Review Record
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-015 |
| CrossReference | [MIL-003], [QC-PY-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Code review record | [4de0501] |
---
## Artifact Under Review
- Instance reviewed: [MIL-003]
- Checklist used: [QC-PY-001] (`QC-PY-001`)
- Scope: full review of the source and tests added by MIL-003: constants, game port, game loop, Selenium adapter, browser factory, cli and 20 new tests (written with the python-developer agent); a 15 s container run clicked 344 times and produced step-1.png and step-2.png
- Language and domain: n/a
- Language reviewer: none
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S02`)
## Checklist Results
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | ruff (N rules) passes; names follow PEP 8 casing. |
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names use the terms of DICT-001 (cookie, upgrade, bot); no single-letter names outside comprehensions. |
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff format` and `ruff check` pass with the rules in `pyproject.toml`; no suppressions. |
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | `mypy --strict` passes; every signature is annotated. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No bare `except`; ruff B and S rules pass. |
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults; ruff B006 and A rules pass. |
| 7 | Files, locks and connections are managed with context managers | Pass | The browser is closed in a `finally` block in `main`; the screenshot folder is created with `Path.mkdir`. |
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Public modules, classes and functions have Doxygen docstrings with `@brief`. |
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | Diagnostics use `logging`; the required result line `cookies/second: N` is the program's output, not a log; no secrets are read or logged. |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | No Design Class Diagram exists (ADR-0001 skips use case design); ADR-0002 records the layout instead. |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 23 tests pass; fakes replace the browser; 100% coverage of the logic modules. |
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy --strict` passes; no `Any`. |
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `pyproject.toml` lists Selenium as the only runtime dependency; dev extras are test and lint tools. |
| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | Constants live only in `constants.py`; rules are defined once. |
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `domain` imports no Selenium; `application` depends on a port; no import cycles. |
| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | Modules have one reason to change; the game is behind a narrow port. |
| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | Layers `domain`, `application`, `infrastructure` under `src/`, `unit` and `integration` under `tests/`; no `presentation` package by ADR-0002. |
| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | English names, snake_case files, no spaces. |
## Overall Verdict
Go — the code of MIL-003 meets every Mandatory criterion; `ruff`, `mypy --strict` and `pytest` were run and pass. The review is not independent: the AI wrote and reviewed it as stand-in for S02, and S01 can challenge it in the end-of-run summary.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| None | S01 | - |
---
[MIL-003]: ../../milestones/mil-003-click-bot.md
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[4de0501]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/commit/4de05013ff6b204587ce694e40b757c9665cd7fd
+62
View File
@@ -0,0 +1,62 @@
# SQA Review Record
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-016 |
| CrossReference | [MIL-004], [QC-PY-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Code review record | [97215c4] |
---
## Artifact Under Review
- Instance reviewed: [MIL-004]
- Checklist used: [QC-PY-001] (`QC-PY-001`)
- Scope: full review of the source and tests added by MIL-004: domain parsing and upgrade rule, port and adapter extensions, click_and_buy loop, cli --interval and 43 new tests (written with the python-developer agent); a 45 s container run bought 8 upgrades and produced step-3.png
- Language and domain: n/a
- Language reviewer: none
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S02`)
## Checklist Results
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | ruff (N rules) passes; names follow PEP 8 casing. |
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names use the terms of DICT-001 (cookie, upgrade, bot); no single-letter names outside comprehensions. |
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff format` and `ruff check` pass with the rules in `pyproject.toml`; no suppressions. |
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | `mypy --strict` passes; every signature is annotated. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No bare `except`; ruff B and S rules pass. |
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults; ruff B006 and A rules pass. |
| 7 | Files, locks and connections are managed with context managers | Pass | The browser is closed in a `finally` block in `main`. |
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Public modules, classes and functions have Doxygen docstrings with `@brief`. |
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | Diagnostics use `logging`; the required result line `cookies/second: N` is the program's output, not a log; no secrets are read or logged. |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | No Design Class Diagram exists (ADR-0001 skips use case design); ADR-0002 records the layout instead. |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 66 tests pass with fakes; 100% coverage of the logic modules; the 105-cookie Grandma versus Cursor case is tested. |
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy --strict` passes; no `Any`. |
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `pyproject.toml` lists Selenium as the only runtime dependency; dev extras are test and lint tools. |
| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | Constants live only in `constants.py`; rules are defined once. |
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `domain` imports no Selenium; `application` depends on a port; no import cycles. |
| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | Modules have one reason to change; the game is behind a narrow port. |
| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | Layers `domain`, `application`, `infrastructure` under `src/`, `unit` and `integration` under `tests/`; no `presentation` package by ADR-0002. |
| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | English names, snake_case files, no spaces. |
## Overall Verdict
Go — the code of MIL-004 meets every Mandatory criterion; `ruff`, `mypy --strict` and `pytest` were run and pass. The review is not independent: the AI wrote and reviewed it as stand-in for S02, and S01 can challenge it in the end-of-run summary.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| None | S01 | - |
---
[MIL-004]: ../../milestones/mil-004-buy-upgrades.md
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[97215c4]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/commit/97215c4137ac798f63600abba947968671ad3957
+62
View File
@@ -0,0 +1,62 @@
# SQA Review Record
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-017 |
| CrossReference | [MIL-005], [QC-PY-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Code review record | [e3155de] |
---
## Artifact Under Review
- Instance reviewed: [MIL-005]
- Checklist used: [QC-PY-001] (`QC-PY-001`)
- Scope: full review of the source and tests added by MIL-005: cookies per second parsing, result formatter, elapsed time, stale-element handling, JavaScript fallback for blocked product clicks and 27 new tests (written with the python-developer agent); a full 300 s container run printed one stdout line, cookies/second: 18.9, and produced step-4.png
- Language and domain: n/a
- Language reviewer: none
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S02`)
## Checklist Results
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | ruff (N rules) passes; names follow PEP 8 casing. |
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names use the terms of DICT-001 (cookie, upgrade, bot); no single-letter names outside comprehensions. |
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff format` and `ruff check` pass with the rules in `pyproject.toml`; no suppressions. |
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | `mypy --strict` passes; every signature is annotated. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No bare `except`; ruff B and S rules pass. |
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults; ruff B006 and A rules pass. |
| 7 | Files, locks and connections are managed with context managers | Pass | The browser is closed in a `finally` block in `main`. |
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Public modules, classes and functions have Doxygen docstrings with `@brief`. |
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | Diagnostics use `logging` (stderr); the required result line is printed to stdout by `main` and is marked as the program output; no secrets are read or logged. |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | No Design Class Diagram exists (ADR-0001 skips use case design); ADR-0002 records the layout instead. |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 93 tests pass with fakes, including regressions for the stale element and the blocked click found in real runs; 100% coverage of the logic modules. |
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy --strict` passes; no `Any`. |
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `pyproject.toml` lists Selenium as the only runtime dependency; dev extras are test and lint tools. |
| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | Constants live only in `constants.py`; rules are defined once. |
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `domain` imports no Selenium; `application` depends on a port; no import cycles. |
| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | Modules have one reason to change; the game is behind a narrow port. |
| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | Layers `domain`, `application`, `infrastructure` under `src/`, `unit` and `integration` under `tests/`; no `presentation` package by ADR-0002. |
| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | English names, snake_case files, no spaces. |
## Overall Verdict
Go — the code of MIL-005 meets every Mandatory criterion; `ruff`, `mypy --strict` and `pytest` were run and pass. The review is not independent: the AI wrote and reviewed it as stand-in for S02, and S01 can challenge it in the end-of-run summary.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| None | S01 | - |
---
[MIL-005]: ../../milestones/mil-005-timed-run.md
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[e3155de]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/commit/e3155de9b58e16bd7be98ef73724fe90305e6627
+62
View File
@@ -0,0 +1,62 @@
# SQA Review Record
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-018 |
| CrossReference | [MIL-006], [QC-PY-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Code review record | [afcb7d3] |
---
## Artifact Under Review
- Instance reviewed: [MIL-006]
- Checklist used: [QC-PY-001] (`QC-PY-001`)
- Scope: full review of the source and tests added by MIL-006: BuyStrategy, buy-all plan, interval validation, --buy-all and --interval options, five attempt stale-read retry and 22 new tests (written with the python-developer agent); two batches of five 300 s container runs produced the comparison table and step-5.png
- Language and domain: n/a
- Language reviewer: none
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S02`)
## Checklist Results
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | ruff (N rules) passes; names follow PEP 8 casing. |
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names use the terms of DICT-001 (cookie, upgrade, bot); no single-letter names outside comprehensions. |
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff format` and `ruff check` pass with the rules in `pyproject.toml`; no suppressions. |
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | `mypy --strict` passes; every signature is annotated. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No bare `except`; ruff B and S rules pass. |
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults; ruff B006 and A rules pass. |
| 7 | Files, locks and connections are managed with context managers | Pass | The browser is closed in a `finally` block in `main`. |
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Public modules, classes and functions have Doxygen docstrings with `@brief`. |
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | Diagnostics use `logging`; the required result line `cookies/second: N` is the program's output, not a log; no secrets are read or logged. |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | No Design Class Diagram exists (ADR-0001 skips use case design); ADR-0002 records the layout instead. |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 116 tests pass with fakes; 100% coverage of the logic modules; a regression test covers the stale read found in a real run. |
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy --strict` passes; no `Any`. |
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `pyproject.toml` lists Selenium as the only runtime dependency; dev extras are test and lint tools. |
| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | Constants live only in `constants.py`; rules are defined once. |
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `domain` imports no Selenium; `application` depends on a port; no import cycles. |
| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | Modules have one reason to change; the game is behind a narrow port. |
| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | Layers `domain`, `application`, `infrastructure` under `src/`, `unit` and `integration` under `tests/`; no `presentation` package by ADR-0002. |
| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | English names, snake_case files, no spaces. |
## Overall Verdict
Go — the code of MIL-006 meets every Mandatory criterion; `ruff`, `mypy --strict` and `pytest` were run and pass. The review is not independent: the AI wrote and reviewed it as stand-in for S02, and S01 can challenge it in the end-of-run summary.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| None | S01 | - |
---
[MIL-006]: ../../milestones/mil-006-strategy.md
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[afcb7d3]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/commit/afcb7d35a8d33f1eabacf6460769b887f326f6cc
+92 -87
View File
@@ -1,87 +1,92 @@
# Traceability Matrix
## Metadata
| Key | Value |
| --- | --- |
| ID | TM-001 |
| CrossReference | [BC-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Initial version: baseline artifacts | [cadc5ef] |
---
## Purpose
Tracks backward/forward links between artifact instances so that the Business Case's
cross-artifact traceability success criterion is measurable. A row is added or
updated whenever an artifact instance is created or reviewed.
## Purpose
Tracks backward and forward links between artifact instances so that the Business Case's traceability can be read in one place. A row is added or updated whenever an artifact is created or reviewed. Rows for code and tests are added by the milestones that create them.
## Traceability Table
| Artifact Instance | Type | Language | Domain | Upstream (Backward Link) | Downstream (Forward Link) | Last Reviewed (RC-ID) |
| --- | --- | --- | --- | --- | --- | --- |
| [BC-001] | Business Case | en | it | - | [SA-001], [PP-001] | RC-001 |
| [SA-001] | Stakeholder Analysis | en | it | [BC-001] | [PP-001] | RC-002 |
| [PP-001] | Project Plan | en | it | [BC-001], [SA-001] | [MIL-001] to [MIL-007] | - (accepted by S01 in chat; no checklist) |
| [DICT-001] | Domain Dictionary | en | it | [BC-001], [SA-001] | - | RC-003 |
| [ADR-0001] | ADR | - | - | [BC-001] | [PP-001] | RC-004 |
| [ADR-0002] | ADR | - | - | [BC-001], [SA-001] | code in MIL-002 onward | RC-005 |
| [ADR-0003] | ADR | - | - | [BC-001] | [MIL-002], [MIL-003] | RC-006 |
| [MIL-001] | Milestone | en | it | [BC-001], [PP-001] | [MIL-002] | RC-007 |
| [MIL-002] | Milestone | en | it | [BC-001], [PP-001] | [MIL-003] | RC-008 |
| [MIL-003] | Milestone | en | it | [BC-001], [PP-001] | [MIL-004] | RC-009 |
| [MIL-004] | Milestone | en | it | [BC-001], [PP-001] | [MIL-005] | RC-010 |
| [MIL-005] | Milestone | en | it | [BC-001], [PP-001] | [MIL-006] | RC-011 |
| [MIL-006] | Milestone | en | it | [BC-001], [PP-001] | [MIL-007] | RC-012 |
| [MIL-007] | Milestone | en | it | [BC-001], [PP-001] | - | RC-013 |
## Concern Coverage
| Concern (CN-ID) | Views that answer it | Decisions (ADR) | Quality check (TP) | Status |
| --- | --- | --- | --- | --- |
| CN-001 | [BC-001], [MIL-003], [MIL-004], [MIL-005] | [ADR-0002] | Unit tests per gateway | Planned |
| CN-002 | [PP-001], [MIL-001] | [ADR-0001] | Review records | Covered |
| CN-003 | [MIL-006] | - | Unit tests per gateway | Planned |
| CN-004 | [MIL-002], [MIL-003] | [ADR-0002] | Code review | Planned |
| CN-005 | [MIL-002], [MIL-007] | - | Clean clone walk-through | Planned |
| CN-006 | [MIL-007] | - | Wiki check | Planned |
| CN-007 | [MIL-001], [MIL-007] | - | Repository API check | Planned |
| CN-008 | [MIL-002] | [ADR-0002] | pyproject check | Planned |
| CN-009 | [MIL-001] | - | Review of .gitignore and history | Planned |
## Open Inconsistencies
| # | Views | Inconsistency | Found in (RC-ID) | Status | Resolved in |
| --- | --- | --- | --- | --- | --- |
| - | - | None found | - | Closed | - |
## Coverage Notes
- No test plan (`TP`), architecture description (`AD`) or design artifacts exist: ADR-0001 skips stories and use cases, and the code is small. Quality checks in Concern Coverage name the check used instead.
- `-` in Upstream means foundational, in Downstream that nothing is built on it yet, and in Last Reviewed that no `RC-*` exists.
- Status `Planned` means a milestone will answer the concern; it is not a gap.
---
[BC-001]: ../business-case.md
[SA-001]: ../stakeholder-analysis.md
[PP-001]: ../project-plan.md
[DICT-001]: ../dictionary.md
[ADR-0001]: ../adr/adr-0001-skip-stories-use-cases.md
[ADR-0002]: ../adr/adr-0002-light-clean-architecture.md
[ADR-0003]: ../adr/adr-0003-docker-headless-browser.md
[MIL-001]: ../milestones/mil-001-baseline.md
[MIL-002]: ../milestones/mil-002-scaffold.md
[MIL-003]: ../milestones/mil-003-click-bot.md
[MIL-004]: ../milestones/mil-004-buy-upgrades.md
[MIL-005]: ../milestones/mil-005-timed-run.md
[MIL-006]: ../milestones/mil-006-strategy.md
[MIL-007]: ../milestones/mil-007-documentation.md
[cadc5ef]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/commit/cadc5eff21d9afc09799d4bc2e3f6ac17ab2612a
# Traceability Matrix
## Metadata
| Key | Value |
| --- | --- |
| ID | TM-001 |
| CrossReference | [BC-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Initial version: baseline artifacts | [cadc5ef] |
---
## Purpose
Tracks backward/forward links between artifact instances so that the Business Case's
cross-artifact traceability success criterion is measurable. A row is added or
updated whenever an artifact instance is created or reviewed.
## Purpose
Tracks backward and forward links between artifact instances so that the Business Case's traceability can be read in one place. A row is added or updated whenever an artifact is created or reviewed. Rows for code and tests are added by the milestones that create them.
## Traceability Table
| Artifact Instance | Type | Language | Domain | Upstream (Backward Link) | Downstream (Forward Link) | Last Reviewed (RC-ID) |
| --- | --- | --- | --- | --- | --- | --- |
| [BC-001] | Business Case | en | it | - | [SA-001], [PP-001] | RC-001 |
| [SA-001] | Stakeholder Analysis | en | it | [BC-001] | [PP-001] | RC-002 |
| [PP-001] | Project Plan | en | it | [BC-001], [SA-001] | [MIL-001] to [MIL-007] | - (accepted by S01 in chat; no checklist) |
| [DICT-001] | Domain Dictionary | en | it | [BC-001], [SA-001] | - | RC-003 |
| [ADR-0001] | ADR | - | - | [BC-001] | [PP-001] | RC-004 |
| [ADR-0002] | ADR | - | - | [BC-001], [SA-001] | code in MIL-002 onward | RC-005 |
| [ADR-0003] | ADR | - | - | [BC-001] | [MIL-002], [MIL-003] | RC-006 |
| [MIL-001] | Milestone | en | it | [BC-001], [PP-001] | [MIL-002] | RC-007 |
| [MIL-002] | Milestone | en | it | [BC-001], [PP-001] | [MIL-003] | RC-008 |
| [MIL-003] | Milestone | en | it | [BC-001], [PP-001] | [MIL-004] | RC-009 |
| [MIL-004] | Milestone | en | it | [BC-001], [PP-001] | [MIL-005] | RC-010 |
| [MIL-005] | Milestone | en | it | [BC-001], [PP-001] | [MIL-006] | RC-011 |
| [MIL-006] | Milestone | en | it | [BC-001], [PP-001] | [MIL-007] | RC-012 |
| [MIL-007] | Milestone | en | it | [BC-001], [PP-001] | - | RC-013 |
| Source and tests of [MIL-002] | Python source code | - | - | [MIL-002], [ADR-0002] | - | RC-014 |
| Source and tests of [MIL-003] | Python source code | - | - | [MIL-003], [ADR-0002] | - | RC-015 |
| Source and tests of [MIL-004] | Python source code | - | - | [MIL-004], [ADR-0002] | - | RC-016 |
| Source and tests of [MIL-005] | Python source code | - | - | [MIL-005], [ADR-0002] | - | RC-017 |
| Source and tests of [MIL-006] | Python source code | - | - | [MIL-006], [ADR-0002] | - | RC-018 |
## Concern Coverage
| Concern (CN-ID) | Views that answer it | Decisions (ADR) | Quality check (TP) | Status |
| --- | --- | --- | --- | --- |
| CN-001 | [BC-001], [MIL-003], [MIL-004], [MIL-005] | [ADR-0002] | Unit tests per gateway | Covered |
| CN-002 | [PP-001], [MIL-001] | [ADR-0001] | Review records | Covered |
| CN-003 | [MIL-006] | - | Unit tests per gateway | Covered |
| CN-004 | [MIL-002], [MIL-003] | [ADR-0002] | Code review | Covered |
| CN-005 | [MIL-002], [MIL-007] | - | Clean clone walk-through | Covered |
| CN-006 | [MIL-007] | - | Wiki check | Covered |
| CN-007 | [MIL-001], [MIL-007] | - | Repository API check | Covered |
| CN-008 | [MIL-002] | [ADR-0002] | pyproject check | Covered |
| CN-009 | [MIL-001] | - | Review of .gitignore and history | Covered |
## Open Inconsistencies
| # | Views | Inconsistency | Found in (RC-ID) | Status | Resolved in |
| --- | --- | --- | --- | --- | --- |
| - | - | None found | - | Closed | - |
## Coverage Notes
- No test plan (`TP`), architecture description (`AD`) or design artifacts exist: ADR-0001 skips stories and use cases, and the code is small. Quality checks in Concern Coverage name the check used instead.
- `-` in Upstream means foundational, in Downstream that nothing is built on it yet, and in Last Reviewed that no `RC-*` exists.
- Every concern is covered once MIL-007 is merged; the quality check column names the check that was run.
---
[BC-001]: ../business-case.md
[SA-001]: ../stakeholder-analysis.md
[PP-001]: ../project-plan.md
[DICT-001]: ../dictionary.md
[ADR-0001]: ../adr/adr-0001-skip-stories-use-cases.md
[ADR-0002]: ../adr/adr-0002-light-clean-architecture.md
[ADR-0003]: ../adr/adr-0003-docker-headless-browser.md
[MIL-001]: ../milestones/mil-001-baseline.md
[MIL-002]: ../milestones/mil-002-scaffold.md
[MIL-003]: ../milestones/mil-003-click-bot.md
[MIL-004]: ../milestones/mil-004-buy-upgrades.md
[MIL-005]: ../milestones/mil-005-timed-run.md
[MIL-006]: ../milestones/mil-006-strategy.md
[MIL-007]: ../milestones/mil-007-documentation.md
[cadc5ef]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/commit/cadc5eff21d9afc09799d4bc2e3f6ac17ab2612a
+46
View File
@@ -0,0 +1,46 @@
[English](Home) · **Dansk** · [Bahasa Malaysia](Home-ms)
# Cookie Clicker Bot
En Selenium-bot, der spiller [Cookie Clicker](https://ozh.github.io/cookieclicker/) for dig: den klikker på den store cookie, køber den dyreste opgradering, den har råd til, hvert 5. sekund og udskriver cookies pr. sekund efter 5 minutter. Det er dag 48 i Udemys *100 Days of Code: The Complete Python Pro Bootcamp*.
## De fem trin i lektionen
| Trin | Hvad botten lærer | Side |
| --- | --- | --- |
| 1 | Lær spillet og dets side at kende | [Step 1](Step-1-Get-to-Know-the-Game-da) |
| 2 | Luk sprogvalget og klik på cookien | [Step 2](Step-2-Click-the-Cookie-da) |
| 3 | Køb den dyreste opgradering, der er råd til, hvert 5. sekund | [Step 3](Step-3-Buy-Upgrades-da) |
| 4 | Stop efter 5 minutter og udskriv cookies pr. sekund | [Step 4](Step-4-Five-Minute-Run-da) |
| 5 | Justér strategien og sammenlign kørsler | [Step 5](Step-5-Improve-the-Strategy-da) |
Hver side viser et skærmbillede af spillet på det pågældende trin og forklarer, hvad vi fik ud af det.
## Kør den
```bash
python -m venv .venv
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
cookie-clicker-bot
```
For at holde dit skrivebord frit kan du køre den i Docker (headless Chromium): `docker build -t cookie-clicker-bot .` og `docker run --rm cookie-clicker-bot`. Repositoriets README har den fulde vejledning til Windows, Linux og macOS samt til testene.
## Resultater
| Kørsel | cookies/second |
| --- | --- |
| Lektionens trin 4: tjek hvert 5. sekund, køb én (alene på computeren) | 18.9 |
| Lektionens trin 5: tjek hvert 10. sekund, køb én (bedste i sammenligningen) | 18.2 |
Rækkerne stammer fra forskellige forhold; [Step 5](Step-5-Improve-the-Strategy-da) forklarer, hvordan de blev målt.
## Sådan er koden organiseret
- `domain/`: reglerne (fortolkning af sidens tekst, valg af opgradering) uden Selenium.
- `application/`: spillets løkke, som taler med spillet gennem en port.
- `infrastructure/`: Selenium-adapteren og Chromium-fabrikken.
- `constants.py`: alle konstanter.
Testene bruger en falsk driver, så de kører på et sekund og kræver ingen browser.
+46
View File
@@ -0,0 +1,46 @@
[English](Home) · [Dansk](Home-da) · **Bahasa Malaysia**
# Cookie Clicker Bot
Bot Selenium yang bermain [Cookie Clicker](https://ozh.github.io/cookieclicker/) untuk anda: ia mengklik kuki besar, membeli naik taraf termahal yang mampu dibeli setiap 5 saat dan mencetak kuki sesaat selepas 5 minit. Ini ialah hari ke-48 kursus Udemy *100 Days of Code: The Complete Python Pro Bootcamp*.
## Lima langkah kuliah
| Langkah | Apa yang dipelajari bot | Halaman |
| --- | --- | --- |
| 1 | Mengenali permainan dan halamannya | [Langkah 1](Step-1-Get-to-Know-the-Game-ms) |
| 2 | Menutup pemilihan bahasa dan mengklik kuki | [Langkah 2](Step-2-Click-the-Cookie-ms) |
| 3 | Setiap 5 saat, beli naik taraf termahal yang mampu dibeli | [Langkah 3](Step-3-Buy-Upgrades-ms) |
| 4 | Berhenti selepas 5 minit dan cetak kuki sesaat | [Langkah 4](Step-4-Five-Minute-Run-ms) |
| 5 | Ubah suai strategi dan bandingkan larian | [Langkah 5](Step-5-Improve-the-Strategy-ms) |
Setiap halaman menunjukkan tangkapan skrin permainan pada langkah tersebut dan menerangkan apa yang kita peroleh di dalamnya.
## Jalankannya
```bash
python -m venv .venv
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
cookie-clicker-bot
```
Untuk memastikan desktop anda bebas, jalankannya dalam Docker (Chromium tanpa kepala): `docker build -t cookie-clicker-bot .` dan `docker run --rm cookie-clicker-bot`. README repositori mengandungi arahan lengkap untuk Windows, Linux dan macOS, serta untuk ujian.
## Keputusan
| Larian | cookies/second |
| --- | --- |
| Langkah kuliah 4: semak setiap 5 saat, beli satu (bersendirian pada komputer) | 18.9 |
| Langkah kuliah 5: semak setiap 10 saat, beli satu (terbaik dalam perbandingan) | 18.2 |
Baris-baris ini datang daripada keadaan yang berbeza; [Langkah 5](Step-5-Improve-the-Strategy-ms) menerangkan cara ia diukur.
## Cara kod disusun
- `domain/`: peraturan (menghurai teks halaman, memilih naik taraf), tanpa Selenium.
- `application/`: gelung permainan, yang berhubung dengan permainan melalui port.
- `infrastructure/`: penyesuai Selenium dan kilang Chromium.
- `constants.py`: semua pemalar.
Ujian menggunakan pemacu palsu, jadi ia berjalan dalam sesaat dan tidak memerlukan pelayar.
+46
View File
@@ -0,0 +1,46 @@
**English** · [Dansk](Home-da) · [Bahasa Malaysia](Home-ms)
# Cookie Clicker Bot
A Selenium bot that plays [Cookie Clicker](https://ozh.github.io/cookieclicker/) for you: it clicks the big cookie, buys the most expensive upgrade it can afford every 5 seconds and prints the cookies per second after 5 minutes. It is day 48 of Udemy's *100 Days of Code: The Complete Python Pro Bootcamp*.
## The five lecture steps
| Step | What the bot learns | Page |
| --- | --- | --- |
| 1 | Get to know the game and its page | [Step 1](Step-1-Get-to-Know-the-Game) |
| 2 | Dismiss the language selection and click the cookie | [Step 2](Step-2-Click-the-Cookie) |
| 3 | Every 5 seconds buy the most expensive affordable upgrade | [Step 3](Step-3-Buy-Upgrades) |
| 4 | Stop after 5 minutes and print the cookies per second | [Step 4](Step-4-Five-Minute-Run) |
| 5 | Tweak the strategy and compare runs | [Step 5](Step-5-Improve-the-Strategy) |
Each page shows a screenshot of the game at that step and explains what we gained in it.
## Run it
```bash
python -m venv .venv
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
cookie-clicker-bot
```
To keep your desktop free, run it in Docker (headless Chromium): `docker build -t cookie-clicker-bot .` and `docker run --rm cookie-clicker-bot`. The README of the repository has the full instructions for Windows, Linux and macOS, and for the tests.
## Results
| Run | cookies/second |
| --- | --- |
| Lecture step 4: check every 5 seconds, buy one (alone on the computer) | 18.9 |
| Lecture step 5: check every 10 seconds, buy one (best of the comparison) | 18.2 |
The rows come from different conditions; [Step 5](Step-5-Improve-the-Strategy) explains how they were measured.
## How the code is organised
- `domain/`: the rules (parsing the page text, choosing the upgrade), with no Selenium.
- `application/`: the game loop, which talks to the game through a port.
- `infrastructure/`: the Selenium adapter and the Chromium factory.
- `constants.py`: every constant.
The tests use a fake driver, so they run in a second and need no browser.
@@ -0,0 +1,36 @@
[English](Step-1-Get-to-Know-the-Game) · **Dansk** · [Bahasa Malaysia](Step-1-Get-to-Know-the-Game-ms)
# Lektionens trin 1: lær spillet at kende
![Spillet lige efter sproget er valgt](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/raw/branch/main/docs/images/step-1.png)
## Hvad opgaven går ud på
Åbn den nye version af Cookie Clicker (ozh.github.io/cookieclicker) og lær, hvordan den fungerer, før du skriver kode.
## Hvad vi fandt
Vi åbnede siden i en headless Chromium i en Docker-container og kiggede på dens HTML. Det, botten har brug for:
| Hvad | Hvor på siden |
| --- | --- |
| Engelsk i sprogvalget | `#langSelect-EN` |
| Den store cookie | `#bigCookie` |
| Antal cookies og cookies pr. sekund | `#cookies` (og `#cookiesPerSecond` inde i den) |
| Butikken | `#products`, med `#product0` (Cursor), `#product1` (Grandma), `#product2` (Farm) osv. |
| Et produkts navn og pris | `.productName` og `.price` inde i produktet |
| Har jeg råd? | produktet har klassen `unlocked enabled` (et produkt, du endnu ikke har råd til, er `unlocked disabled`; et skjult er `locked disabled`) |
Godt at vide:
- Spillet beder om et sprog, første gang siden indlæses. Botten skal vælge et af sig selv.
- Antallet er skrevet som tekst: `3 cookies`, `5,000 cookies`, og for store tal på to linjer, `1.235` og `million cookies`.
- Et cookie-banner ("Got it!") sidder nederst på siden. Botten klikker aldrig på det, fordi det er et samtykkebanner, der tilhører den, der spiller; botten arbejder uden om det (se [Step 4](Step-4-Five-Minute-Run-da)).
Alle disse id'er og selektorer står ét sted, i `constants.py`.
## Hvad vi fik ud af dette trin
- **Læs siden, før du koder.** Et kort kig på den rigtige HTML sparede timers gætværk; hver eneste selektor i botten stammer fra dette trin.
- **Hav selektorerne ét sted.** Hvis spillet ændrer et id, er rettelsen én linje i `constants.py`.
- **Kør browseren i en container.** Det samme Docker-image, der kører botten, tager skærmbillederne, så der åbner intet på dit eget skrivebord, og hver kørsel starter fra den samme rene browser.
@@ -0,0 +1,36 @@
[English](Step-1-Get-to-Know-the-Game) · [Dansk](Step-1-Get-to-Know-the-Game-da) · **Bahasa Malaysia**
# Langkah kuliah 1: mengenali permainan
![Permainan sebaik sahaja bahasa dipilih](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/raw/branch/main/docs/images/step-1.png)
## Apa yang diminta oleh tugasan
Buka versi baharu Cookie Clicker (ozh.github.io/cookieclicker) dan fahami cara ia berfungsi sebelum menulis sebarang kod.
## Apa yang kami temui
Kami membuka halaman itu dalam Chromium tanpa kepala di dalam bekas Docker dan melihat HTML-nya. Bahagian yang diperlukan oleh bot:
| Apa | Di mana pada halaman |
| --- | --- |
| Bahasa Inggeris pada pemilihan bahasa | `#langSelect-EN` |
| Kuki besar | `#bigCookie` |
| Bilangan kuki dan kuki sesaat | `#cookies` (dan `#cookiesPerSecond` di dalamnya) |
| Kedai | `#products`, dengan `#product0` (Cursor), `#product1` (Grandma), `#product2` (Farm) dan seterusnya |
| Nama dan harga produk | `.productName` dan `.price` di dalam produk |
| Mampukah saya membelinya? | produk itu mempunyai kelas `unlocked enabled` (produk yang belum mampu dibeli ialah `unlocked disabled`; yang tersembunyi ialah `locked disabled`) |
Juga baik untuk diketahui:
- Permainan meminta bahasa apabila halaman dimuatkan buat kali pertama. Bot mesti memilih satu sendiri.
- Bilangan ditulis sebagai teks: `3 cookies`, `5,000 cookies`, dan untuk nombor besar pada dua baris, `1.235` dan `million cookies`.
- Sepanduk kuki ("Got it!") berada di bahagian bawah halaman. Bot tidak pernah mengkliknya, kerana ia ialah sepanduk persetujuan yang menjadi hak orang yang bermain; bot mengatasinya dengan cara lain (lihat [Langkah 4](Step-4-Five-Minute-Run-ms)).
Semua id dan pemilih ini ditulis sekali sahaja, dalam `constants.py`.
## Apa yang kita peroleh dalam langkah ini
- **Baca halaman sebelum menulis kod.** Meninjau sebentar HTML sebenar menjimatkan berjam-jam meneka; setiap pemilih dalam bot datang daripada langkah ini.
- **Simpan pemilih di satu tempat.** Jika permainan menukar sesuatu id, satu baris dalam `constants.py` ialah pembetulannya.
- **Jalankan pelayar dalam bekas.** Imej Docker yang sama yang menjalankan bot mengambil tangkapan skrin, jadi tiada apa yang terbuka pada desktop anda sendiri dan setiap larian bermula daripada pelayar bersih yang sama.
+36
View File
@@ -0,0 +1,36 @@
**English** · [Dansk](Step-1-Get-to-Know-the-Game-da) · [Bahasa Malaysia](Step-1-Get-to-Know-the-Game-ms)
# Lecture step 1: get to know the game
![The game right after the language was chosen](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/raw/branch/main/docs/images/step-1.png)
## What the assignment asks
Open the new version of Cookie Clicker (ozh.github.io/cookieclicker) and learn how it works before writing any code.
## What we found
We opened the page in a headless Chromium inside a Docker container and looked at its HTML. The parts the bot needs:
| What | Where on the page |
| --- | --- |
| English on the language selection | `#langSelect-EN` |
| The big cookie | `#bigCookie` |
| The cookie count and the cookies per second | `#cookies` (and `#cookiesPerSecond` inside it) |
| The store | `#products`, with `#product0` (Cursor), `#product1` (Grandma), `#product2` (Farm) and so on |
| A product's name and price | `.productName` and `.price` inside the product |
| Can I afford it? | the product has the class `unlocked enabled` (a product you cannot afford yet is `unlocked disabled`; a hidden one is `locked disabled`) |
Also good to know:
- The game asks for a language the first time the page loads. The bot must pick one by itself.
- The count is written as text: `3 cookies`, `5,000 cookies`, and for large numbers on two lines, `1.235` and `million cookies`.
- A cookie banner ("Got it!") sits at the bottom of the page. The bot never clicks it, because it is a consent banner that belongs to the person playing; the bot works around it (see [Step 4](Step-4-Five-Minute-Run)).
All of these ids and selectors are written once, in `constants.py`.
## What we gained in this step
- **Read the page before coding.** A short look at the real HTML saved hours of guessing; every selector in the bot came from this step.
- **Keep selectors in one place.** If the game changes an id, one line in `constants.py` is the fix.
- **Run the browser in a container.** The same Docker image that runs the bot takes the screenshots, so nothing opens on your own desktop and every run starts from the same clean browser.
+26
View File
@@ -0,0 +1,26 @@
[English](Step-2-Click-the-Cookie) · **Dansk** · [Bahasa Malaysia](Step-2-Click-the-Cookie-ms)
# Lektionens trin 2: klik på cookien så hurtigt som muligt
![Spillet efter 15 sekunders klikning](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/raw/branch/main/docs/images/step-2.png)
## Hvad opgaven går ud på
Lav en bot med Selenium og Python, der klikker på cookien så hurtigt som muligt. Den skal selv lukke sprogvalget, før den går i gang.
## Hvad vi byggede
- `open_game` indlæser siden.
- `dismiss_language` venter, til den engelske knap er synlig, klikker på den og venter derefter, til den store cookie er synlig. Spillet skal bruge nogle sekunder på at starte, efter sproget er valgt, så botten poller (hvert 0,2 sekund, højst 20 sekunder) i stedet for at sove i fast tid.
- `click_cookie` klikker én gang på `#bigCookie`. Hvis et banner eller en gentegning blokerer klikket et øjeblik, prøver botten igen i næste runde.
- En løkke kalder `click_cookie`, indtil tiden er gået. I en kørsel på 15 sekunder i containeren klikkede botten 344 gange, og skærmbilledet ovenfor viser 342 cookies.
```bash
cookie-clicker-bot --headless --duration 15 --screenshot step-2.png
```
## Hvad vi fik ud af dette trin
- **Vent på en betingelse, ikke på et tidsrum.** En fast `sleep` er enten for kort (botten fejler) eller for lang (botten spilder tid). At polle efter "cookien er synlig" er både hurtigere og sikrere.
- **Hold browseren bag en port.** Løkken kender kun `GamePort` (`open_game`, `dismiss_language`, `click_cookie`, ...). Selenium-koden bor i én adapter, så løkken testes med et falsk spil på få millisekunder.
- **Tidsstyr en løkke med et ur, du kan udskifte.** `click_and_buy` tager uret som parameter; testene giver den et falsk ur og kontrollerer antallet af klik helt præcist.
+26
View File
@@ -0,0 +1,26 @@
[English](Step-2-Click-the-Cookie) · [Dansk](Step-2-Click-the-Cookie-da) · **Bahasa Malaysia**
# Langkah kuliah 2: klik kuki secepat mungkin
![Permainan selepas 15 saat mengklik](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/raw/branch/main/docs/images/step-2.png)
## Apa yang diminta oleh tugasan
Cipta bot dengan Selenium dan Python yang mengklik kuki secepat mungkin. Ia mesti menutup pemilihan bahasa sendiri sebelum bermula.
## Apa yang kami bina
- `open_game` memuatkan halaman.
- `dismiss_language` menunggu sehingga butang bahasa Inggeris kelihatan, mengkliknya, kemudian menunggu sehingga kuki besar kelihatan. Permainan memerlukan beberapa saat untuk bermula selepas bahasa dipilih, jadi bot membuat pengundian (setiap 0.2 saat, paling lama 20 saat) dan bukannya tidur untuk masa yang tetap.
- `click_cookie` mengklik `#bigCookie` sekali. Jika sepanduk atau pemaparan semula menghalang klik seketika, bot mencuba lagi pada pusingan seterusnya.
- Satu gelung memanggil `click_cookie` sehingga masa tamat. Dalam larian 15 saat di dalam bekas, bot mengklik sebanyak 344 kali, dan tangkapan skrin di atas menunjukkan 342 kuki.
```bash
cookie-clicker-bot --headless --duration 15 --screenshot step-2.png
```
## Apa yang kita peroleh dalam langkah ini
- **Tunggu sesuatu keadaan, bukan sesuatu masa.** `sleep` yang tetap sama ada terlalu pendek (bot gagal) atau terlalu lama (bot membazir masa). Mengundi untuk "kuki kelihatan" adalah lebih pantas dan lebih selamat.
- **Letakkan pelayar di belakang port.** Gelung hanya mengenali `GamePort` (`open_game`, `dismiss_language`, `click_cookie`, ...). Kod Selenium berada dalam satu penyesuai, jadi gelung diuji dengan permainan palsu dalam beberapa milisaat.
- **Catat masa gelung dengan jam yang boleh digantikan.** `click_and_buy` menerima jam sebagai parameter; ujian memberikannya jam palsu dan menyemak bilangan klik dengan tepat.
+26
View File
@@ -0,0 +1,26 @@
**English** · [Dansk](Step-2-Click-the-Cookie-da) · [Bahasa Malaysia](Step-2-Click-the-Cookie-ms)
# Lecture step 2: click the cookie as fast as possible
![The game after 15 seconds of clicking](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/raw/branch/main/docs/images/step-2.png)
## What the assignment asks
Create a bot with Selenium and Python that clicks the cookie as fast as possible. It must dismiss the language selection by itself before it starts.
## What we built
- `open_game` loads the page.
- `dismiss_language` waits until the English button is visible, clicks it, and then waits until the big cookie is visible. The game needs a few seconds to start after the language is chosen, so the bot polls (every 0.2 seconds, at most 20 seconds) instead of sleeping for a fixed time.
- `click_cookie` clicks `#bigCookie` once. If a banner or a re-render blocks the click for a moment, the bot tries again on the next round.
- A loop calls `click_cookie` until the time is up. In a 15 second run in the container the bot clicked 344 times, and the screenshot above shows 342 cookies.
```bash
cookie-clicker-bot --headless --duration 15 --screenshot step-2.png
```
## What we gained in this step
- **Wait for a condition, not for a time.** A fixed `sleep` is either too short (the bot fails) or too long (the bot wastes time). Polling for "the cookie is visible" is both faster and safer.
- **Keep the browser behind a port.** The loop knows only `GamePort` (`open_game`, `dismiss_language`, `click_cookie`, ...). The Selenium code lives in one adapter, so the loop is tested with a fake game in a few milliseconds.
- **Time a loop with a clock you can replace.** `click_and_buy` takes the clock as a parameter; the tests give it a fake clock and check the number of clicks exactly.
+23
View File
@@ -0,0 +1,23 @@
[English](Step-3-Buy-Upgrades) · **Dansk** · [Bahasa Malaysia](Step-3-Buy-Upgrades-ms)
# Lektionens trin 3: køb den dyreste opgradering, der er råd til
![Spillet efter 45 sekunders klikning og køb](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/raw/branch/main/docs/images/step-3.png)
## Hvad opgaven går ud på
Hvert 5. sekund skal botten se på ruden til højre, finde de opgraderinger, der er råd til, og købe den dyreste. Med 105 cookies er der for eksempel råd til både Cursor (15) og Grandma (100), så botten køber Grandma. Hintene peger på `split`, `strip` og `replace` til at omdanne sidens tekst til tal.
## Hvad vi byggede
- `parse_amount` og `parse_cookie_count` omdanner sidens tekst til tal. De fjerner ordet `cookies`, dropper alt efter `per second`, fjerner tusindtalskommaerne med `replace` og forstår ord som `1.4 million`. Eksempler: `5,000 cookies` giver 5000; `1.235` og `million cookies` på to linjer giver 1.235.000.
- `SeleniumGame.upgrades` læser hvert synligt produkt: dets id, navn og pris.
- `choose_most_expensive_affordable(upgrades, cookies)` er en ren funktion: blandt de opgraderinger, hvis pris ikke overstiger antallet af cookies, returnerer den den dyreste eller ingenting. Den er testet med eksemplet ovenfor (105 cookies giver Grandma).
- `click_and_buy` bliver ved med at klikke, og når `check_interval_seconds` (som standard 5) er gået, læser den antallet, vælger og køber.
- I en kørsel på 45 sekunder i containeren købte botten 8 opgraderinger, og skærmbilledet viser de Cursors og Grandmas, den ejer.
## Hvad vi fik ud af dette trin
- **Det svære er teksten, ikke klikket.** Det samme tal optræder som `120`, `5,000`, `1.4 million` og på to linjer. Fortolkningen er en lille ren funktion med mange testcases, så den er sikker at ændre.
- **Hold reglen væk fra browseren.** "Køb den dyreste opgradering, der er råd til" er én funktion, der intet ved om Selenium. Derfor kunne trin 5 tilføje en anden regel uden at røre browserkoden.
- **Tæl ikke et køb, du ikke har foretaget.** Den første version loggede et køb, hver gang den klikkede. En rigtig kørsel viste, at klikket ikke altid nåede produktet, så `buy` melder nu, om det gjorde, og kun rigtige køb tælles (se [Step 4](Step-4-Five-Minute-Run-da)).
+23
View File
@@ -0,0 +1,23 @@
[English](Step-3-Buy-Upgrades) · [Dansk](Step-3-Buy-Upgrades-da) · **Bahasa Malaysia**
# Langkah kuliah 3: beli naik taraf termahal yang mampu dibeli
![Permainan selepas 45 saat mengklik dan membeli](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/raw/branch/main/docs/images/step-3.png)
## Apa yang diminta oleh tugasan
Setiap 5 saat, lihat anak tetingkap sebelah kanan, cari naik taraf yang mampu dibeli, dan beli yang paling mahal. Contohnya, dengan 105 kuki, Cursor (15) dan Grandma (100) kedua-duanya mampu dibeli, jadi bot membeli Grandma. Petunjuk menyebut `split`, `strip` dan `replace` untuk menukar teks halaman kepada nombor.
## Apa yang kami bina
- `parse_amount` dan `parse_cookie_count` menukar teks halaman kepada nombor. Ia membuang perkataan `cookies`, menggugurkan segala-galanya selepas `per second`, membuang koma ribuan dengan `replace`, dan memahami perkataan seperti `1.4 million`. Contoh: `5,000 cookies` menghasilkan 5000; `1.235` dan `million cookies` pada dua baris menghasilkan 1,235,000.
- `SeleniumGame.upgrades` membaca setiap produk yang kelihatan: id, nama dan harganya.
- `choose_most_expensive_affordable(upgrades, cookies)` ialah fungsi tulen: antara naik taraf yang harganya tidak melebihi bilangan kuki, pulangkan yang paling mahal, atau tiada apa-apa. Ia diuji dengan contoh di atas (105 kuki menghasilkan Grandma).
- `click_and_buy` terus mengklik, dan apabila `check_interval_seconds` (5 secara lalai) telah berlalu, ia membaca bilangan kuki, memilih, dan membeli.
- Dalam larian bekas selama 45 saat, bot membeli 8 naik taraf, dan tangkapan skrin menunjukkan Cursor dan Grandma yang dimilikinya.
## Apa yang kita peroleh dalam langkah ini
- **Bahagian yang sukar ialah teks, bukan kliknya.** Nombor yang sama muncul sebagai `120`, `5,000`, `1.4 million` dan pada dua baris. Penghuraian ialah fungsi tulen kecil dengan banyak kes ujian, jadi selamat untuk diubah.
- **Jauhkan peraturan daripada pelayar.** "Beli naik taraf termahal yang mampu dibeli" ialah satu fungsi yang tidak tahu apa-apa tentang Selenium. Itulah sebabnya langkah 5 boleh menambah peraturan kedua tanpa menyentuh kod pelayar.
- **Jangan kira pembelian yang tidak anda buat.** Versi pertama mencatat pembelian setiap kali ia mengklik. Larian sebenar menunjukkan klik itu tidak selalu sampai ke produk, jadi `buy` kini memberitahu sama ada ia berjaya, dan hanya pembelian sebenar dikira (lihat [Langkah 4](Step-4-Five-Minute-Run-ms)).
+23
View File
@@ -0,0 +1,23 @@
**English** · [Dansk](Step-3-Buy-Upgrades-da) · [Bahasa Malaysia](Step-3-Buy-Upgrades-ms)
# Lecture step 3: buy the most expensive affordable upgrade
![The game after 45 seconds of clicking and buying](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/raw/branch/main/docs/images/step-3.png)
## What the assignment asks
Every 5 seconds, look at the right-hand pane, find the upgrades you can afford, and buy the most expensive one. For example, with 105 cookies both Cursor (15) and Grandma (100) are affordable, so the bot buys Grandma. The hints point to `split`, `strip` and `replace` for turning the page text into numbers.
## What we built
- `parse_amount` and `parse_cookie_count` turn page text into numbers. They remove the word `cookies`, drop everything after `per second`, take away the thousands commas with `replace`, and understand words such as `1.4 million`. Examples: `5,000 cookies` gives 5000; `1.235` and `million cookies` on two lines gives 1,235,000.
- `SeleniumGame.upgrades` reads every visible product: its id, its name and its price.
- `choose_most_expensive_affordable(upgrades, cookies)` is a pure function: of the upgrades whose price is not above the cookies, return the most expensive one, or nothing. It is tested with the example above (105 cookies gives Grandma).
- `click_and_buy` keeps clicking, and when `check_interval_seconds` (5 by default) have passed it reads the count, chooses, and buys.
- In a 45 second container run the bot bought 8 upgrades, and the screenshot shows the Cursors and Grandmas it owns.
## What we gained in this step
- **The hard part is the text, not the click.** The same number appears as `120`, `5,000`, `1.4 million` and on two lines. Parsing is a small pure function with many test cases, so it is safe to change.
- **Keep the rule away from the browser.** "Buy the most expensive affordable upgrade" is one function that knows nothing about Selenium. That is why step 5 could add a second rule without touching the browser code.
- **Do not count a purchase you did not make.** The first version logged a purchase whenever it clicked. A real run showed the click did not always reach the product, so `buy` now says whether it did, and only real purchases are counted (see [Step 4](Step-4-Five-Minute-Run)).
+33
View File
@@ -0,0 +1,33 @@
[English](Step-4-Five-Minute-Run) · **Dansk** · [Bahasa Malaysia](Step-4-Five-Minute-Run-ms)
# Lektionens trin 4: stop efter fem minutter og udskriv cookies pr. sekund
![Spillet efter kørslen på fem minutter](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/raw/branch/main/docs/images/step-4.png)
## Hvad opgaven går ud på
Efter 5 minutter stopper botten og udskriver `cookies/second`.
## Hvad vi byggede
- `click_and_buy` kører til en deadline målt med et monotont ur (`time.monotonic`), så kørslen varer præcis `RUN_DURATION_SECONDS` (300) og driver ikke, hvis et klik er langsomt.
- `SeleniumGame.cookies_per_second` læser spillets eget tal fra `#cookiesPerSecond` (for eksempel `per second: 18.9`), og `parse_cookies_per_second` omdanner det til et tal, også værdier som `1.2 million`.
- `format_result` bygger linjen `cookies/second: 18.9`. Programmet udskriver præcis denne ene linje på standard output; loggen (klik, køb, forløbet tid) går til standard error, så `cookie-clicker-bot > result.txt` kun beholder resultatet.
## Vores første resultat
En fuld kørsel i Docker-containeren med opgavens regler (tjek hvert 5. sekund, køb det dyreste produkt, der er råd til):
| Mål | Værdi |
| --- | --- |
| Varighed | 300,0 sekunder |
| Klik | 7.666 |
| Købte produkter | 45 (29 Cursor, 16 Grandma) |
| Resultat | `cookies/second: 18.9` |
## Hvad vi fik ud af dette trin
- **En tidsstyret løkke, der ikke driver.** Et monotont ur og en deadline er mere pålidelige end at tælle sleeps.
- **Adskilt output til mennesker og til programmer.** Logning går til standard error og resultatet til standard output.
- **To rigtige fejl fundet ved en rigtig kørsel.** Spillet gentegner siden, efter sproget er valgt, så et element kan blive forældet mellem det øjeblik, man finder det, og det øjeblik, man bruger det; løsningen er at slå det op igen. Et cookie-banner dækker en del af butikken, så et klik på et produkt kan blive opfanget; botten klikker så med JavaScript og tæller kun et køb, når klikket gik igennem. Enhedstests med en falsk driver afslørede ingen af problemerne, og derfor har hvert trin en containerkørsel og et skærmbillede.
- **Et udgangspunkt.** 18,9 cookies pr. sekund er det tal, trin 5 forsøger at slå.
+33
View File
@@ -0,0 +1,33 @@
[English](Step-4-Five-Minute-Run) · [Dansk](Step-4-Five-Minute-Run-da) · **Bahasa Malaysia**
# Langkah kuliah 4: berhenti selepas lima minit dan cetak kuki sesaat
![Permainan selepas larian lima minit](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/raw/branch/main/docs/images/step-4.png)
## Apa yang diminta oleh tugasan
Selepas 5 minit bot berhenti dan mencetak `cookies/second`.
## Apa yang kami bina
- `click_and_buy` berjalan sehingga tarikh akhir yang diukur dengan jam monotonik (`time.monotonic`), jadi larian berlangsung tepat `RUN_DURATION_SECONDS` (300) dan tidak tersasar jika sesuatu klik perlahan.
- `SeleniumGame.cookies_per_second` membaca angka permainan itu sendiri daripada `#cookiesPerSecond` (contohnya `per second: 18.9`), dan `parse_cookies_per_second` menukarnya kepada nombor, termasuk nilai seperti `1.2 million`.
- `format_result` membina baris `cookies/second: 18.9`. Program mencetak tepat satu baris ini pada output standard; log (klik, pembelian, masa berlalu) pergi ke ralat standard, jadi `cookie-clicker-bot > result.txt` hanya menyimpan keputusan.
## Keputusan pertama kami
Larian penuh dalam bekas Docker dengan peraturan tugasan (semak setiap 5 saat, beli produk termahal yang mampu dibeli):
| Ukuran | Nilai |
| --- | --- |
| Tempoh | 300.0 saat |
| Klik | 7,666 |
| Produk dibeli | 45 (29 Cursor, 16 Grandma) |
| Keputusan | `cookies/second: 18.9` |
## Apa yang kita peroleh dalam langkah ini
- **Gelung bermasa yang tidak tersasar.** Jam monotonik dan tarikh akhir lebih boleh dipercayai daripada mengira tidur.
- **Output berasingan untuk manusia dan untuk program.** Pencatatan log pergi ke ralat standard dan keputusan ke output standard.
- **Dua pepijat sebenar ditemui oleh larian sebenar.** Permainan memaparkan semula selepas bahasa dipilih, jadi sesuatu elemen boleh menjadi basi antara ketika ia ditemui dan ketika ia digunakan; pembetulannya ialah mencari semula. Sepanduk kuki menutup sebahagian kedai, jadi klik pada produk boleh dipintas; bot kemudian mengklik dengan JavaScript dan hanya mengira pembelian apabila klik itu berjaya. Ujian unit dengan pemacu palsu tidak menunjukkan sebarang masalah ini, itulah sebabnya setiap langkah mempunyai larian bekas dan tangkapan skrin.
- **Garis dasar.** 18.9 kuki sesaat ialah nombor yang cuba dikalahkan oleh langkah 5.
+33
View File
@@ -0,0 +1,33 @@
**English** · [Dansk](Step-4-Five-Minute-Run-da) · [Bahasa Malaysia](Step-4-Five-Minute-Run-ms)
# Lecture step 4: stop after five minutes and print cookies per second
![The game after the five minute run](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/raw/branch/main/docs/images/step-4.png)
## What the assignment asks
After 5 minutes the bot stops and prints `cookies/second`.
## What we built
- `click_and_buy` runs until a deadline measured with a monotonic clock (`time.monotonic`), so the run lasts exactly `RUN_DURATION_SECONDS` (300) and does not drift if a click is slow.
- `SeleniumGame.cookies_per_second` reads the game's own figure from `#cookiesPerSecond` (for example `per second: 18.9`), and `parse_cookies_per_second` turns it into a number, including values such as `1.2 million`.
- `format_result` builds the line `cookies/second: 18.9`. The program prints exactly this one line on standard output; the log (clicks, purchases, elapsed time) goes to standard error, so `cookie-clicker-bot > result.txt` keeps only the result.
## Our first result
A full run in the Docker container with the assignment's rules (check every 5 seconds, buy the most expensive affordable product):
| Measure | Value |
| --- | --- |
| Duration | 300.0 seconds |
| Clicks | 7,666 |
| Products bought | 45 (29 Cursor, 16 Grandma) |
| Result | `cookies/second: 18.9` |
## What we gained in this step
- **A timed loop that does not drift.** A monotonic clock and a deadline are more reliable than counting sleeps.
- **Separate output for people and for programs.** Logging goes to standard error and the result to standard output.
- **Two real bugs found by a real run.** The game re-renders after the language is chosen, so an element can go stale between finding it and using it; the fix is to look again. A cookie banner covers part of the store, so a click on a product can be intercepted; the bot then clicks with JavaScript and counts a purchase only when the click went through. Unit tests with a fake driver did not show either problem, which is why every step has a container run and a screenshot.
- **A baseline.** 18.9 cookies per second is the number step 5 tries to beat.
@@ -0,0 +1,44 @@
[English](Step-5-Improve-the-Strategy) · **Dansk** · [Bahasa Malaysia](Step-5-Improve-the-Strategy-ms)
# Lektionens trin 5: justér algoritmen
![Spillet efter den bedste kørsel, med tjek hvert 10. sekund](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/raw/branch/main/docs/images/step-5.png)
## Hvad opgaven går ud på
Prøv et andet tjekinterval (hvert sekund, hvert 10. sekund) eller køb alle opgraderinger, der er råd til, og se, om cookies pr. sekund stiger.
## Hvad vi byggede
- `--interval SECONDS` sætter tiden mellem tjek af opgraderinger. Den skal være større end 0; standarden forbliver 5 sekunder.
- `--buy-all` køber alle opgraderinger, der stadig kan være inden for de cookies, du har, den dyreste først (`choose_affordable_upgrades`). Uden den køber botten én opgradering, den dyreste, der er råd til (`choose_most_expensive_affordable`).
- Begge regler er rene funktioner i domænepakken, så de enhedstestes uden en browser. Strategien er en værdi (`BuyStrategy`), ikke et flag spredt rundt i koden.
```bash
cookie-clicker-bot --interval 10
cookie-clicker-bot --interval 1 --buy-all
```
## Hvad vi målte
Hver række er en fuld kørsel på 300 sekunder i en Docker-container. Fem containere kørte samtidig på én computer, i to hold, så tallene er gode til at sammenligne rækkerne med hinanden, men en kørsel alene er hurtigere (vores solo-udgangspunkt fra lektionens trin 4 var 18,9).
| Tjek hvert | Strategi | Hold 1 | Hold 2 |
| --- | --- | --- | --- |
| 1 sekund | køb én | 13.5 | 13.5 |
| 1 sekund | køb alle | 13.5 | 13.5 |
| 5 sekunder (opgaven) | køb én | 15.5 | 15.5 |
| 5 sekunder | køb alle | styrtede ned, se nedenfor | 14.5 |
| 10 sekunder | køb én | **18.2** | **18.2** |
Det bedste resultat er intervallet på 10 sekunder med køb én. Hvorfor:
- At tjekke oftere er ikke bedre. Hvert tjek læser siden og tager tid fra klikningen, og det dyreste produkt, der er råd til, er som regel kun lidt bedre end det billigere, man kunne have købt et øjeblik før.
- Køb alle gør ingen forskel tidligt i spillet: der er kun råd til Cursor og Grandma, og det køber botten alligevel. Det ville få betydning senere, når antallet af cookies rummer flere produkter på én gang.
## Hvad vi fik ud af dette trin
- **Små, rene regler er nemme at sammenligne.** Fordi valget af opgradering ikke rører Selenium, er en ny strategi en ny funktion plus en test.
- **Mål, gæt ikke.** Det resultat, vi forventede (flere tjek, flere cookies), var det modsatte af, hvad kørslerne viste.
- **En anden rigtig fejl.** Det første hold styrtede ned i én container: spillet omskriver tælleren i hvert billede, og en computer under belastning fik vores ene genforsøg til at fejle. Læsningen forsøger nu fem gange med en kort pause. En test med en falsk driver, der bliver forældet fire gange, viser det.
- **Retfærdige sammenligninger kræver de samme forhold.** Vi kørte rækkerne samtidig og rapporterer to hold, fordi en enkelt kørsel kan være heldig.
@@ -0,0 +1,44 @@
[English](Step-5-Improve-the-Strategy) · [Dansk](Step-5-Improve-the-Strategy-da) · **Bahasa Malaysia**
# Langkah kuliah 5: ubah suai algoritma
![Permainan selepas larian terbaik, menyemak setiap 10 saat](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/raw/branch/main/docs/images/step-5.png)
## Apa yang diminta oleh tugasan
Cuba selang semakan lain (setiap saat, setiap 10 saat) atau beli semua naik taraf yang mampu dibeli, dan lihat sama ada kuki sesaat meningkat.
## Apa yang kami bina
- `--interval SECONDS` menetapkan masa antara semakan naik taraf. Ia mesti lebih besar daripada 0; nilai lalai kekal 5 saat.
- `--buy-all` membeli setiap naik taraf yang masih muat dalam kuki yang anda ada, yang paling mahal dahulu (`choose_affordable_upgrades`). Tanpanya bot membeli satu naik taraf, iaitu yang termahal yang mampu dibeli (`choose_most_expensive_affordable`).
- Kedua-dua peraturan ialah fungsi tulen dalam pakej domain, jadi ia diuji unit tanpa pelayar. Strategi ialah satu nilai (`BuyStrategy`), bukan bendera yang tersebar di seluruh kod.
```bash
cookie-clicker-bot --interval 10
cookie-clicker-bot --interval 1 --buy-all
```
## Apa yang kami ukur
Setiap baris ialah larian penuh 300 saat dalam bekas Docker. Lima bekas berjalan serentak pada satu komputer, dalam dua kelompok, jadi nombor ini sesuai untuk membandingkan baris antara satu sama lain tetapi larian bersendirian adalah lebih pantas (garis dasar solo kami pada langkah kuliah 4 ialah 18.9).
| Semak setiap | Strategi | Kelompok 1 | Kelompok 2 |
| --- | --- | --- | --- |
| 1 saat | beli satu | 13.5 | 13.5 |
| 1 saat | beli semua | 13.5 | 13.5 |
| 5 saat (tugasan) | beli satu | 15.5 | 15.5 |
| 5 saat | beli semua | ranap, lihat di bawah | 14.5 |
| 10 saat | beli satu | **18.2** | **18.2** |
Keputusan terbaik ialah selang 10 saat dengan beli satu. Sebabnya:
- Menyemak lebih kerap tidak lebih baik. Setiap semakan membaca halaman dan mengambil masa daripada mengklik, dan produk termahal yang mampu dibeli biasanya hanya sedikit lebih baik daripada yang lebih murah yang boleh anda beli seketika sebelumnya.
- Beli semua tidak membuat perbezaan pada awal permainan: hanya Cursor dan Grandma yang mampu dibeli, dan bot membelinya juga. Ia akan penting kemudian, apabila bilangan kuki cukup untuk beberapa produk sekali gus.
## Apa yang kita peroleh dalam langkah ini
- **Peraturan kecil yang tulen mudah dibandingkan.** Oleh kerana pemilihan naik taraf tidak menyentuh Selenium, strategi baharu hanyalah fungsi baharu serta satu ujian.
- **Ukur, jangan meneka.** Keputusan yang kami jangkakan (lebih banyak semakan, lebih banyak kuki) bertentangan dengan apa yang ditunjukkan oleh larian.
- **Pepijat sebenar kedua.** Kelompok pertama ranap dalam satu bekas: permainan menulis semula pembilang pada setiap bingkai, dan komputer yang terbeban menyebabkan satu percubaan semula kami gagal. Bacaan kini mencuba semula lima kali dengan jeda singkat. Ujian dengan pemacu palsu yang menjadi basi empat kali menunjukkannya.
- **Perbandingan yang adil memerlukan keadaan yang sama.** Kami menjalankan baris-baris itu serentak dan melaporkan dua kelompok, kerana satu larian boleh bernasib baik.
+44
View File
@@ -0,0 +1,44 @@
**English** · [Dansk](Step-5-Improve-the-Strategy-da) · [Bahasa Malaysia](Step-5-Improve-the-Strategy-ms)
# Lecture step 5: tweak the algorithm
![The game after the best run, checking every 10 seconds](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/raw/branch/main/docs/images/step-5.png)
## What the assignment asks
Try another check interval (every second, every 10 seconds) or buy all affordable upgrades, and see whether the cookies per second go up.
## What we built
- `--interval SECONDS` sets the time between upgrade checks. It must be greater than 0; the default stays 5 seconds.
- `--buy-all` buys every upgrade that still fits in the cookies you have, the most expensive first (`choose_affordable_upgrades`). Without it the bot buys one upgrade, the most expensive affordable one (`choose_most_expensive_affordable`).
- Both rules are pure functions in the domain package, so they are unit tested without a browser. The strategy is a value (`BuyStrategy`), not a flag spread through the code.
```bash
cookie-clicker-bot --interval 10
cookie-clicker-bot --interval 1 --buy-all
```
## What we measured
Each row is a full 300 second run in a Docker container. Five containers ran at the same time on one computer, in two batches, so the numbers are good for comparing the rows with each other but a run alone is faster (our solo baseline of lecture step 4 was 18.9).
| Check every | Strategy | Batch 1 | Batch 2 |
| --- | --- | --- | --- |
| 1 second | buy one | 13.5 | 13.5 |
| 1 second | buy all | 13.5 | 13.5 |
| 5 seconds (assignment) | buy one | 15.5 | 15.5 |
| 5 seconds | buy all | crashed, see below | 14.5 |
| 10 seconds | buy one | **18.2** | **18.2** |
The best result is the 10 second interval with buy one. Why:
- Checking more often is not better. Every check reads the page and takes time away from clicking, and the most expensive affordable product is usually only a little better than the cheaper one you could have bought a moment before.
- Buy all makes no difference early in the game: only Cursor and Grandma are affordable, and the bot buys them anyway. It would matter later, when the cookie count holds several products at once.
## What we gained in this step
- **Small, pure rules are easy to compare.** Because the choice of upgrade does not touch Selenium, a new strategy is a new function plus a test.
- **Measure, do not guess.** The result we expected (more checks, more cookies) was the opposite of what the runs showed.
- **A second real bug.** The first batch crashed in one container: the game rewrites the counter every frame, and a computer under load made our single retry fail. The read now retries five times with a short pause. A test with a fake driver that goes stale four times shows it.
- **Fair comparisons need the same conditions.** We ran the rows at the same time and report two batches, because one run can be lucky.
+1
View File
@@ -0,0 +1 @@
Cookie Clicker Bot, a solution to day 48 of Udemy 100 Days of Code. [English](Home) · [Dansk](Home-da) · [Bahasa Malaysia](Home-ms)
+14
View File
@@ -0,0 +1,14 @@
**Cookie Clicker Bot**
- [Home](Home)
- [Step 1: Get to know the game](Step-1-Get-to-Know-the-Game)
- [Step 2: Click the cookie](Step-2-Click-the-Cookie)
- [Step 3: Buy upgrades](Step-3-Buy-Upgrades)
- [Step 4: Five minute run](Step-4-Five-Minute-Run)
- [Step 5: Improve the strategy](Step-5-Improve-the-Strategy)
**Language**
- [English](Home)
- [Dansk](Home-da)
- [Bahasa Malaysia](Home-ms)
+62
View File
@@ -0,0 +1,62 @@
[build-system]
requires = ["setuptools>=75"]
build-backend = "setuptools.build_meta"
[project]
name = "cookie-clicker-bot"
version = "0.1.0"
description = "Selenium bot that plays Cookie Clicker (Udemy 100 Days of Code, day 48)."
readme = "README.md"
requires-python = ">=3.13"
license = { file = "LICENSE" }
dependencies = [
"selenium>=4.25",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3",
"pytest-cov>=6.0",
"ruff>=0.8",
"mypy>=1.13",
]
[project.scripts]
cookie-clicker-bot = "cookie_clicker_bot.cli:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra --strict-markers"
pythonpath = ["src"]
markers = [
"browser: needs a real browser (run in the Docker container)",
]
[tool.coverage.run]
source = ["cookie_clicker_bot"]
omit = ["*/infrastructure/*", "*/cli.py", "*/__main__.py"]
[tool.coverage.report]
fail_under = 80
show_missing = true
[tool.ruff]
line-length = 88
target-version = "py313"
src = ["src", "tests"]
extend-exclude = ["framework", ".claude", ".agents"]
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP", "N", "D", "ANN", "S", "PT"]
ignore = ["D203", "D213", "D205", "D212", "D400", "D415", "D401"]
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["S101", "D"]
[tool.mypy]
strict = true
python_version = "3.13"
files = ["src", "tests"]
+9
View File
@@ -0,0 +1,9 @@
"""@file __init__.py
@brief Selenium bot that plays Cookie Clicker (Udemy 100 Days of Code, day 48).
@mainpage Cookie Clicker Bot
The bot opens the game, clicks the big cookie, buys the most expensive affordable
upgrade every few seconds and prints the cookies per second when the run ends.
The rules live in the domain package, the game loop in the application package
and the Selenium adapter in the infrastructure package.
"""
+8
View File
@@ -0,0 +1,8 @@
"""@file __main__.py
@brief Entry point for `python -m cookie_clicker_bot`.
"""
from cookie_clicker_bot.cli import main
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,3 @@
"""@file __init__.py
@brief Application layer: the game loop, which talks to the game through a port.
"""
@@ -0,0 +1,96 @@
"""@file game_loop.py
@brief The game loop of the bot, written against the game port.
"""
import time
from collections.abc import Callable
from dataclasses import dataclass
from cookie_clicker_bot.application.ports import GamePort
from cookie_clicker_bot.domain.buy_strategy import BuyStrategy
from cookie_clicker_bot.domain.upgrades import (
Upgrade,
choose_affordable_upgrades,
choose_most_expensive_affordable,
)
@dataclass(frozen=True, slots=True)
class PlayResult:
"""@brief What a run did."""
## Number of clicks on the big cookie.
clicks: int
## Names of the upgrades bought, in the order they were bought.
purchases: tuple[str, ...]
## Seconds the run lasted, measured with the clock given to `click_and_buy`.
elapsed_seconds: float
def click_cookie(game: GamePort) -> None:
"""@brief Click the big cookie once (lecture step 2).
@param game The game to play.
"""
game.click_cookie()
def buy_most_expensive_upgrade(game: GamePort) -> Upgrade | None:
"""@brief Buy the most expensive upgrade the player can afford (lecture step 3).
@param game The game to play.
@return The upgrade that was bought, or None when nothing was affordable or
the click did not reach the product.
"""
chosen = choose_most_expensive_affordable(game.upgrades(), game.cookie_count())
if chosen is not None and game.buy(chosen):
return chosen
return None
def buy_upgrades(game: GamePort, strategy: BuyStrategy) -> list[Upgrade]:
"""@brief Do one purchase round with the given strategy (lecture step 5).
@param game The game to play.
@param strategy BUY_ONE buys the most expensive affordable upgrade, BUY_ALL buys
every upgrade the cookies pay for.
@return The upgrades really bought, in order; a click that missed is left out.
"""
if strategy is BuyStrategy.BUY_ONE:
bought = buy_most_expensive_upgrade(game)
return [] if bought is None else [bought]
plan = choose_affordable_upgrades(game.upgrades(), game.cookie_count())
return [upgrade for upgrade in plan if game.buy(upgrade)]
def click_and_buy(
game: GamePort,
duration_seconds: float,
check_interval_seconds: float,
clock: Callable[[], float] = time.monotonic,
strategy: BuyStrategy = BuyStrategy.BUY_ONE,
) -> PlayResult:
"""@brief Click the cookie nonstop and check the upgrades at every interval.
@param game The game to play.
@param duration_seconds How long to play.
@param check_interval_seconds Time between two upgrade checks; the first check
comes one interval after the start.
@param clock Monotonic clock in seconds; a test passes a fake one.
@param strategy What to buy at each check.
@return The clicks, the names of the upgrades bought in order, and the time spent.
"""
start = clock()
deadline = start + duration_seconds
next_check = start + check_interval_seconds
clicks = 0
purchases: list[str] = []
while (now := clock()) < deadline:
click_cookie(game)
clicks += 1
if now >= next_check:
next_check = now + check_interval_seconds
purchases.extend(upgrade.name for upgrade in buy_upgrades(game, strategy))
return PlayResult(
clicks=clicks, purchases=tuple(purchases), elapsed_seconds=clock() - start
)
@@ -0,0 +1,59 @@
"""@file ports.py
@brief The port through which the game loop talks to the game.
"""
from pathlib import Path
from typing import Protocol
from cookie_clicker_bot.domain.upgrades import Upgrade
class GamePort(Protocol):
"""@brief What the bot needs from the game, whatever drives the browser."""
def open_game(self) -> None:
"""@brief Load the game page."""
def dismiss_language(self) -> None:
"""@brief Pick English on the language selection and wait for the game."""
def click_cookie(self) -> None:
"""@brief Click the big cookie once."""
def cookie_count(self) -> int:
"""@brief Read how many cookies the player has.
@return The cookie count.
"""
...
def cookies_per_second(self) -> float:
"""@brief Read how many cookies the player makes each second.
@return The cookies per second.
"""
...
def upgrades(self) -> list[Upgrade]:
"""@brief Read the upgrades the player can see in the right-hand pane.
@return The visible upgrades, affordable or not, in store order.
"""
...
def buy(self, upgrade: Upgrade) -> bool:
"""@brief Buy an upgrade by clicking it.
@param upgrade The upgrade to buy.
@return True only when a click really reached the product.
"""
...
def save_screenshot(self, path: Path) -> None:
"""@brief Write a screenshot of the game to a PNG file.
@param path Target file; its folder is created when missing.
"""
def close(self) -> None:
"""@brief Close the browser."""
@@ -0,0 +1,16 @@
"""@file report.py
@brief The text the program prints when a run ends.
"""
from cookie_clicker_bot import constants
def format_result(cookies_per_second: float) -> str:
"""@brief Build the result line, for example `cookies/second: 8.5`.
@param cookies_per_second The cookies per second at the end of the run.
@return The line, without a trailing newline.
"""
return constants.RESULT_MESSAGE_TEMPLATE.format(
cookies_per_second=cookies_per_second
)
+139
View File
@@ -0,0 +1,139 @@
"""@file cli.py
@brief Command-line entry point (the composition root of the bot).
"""
import argparse
import logging
from collections.abc import Callable, Sequence
from pathlib import Path
from cookie_clicker_bot import constants
from cookie_clicker_bot.application.game_loop import click_and_buy
from cookie_clicker_bot.application.ports import GamePort
from cookie_clicker_bot.application.report import format_result
from cookie_clicker_bot.domain.buy_strategy import BuyStrategy
from cookie_clicker_bot.infrastructure.browser import create_driver
from cookie_clicker_bot.infrastructure.selenium_game import SeleniumGame
logger = logging.getLogger(__name__)
def create_game(headless: bool) -> GamePort:
"""@brief Build the Selenium game.
@param headless True to run Chromium without a window.
@return The game, ready to open.
"""
return SeleniumGame(create_driver(headless))
def positive_float(text: str) -> float:
"""@brief Read a number greater than zero from the command line.
@param text The argument text.
@return The number.
@throws argparse.ArgumentTypeError When the text is not a number above zero.
"""
message = constants.POSITIVE_NUMBER_ERROR_TEMPLATE.format(value=text)
try:
value = float(text)
except ValueError:
raise argparse.ArgumentTypeError(message) from None
if not value > 0:
raise argparse.ArgumentTypeError(message)
return value
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
"""@brief Read the command line.
@param argv Arguments without the program name; None reads `sys.argv`.
@return The parsed options.
"""
parser = argparse.ArgumentParser(
prog="cookie-clicker-bot",
description=(
"Plays Cookie Clicker: clicks the big cookie as fast as it can "
"and buys upgrades."
),
)
parser.add_argument(
"--duration",
type=float,
default=constants.RUN_DURATION_SECONDS,
help="seconds to play (default: %(default)s)",
)
parser.add_argument(
"--interval",
type=positive_float,
default=constants.UPGRADE_CHECK_INTERVAL_SECONDS,
help="seconds between upgrade checks, greater than 0 (default: %(default)s)",
)
parser.add_argument(
"--buy-all",
action="store_true",
help=(
"at each check buy every affordable upgrade, most expensive first "
"(default: buy only the most expensive affordable one)"
),
)
parser.add_argument(
"--headless",
action="store_true",
help="run Chromium without a window (the Docker image does this)",
)
parser.add_argument(
"--screenshot-start",
type=Path,
help="write a screenshot of the game before the first click",
)
parser.add_argument(
"--screenshot",
type=Path,
help="write a screenshot of the game when the run ends",
)
return parser.parse_args(argv)
def main(
argv: Sequence[str] | None = None,
game_factory: Callable[[bool], GamePort] = create_game,
) -> int:
"""@brief Start the bot.
@param argv Arguments without the program name; None reads `sys.argv`.
@param game_factory Builds the game; a test passes a fake.
@return Process exit code, 0 when the run finished.
"""
logging.basicConfig(level=logging.INFO, format="%(message)s")
args = parse_args(argv)
game = game_factory(args.headless)
try:
game.open_game()
game.dismiss_language()
if args.screenshot_start is not None:
game.save_screenshot(args.screenshot_start)
strategy = BuyStrategy.BUY_ALL if args.buy_all else BuyStrategy.BUY_ONE
logger.info(
"checking upgrades every %s seconds, strategy %s",
args.interval,
strategy.value,
)
result = click_and_buy(game, args.duration, args.interval, strategy=strategy)
for name in result.purchases:
logger.info("bought %s", name)
logger.info(
"clicked the cookie %d times and bought %d upgrades in %.1f seconds",
result.clicks,
len(result.purchases),
result.elapsed_seconds,
)
cookies_per_second = game.cookies_per_second()
logger.info("%s", format_result(cookies_per_second))
if args.screenshot is not None:
game.save_screenshot(args.screenshot)
finally:
game.close()
# The program's required result: the only line written to stdout (logs use stderr).
print(format_result(cookies_per_second))
return 0
+98
View File
@@ -0,0 +1,98 @@
"""@file constants.py
@brief Every constant of the bot: the game address, selectors, timings and messages.
Selectors of the game page are added here by the milestone that first uses them.
"""
from typing import Final
## Address of the new version of Cookie Clicker on GitHub Pages (ozh/cookieclicker).
GAME_URL: Final[str] = "https://ozh.github.io/cookieclicker/"
## Total playing time of one run, in seconds (lecture step 4: five minutes).
RUN_DURATION_SECONDS: Final[int] = 300
## Seconds between two checks for an affordable upgrade (lecture step 3).
UPGRADE_CHECK_INTERVAL_SECONDS: Final[int] = 5
## Error shown when a command-line number must be greater than zero.
POSITIVE_NUMBER_ERROR_TEMPLATE: Final[str] = "{value!r} is not a number greater than 0"
## Longest time to wait for an element of the page, in seconds.
WAIT_TIMEOUT_SECONDS: Final[int] = 20
## Folder where screenshots of a container run are written.
SCREENSHOT_DIR: Final[str] = "docs/images"
## Id of the button that picks English on the language selection page.
LANGUAGE_ENGLISH_ID: Final[str] = "langSelect-EN"
## Id of the big cookie in the left-hand pane.
BIG_COOKIE_ID: Final[str] = "bigCookie"
## Id of the element that shows the cookie count and the cookies per second.
COOKIE_COUNT_ID: Final[str] = "cookies"
## Id of the element that shows only the cookies per second (child of `#cookies`).
COOKIES_PER_SECOND_ID: Final[str] = "cookiesPerSecond"
## The one line the program prints on stdout when the run ends.
RESULT_MESSAGE_TEMPLATE: Final[str] = "cookies/second: {cookies_per_second:.12g}"
## CSS selector of the products in the right-hand pane that the player can see.
VISIBLE_PRODUCT_SELECTOR: Final[str] = ".product.unlocked"
## CSS selector of the name inside a product.
PRODUCT_NAME_SELECTOR: Final[str] = ".productName"
## CSS selector of the price inside a product.
PRODUCT_PRICE_SELECTOR: Final[str] = ".price"
## Attribute of a product element that holds its id (product0 .. product19).
ID_ATTRIBUTE: Final[str] = "id"
## Text in `#cookies` after which the cookies per second follow; the count ends here.
PER_SECOND_MARKER: Final[str] = "per second"
## The words the game puts after the cookie count (the longer word first).
COOKIE_WORDS: Final[tuple[str, ...]] = ("cookies", "cookie")
## Multiplier of each magnitude word the game writes in numbers (1.4 million).
MAGNITUDE_MULTIPLIERS: Final[dict[str, int]] = {
"thousand": 10**3,
"million": 10**6,
"billion": 10**9,
"trillion": 10**12,
"quadrillion": 10**15,
"quintillion": 10**18,
"sextillion": 10**21,
"septillion": 10**24,
"octillion": 10**27,
"nonillion": 10**30,
"decillion": 10**33,
}
## Seconds between two polls while waiting for an element.
POLL_INTERVAL_SECONDS: Final[float] = 0.2
## Total attempts to read a value the game re-rendered under us (stale element).
STALE_READ_ATTEMPTS: Final[int] = 5
## Environment variable that names the Chromium binary (set in the Docker image).
CHROME_BIN_ENV: Final[str] = "CHROME_BIN"
## Environment variable that names the Chromium driver (set in the Docker image).
CHROMEDRIVER_PATH_ENV: Final[str] = "CHROMEDRIVER_PATH"
## Size of the browser window in pixels, so the screenshots look the same every time.
WINDOW_SIZE: Final[str] = "1366,900"
## Script that clicks an element from inside the page, past anything that covers it.
JS_CLICK_SCRIPT: Final[str] = "arguments[0].click();"
## Chromium arguments needed to run headless inside a container.
HEADLESS_BROWSER_ARGUMENTS: Final[tuple[str, ...]] = (
"--headless=new",
"--no-sandbox",
"--disable-dev-shm-usage",
)
@@ -0,0 +1,3 @@
"""@file __init__.py
@brief Domain layer: the rules of the bot, free of Selenium.
"""
@@ -0,0 +1,14 @@
"""@file buy_strategy.py
@brief How many upgrades the bot buys at each check.
"""
from enum import Enum
class BuyStrategy(Enum):
"""@brief The purchase strategy of one upgrade check (lecture step 5)."""
## Buy only the most expensive affordable upgrade (the assignment).
BUY_ONE = "buy-one"
## Buy every upgrade the cookies pay for, most expensive first.
BUY_ALL = "buy-all"
+69
View File
@@ -0,0 +1,69 @@
"""@file parsing.py
@brief Turn the texts of the game page into whole numbers of cookies.
"""
from decimal import Decimal, InvalidOperation
from cookie_clicker_bot import constants
def parse_amount(text: str) -> int:
"""@brief Read an amount such as `1,100`, `15` or `1.4 million`.
@param text The text of a price or a count, with optional thousands commas
and an optional magnitude word.
@return The amount as a whole number.
@throws ValueError When the text is not an amount.
"""
return int(_read_amount(text))
def parse_cookies_per_second(text: str) -> float:
"""@brief Read the text of `#cookiesPerSecond`, such as `per second: 1,234.5`.
@param text The visible text of the element, with its `per second:` label.
@return The cookies per second.
@throws ValueError When the text holds no amount.
"""
amount = text.lower().replace(constants.PER_SECOND_MARKER, "", 1).strip()
return float(_read_amount(amount.removeprefix(":")))
def parse_cookie_count(text: str) -> int:
"""@brief Read the cookie count from the whole text of the `#cookies` element.
The text looks like `5,000 cookies` then a line `per second: 0`; a big count
puts the magnitude word on the second line: `1.235`, `million cookies`.
@param text The visible text of the element.
@return The number of cookies.
@throws ValueError When the text holds no count.
"""
count = text.lower().split(constants.PER_SECOND_MARKER)[0]
for word in constants.COOKIE_WORDS:
count = count.replace(word, "")
return parse_amount(" ".join(count.split()))
def _read_amount(text: str) -> Decimal:
"""@brief Read an amount with optional commas and magnitude word, exactly."""
words = text.strip().lower().replace(",", "").split()
if not 1 <= len(words) <= 2:
raise ValueError(_message(text))
multiplier = 1
if len(words) == 2:
multiplier = constants.MAGNITUDE_MULTIPLIERS.get(words[1], 0)
if multiplier == 0:
raise ValueError(_message(text))
try:
number = Decimal(words[0])
except InvalidOperation:
raise ValueError(_message(text)) from None
if not number.is_finite() or number < 0:
raise ValueError(_message(text))
return number * multiplier
def _message(text: str) -> str:
"""@brief Build the error message for a text that is not an amount."""
return f"not an amount of cookies: {text!r}"
+52
View File
@@ -0,0 +1,52 @@
"""@file upgrades.py
@brief The rule for which upgrade to buy.
"""
from collections.abc import Sequence
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Upgrade:
"""@brief A product of the right-hand pane that can be bought."""
## Id of the product element on the page (product0 .. product19).
key: str
## Name shown in the store, for example Cursor.
name: str
## Price in cookies.
price: int
def choose_affordable_upgrades(
upgrades: Sequence[Upgrade], cookies: int
) -> list[Upgrade]:
"""@brief Plan a buy-all round: as many upgrades as the cookies pay for.
Walks the upgrades from most expensive to cheapest (the list order on a tie) and
takes each one whose price still fits in the cookies left.
@param upgrades The visible upgrades, in store order.
@param cookies The cookies the player has.
@return The upgrades to buy, most expensive first; empty when none is affordable.
"""
plan: list[Upgrade] = []
remaining = cookies
for upgrade in sorted(upgrades, key=lambda candidate: -candidate.price):
if upgrade.price <= remaining:
plan.append(upgrade)
remaining -= upgrade.price
return plan
def choose_most_expensive_affordable(
upgrades: Sequence[Upgrade], cookies: int
) -> Upgrade | None:
"""@brief Pick the most expensive upgrade the player can pay for.
@param upgrades The visible upgrades, in store order.
@param cookies The cookies the player has.
@return The chosen upgrade (the first on a tie), or None when none is affordable.
"""
plan = choose_affordable_upgrades(upgrades, cookies)
return plan[0] if plan else None
@@ -0,0 +1,3 @@
"""@file __init__.py
@brief Infrastructure layer: the Selenium adapter that drives the browser.
"""
@@ -0,0 +1,33 @@
"""@file browser.py
@brief Creates the Chromium driver, headless when asked.
"""
import os
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
from cookie_clicker_bot import constants
def create_driver(headless: bool) -> webdriver.Chrome:
"""@brief Start Chromium.
The binary and the driver come from `CHROME_BIN` and `CHROMEDRIVER_PATH` when
these are set (the Docker image sets them); otherwise Selenium finds them.
@param headless True to run without a window, as in the Docker container.
@return A running Chromium driver.
"""
options = Options()
options.add_argument(f"--window-size={constants.WINDOW_SIZE}")
if headless:
for argument in constants.HEADLESS_BROWSER_ARGUMENTS:
options.add_argument(argument)
binary = os.environ.get(constants.CHROME_BIN_ENV)
if binary:
options.binary_location = binary
driver_path = os.environ.get(constants.CHROMEDRIVER_PATH_ENV)
service = Service(executable_path=driver_path) if driver_path else Service()
return webdriver.Chrome(service=service, options=options)
@@ -0,0 +1,229 @@
"""@file selenium_game.py
@brief Selenium adapter that implements the game port.
"""
import time
from collections.abc import Callable, Sequence
from pathlib import Path
from typing import Any, Protocol, TypeVar
from selenium.common.exceptions import (
ElementClickInterceptedException,
NoSuchElementException,
StaleElementReferenceException,
)
from selenium.webdriver.common.by import By
from cookie_clicker_bot import constants
from cookie_clicker_bot.domain.parsing import (
parse_amount,
parse_cookie_count,
parse_cookies_per_second,
)
from cookie_clicker_bot.domain.upgrades import Upgrade
T = TypeVar("T")
class ElementLike(Protocol):
"""@brief The part of a Selenium element that the adapter uses."""
@property
def text(self) -> str:
"""@brief Visible text of the element."""
...
def click(self) -> None:
"""@brief Click the element."""
def is_displayed(self) -> bool:
"""@brief True when the element is visible."""
...
def get_attribute(self, name: str) -> str | None:
"""@brief Read an attribute of the element."""
...
def find_element(self, by: str, value: str) -> "ElementLike":
"""@brief Find one element inside this element."""
...
class DriverLike(Protocol):
"""@brief The part of a Selenium driver that the adapter uses."""
def get(self, url: str) -> None:
"""@brief Load a page."""
def find_element(self, by: str, value: str) -> ElementLike:
"""@brief Find one element."""
...
def find_elements(self, by: str, value: str) -> Sequence[ElementLike]:
"""@brief Find every element that matches."""
...
def save_screenshot(self, filename: str) -> bool:
"""@brief Write a PNG screenshot."""
...
def execute_script(self, script: str, *args: Any) -> Any: # noqa: ANN401
"""@brief Run JavaScript in the page; the arguments are `arguments[n]`."""
...
def quit(self) -> None:
"""@brief Close the browser."""
class SeleniumGame:
"""@brief Plays the game page through a Selenium driver."""
def __init__(
self,
driver: DriverLike,
timeout_seconds: float = constants.WAIT_TIMEOUT_SECONDS,
clock: Callable[[], float] = time.monotonic,
sleep: Callable[[float], None] = time.sleep,
) -> None:
"""@brief Wrap a driver.
@param driver The Selenium driver (or a fake in tests).
@param timeout_seconds Longest wait for an element.
@param clock Monotonic clock in seconds.
@param sleep Pause function used between polls.
"""
self._driver = driver
self._timeout = timeout_seconds
self._clock = clock
self._sleep = sleep
def open_game(self) -> None:
"""@brief Load the game page."""
self._driver.get(constants.GAME_URL)
def dismiss_language(self) -> None:
"""@brief Pick English, then wait until the big cookie can be clicked."""
english = self._wait_for(lambda: self._visible(constants.LANGUAGE_ENGLISH_ID))
english.click()
self._wait_for(lambda: self._visible(constants.BIG_COOKIE_ID))
def click_cookie(self) -> None:
"""@brief Click the big cookie once."""
try:
self._driver.find_element(By.ID, constants.BIG_COOKIE_ID).click()
except (ElementClickInterceptedException, StaleElementReferenceException):
# A banner or a re-render covered the cookie for a moment; try again.
return
def cookie_count(self) -> int:
"""@brief Read how many cookies the player has.
@return The cookie count.
"""
return self._read_again_if_stale(
lambda: parse_cookie_count(
self._driver.find_element(By.ID, constants.COOKIE_COUNT_ID).text
)
)
def cookies_per_second(self) -> float:
"""@brief Read how many cookies the player makes each second.
@return The cookies per second.
"""
return self._read_again_if_stale(
lambda: parse_cookies_per_second(
self._driver.find_element(By.ID, constants.COOKIES_PER_SECOND_ID).text
)
)
def upgrades(self) -> list[Upgrade]:
"""@brief Read the upgrades the player can see in the right-hand pane.
@return The visible upgrades, affordable or not, in store order.
"""
return self._read_again_if_stale(self._read_upgrades)
def _read_upgrades(self) -> list[Upgrade]:
"""@brief Find the visible products and read each one."""
products = self._driver.find_elements(
By.CSS_SELECTOR, constants.VISIBLE_PRODUCT_SELECTOR
)
return [self._read_upgrade(product) for product in products]
def _read_again_if_stale(self, read: Callable[[], T]) -> T:
"""@brief Run a read; repeat it while the page re-renders under it.
@throws StaleElementReferenceException When every attempt went stale.
"""
for _ in range(constants.STALE_READ_ATTEMPTS - 1):
try:
return read()
except StaleElementReferenceException:
self._sleep(constants.POLL_INTERVAL_SECONDS)
return read()
def buy(self, upgrade: Upgrade) -> bool:
"""@brief Buy an upgrade by clicking its product.
@param upgrade The upgrade to buy.
@return True only when a click really reached the product.
"""
try:
element = self._driver.find_element(By.ID, upgrade.key)
try:
element.click()
except ElementClickInterceptedException:
# The cookie-consent banner covers the lower products. Click from
# inside the page instead; the banner itself is never clicked.
self._driver.execute_script(constants.JS_CLICK_SCRIPT, element)
except StaleElementReferenceException:
# The pane re-rendered under us; the next check retries.
return False
return True
def save_screenshot(self, path: Path) -> None:
"""@brief Write a screenshot of the game to a PNG file.
@param path Target file; its folder is created when missing.
"""
path.parent.mkdir(parents=True, exist_ok=True)
self._driver.save_screenshot(str(path))
def close(self) -> None:
"""@brief Close the browser."""
self._driver.quit()
@staticmethod
def _read_upgrade(product: ElementLike) -> Upgrade:
"""@brief Read the id, name and price of one product element."""
name = product.find_element(By.CSS_SELECTOR, constants.PRODUCT_NAME_SELECTOR)
price = product.find_element(By.CSS_SELECTOR, constants.PRODUCT_PRICE_SELECTOR)
key = product.get_attribute(constants.ID_ATTRIBUTE) or ""
return Upgrade(key=key, name=name.text.strip(), price=parse_amount(price.text))
def _visible(self, element_id: str) -> ElementLike | None:
"""@brief Return the element with this id when it exists and is displayed."""
try:
element = self._driver.find_element(By.ID, element_id)
return element if element.is_displayed() else None
except (NoSuchElementException, StaleElementReferenceException):
# Not there yet, or the page re-rendered under us: poll again.
return None
def _wait_for(self, locate: Callable[[], T | None]) -> T:
"""@brief Poll until `locate` returns a value.
@param locate Function that returns the value, or None while not ready.
@return The value.
@throws TimeoutError When the value is not there within the timeout.
"""
deadline = self._clock() + self._timeout
while True:
found = locate()
if found is not None:
return found
if self._clock() >= deadline:
message = f"the game page was not ready within {self._timeout} s"
raise TimeoutError(message)
self._sleep(constants.POLL_INTERVAL_SECONDS)
+429
View File
@@ -0,0 +1,429 @@
"""@file test_selenium_game.py
@brief Tests of the Selenium adapter against a fake driver (no browser).
"""
from collections.abc import Callable
from pathlib import Path
from typing import Any
import pytest
from selenium.common.exceptions import (
ElementClickInterceptedException,
NoSuchElementException,
StaleElementReferenceException,
)
from cookie_clicker_bot import constants
from cookie_clicker_bot.domain.upgrades import Upgrade
from cookie_clicker_bot.infrastructure.selenium_game import SeleniumGame
class FakeElement:
"""An element that records clicks and may raise on click."""
def __init__(
self,
displayed: Callable[[], bool] = lambda: True,
click_error: Exception | None = None,
text: str = "",
attributes: dict[str, str] | None = None,
children: dict[str, "FakeElement"] | None = None,
) -> None:
self.text = text
self.clicks = 0
self._displayed = displayed
self._click_error = click_error
self._attributes = attributes or {}
self._children = children or {}
def get_attribute(self, name: str) -> str | None:
return self._attributes.get(name)
def find_element(self, by: str, value: str) -> "FakeElement":
if by != "css selector" or value not in self._children:
raise NoSuchElementException(value)
return self._children[value]
def click(self) -> None:
if self._click_error is not None:
raise self._click_error
self.clicks += 1
def is_displayed(self) -> bool:
return self._displayed()
class FakeDriver:
"""A driver with elements by id."""
def __init__(self, elements: dict[str, FakeElement] | None = None) -> None:
self.elements = elements or {}
self.groups: dict[str, list[FakeElement]] = {}
self.loaded: list[str] = []
self.screenshots: list[str] = []
self.quit_called = False
self.scripts: list[tuple[str, FakeElement]] = []
def execute_script(self, script: str, *args: Any) -> None: # noqa: ANN401
self.scripts.append((script, args[0]))
def get(self, url: str) -> None:
self.loaded.append(url)
def find_element(self, by: str, value: str) -> FakeElement:
if by != "id" or value not in self.elements:
raise NoSuchElementException(value)
return self.elements[value]
def find_elements(self, by: str, value: str) -> list[FakeElement]:
if by != "css selector":
return []
return self.groups.get(value, [])
def save_screenshot(self, filename: str) -> bool:
self.screenshots.append(filename)
return True
def quit(self) -> None:
self.quit_called = True
class FakeTime:
"""A clock that only moves when the fake sleep is called."""
def __init__(self) -> None:
self.now = 0.0
self.sleeps: list[float] = []
def clock(self) -> float:
return self.now
def sleep(self, seconds: float) -> None:
self.sleeps.append(seconds)
self.now += seconds
def make_game(driver: FakeDriver, fake_time: FakeTime | None = None) -> SeleniumGame:
fake_time = fake_time or FakeTime()
return SeleniumGame(
driver, timeout_seconds=1.0, clock=fake_time.clock, sleep=fake_time.sleep
)
def ready_driver() -> FakeDriver:
return FakeDriver(
{
constants.LANGUAGE_ENGLISH_ID: FakeElement(),
constants.BIG_COOKIE_ID: FakeElement(),
}
)
def test_open_game_loads_the_game_address() -> None:
driver = FakeDriver()
make_game(driver).open_game()
assert driver.loaded == [constants.GAME_URL]
def test_dismiss_language_clicks_english_and_waits_for_the_cookie() -> None:
driver = ready_driver()
make_game(driver).dismiss_language()
assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1
def test_dismiss_language_polls_until_the_button_is_visible() -> None:
fake_time = FakeTime()
driver = ready_driver()
driver.elements[constants.LANGUAGE_ENGLISH_ID] = FakeElement(
displayed=lambda: fake_time.now >= 0.4
)
make_game(driver, fake_time).dismiss_language()
assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1
assert fake_time.sleeps == [constants.POLL_INTERVAL_SECONDS] * 2
def stale_once() -> Callable[[], bool]:
"""Return an `is_displayed` that goes stale on the first call only."""
calls: list[int] = []
def displayed() -> bool:
calls.append(1)
if len(calls) == 1:
raise StaleElementReferenceException
return True
return displayed
def test_dismiss_language_survives_a_cookie_that_goes_stale_once() -> None:
driver = ready_driver()
driver.elements[constants.BIG_COOKIE_ID] = FakeElement(displayed=stale_once())
make_game(driver).dismiss_language()
assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1
def test_dismiss_language_survives_a_button_that_goes_stale_once() -> None:
driver = ready_driver()
driver.elements[constants.LANGUAGE_ENGLISH_ID] = FakeElement(displayed=stale_once())
make_game(driver).dismiss_language()
assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1
def test_dismiss_language_survives_a_lookup_that_goes_stale_once() -> None:
driver = ready_driver()
real_find = driver.find_element
calls: list[int] = []
def find_element(by: str, value: str) -> FakeElement:
calls.append(1)
if len(calls) == 1:
raise StaleElementReferenceException
return real_find(by, value)
driver.find_element = find_element # type: ignore[method-assign]
make_game(driver).dismiss_language()
assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1
def test_dismiss_language_times_out_when_the_button_never_shows() -> None:
fake_time = FakeTime()
with pytest.raises(TimeoutError):
make_game(FakeDriver(), fake_time).dismiss_language()
assert fake_time.now >= 1.0
def test_dismiss_language_times_out_when_the_cookie_never_shows() -> None:
driver = FakeDriver({constants.LANGUAGE_ENGLISH_ID: FakeElement()})
with pytest.raises(TimeoutError):
make_game(driver).dismiss_language()
def test_click_cookie_clicks_the_big_cookie() -> None:
driver = ready_driver()
make_game(driver).click_cookie()
assert driver.elements[constants.BIG_COOKIE_ID].clicks == 1
@pytest.mark.parametrize(
"error",
[ElementClickInterceptedException(), StaleElementReferenceException()],
)
def test_click_cookie_ignores_a_covered_or_stale_cookie(error: Exception) -> None:
driver = FakeDriver({constants.BIG_COOKIE_ID: FakeElement(click_error=error)})
make_game(driver).click_cookie()
def make_product(key: str, name: str, price: str) -> FakeElement:
return FakeElement(
attributes={"id": key},
children={
constants.PRODUCT_NAME_SELECTOR: FakeElement(text=name),
constants.PRODUCT_PRICE_SELECTOR: FakeElement(text=price),
},
)
def test_cookie_count_reads_the_cookie_counter() -> None:
driver = FakeDriver(
{
constants.COOKIE_COUNT_ID: FakeElement(
text="1.235\nmillion cookies\nper second: 0"
)
}
)
assert make_game(driver).cookie_count() == 1_235_000
def test_cookies_per_second_reads_its_element() -> None:
driver = FakeDriver(
{constants.COOKIES_PER_SECOND_ID: FakeElement(text="per second: 1,234.5")}
)
assert make_game(driver).cookies_per_second() == 1234.5
def test_upgrades_reads_the_visible_products() -> None:
driver = FakeDriver()
driver.groups[constants.VISIBLE_PRODUCT_SELECTOR] = [
make_product("product0", "Cursor", "15"),
make_product("product2", "Farm", "1,100"),
]
assert make_game(driver).upgrades() == [
Upgrade("product0", "Cursor", 15),
Upgrade("product2", "Farm", 1100),
]
class StaleOnceText(FakeElement):
"""An element whose text goes stale on the first read."""
def __init__(self, text: str) -> None:
super().__init__()
self._text = text
self._reads = 0
@property
def text(self) -> str:
self._reads += 1
if self._reads == 1:
raise StaleElementReferenceException
return self._text
@text.setter
def text(self, value: str) -> None:
self._text = value
def test_cookie_count_retries_once_when_the_counter_goes_stale() -> None:
driver = FakeDriver({constants.COOKIE_COUNT_ID: StaleOnceText("42 cookies")})
assert make_game(driver).cookie_count() == 42
def test_cookies_per_second_retries_once_when_the_element_goes_stale() -> None:
driver = FakeDriver(
{constants.COOKIES_PER_SECOND_ID: StaleOnceText("per second: 3.5")}
)
assert make_game(driver).cookies_per_second() == 3.5
def test_upgrades_retries_once_when_a_product_goes_stale() -> None:
product = make_product("product0", "Cursor", "15")
product.get_attribute = _stale_once_then(product.get_attribute) # type: ignore[assignment]
driver = FakeDriver()
driver.groups[constants.VISIBLE_PRODUCT_SELECTOR] = [product]
assert make_game(driver).upgrades() == [Upgrade("product0", "Cursor", 15)]
def test_cookies_per_second_succeeds_after_four_stale_attempts() -> None:
driver = FakeDriver()
attempts = _stale_for(4, FakeElement(text="per second: 3.5"))
driver.find_element = attempts.find_element # type: ignore[method-assign]
fake_time = FakeTime()
assert make_game(driver, fake_time).cookies_per_second() == 3.5
assert attempts.calls == 5
assert fake_time.sleeps == [constants.POLL_INTERVAL_SECONDS] * 4
def test_cookie_count_gives_up_when_the_counter_stays_stale() -> None:
driver = FakeDriver()
attempts = _stale_for(100, FakeElement(text="42 cookies"))
driver.find_element = attempts.find_element # type: ignore[method-assign]
fake_time = FakeTime()
with pytest.raises(StaleElementReferenceException):
make_game(driver, fake_time).cookie_count()
assert attempts.calls == constants.STALE_READ_ATTEMPTS
assert fake_time.sleeps == [constants.POLL_INTERVAL_SECONDS] * (
constants.STALE_READ_ATTEMPTS - 1
)
class _StaleFor:
"""A `find_element` that goes stale for the first `stale` calls."""
def __init__(self, stale: int, element: FakeElement) -> None:
self._stale = stale
self._element = element
self.calls = 0
def find_element(self, by: str, value: str) -> FakeElement:
self.calls += 1
if self.calls <= self._stale:
raise StaleElementReferenceException
return self._element
def _stale_for(stale: int, element: FakeElement) -> _StaleFor:
return _StaleFor(stale, element)
def _stale_once_then(
original: Callable[[str], str | None],
) -> Callable[[str], str | None]:
calls: list[int] = []
def get_attribute(name: str) -> str | None:
calls.append(1)
if len(calls) == 1:
raise StaleElementReferenceException
return original(name)
return get_attribute
def test_upgrades_is_empty_when_no_product_is_visible() -> None:
assert make_game(FakeDriver()).upgrades() == []
def test_buy_clicks_the_product_with_the_upgrade_key() -> None:
driver = FakeDriver({"product1": FakeElement()})
bought = make_game(driver).buy(Upgrade("product1", "Grandma", 100))
assert bought is True
assert driver.elements["product1"].clicks == 1
assert driver.scripts == []
def test_buy_clicks_through_javascript_when_a_banner_covers_the_product() -> None:
element = FakeElement(click_error=ElementClickInterceptedException())
driver = FakeDriver({"product1": element})
bought = make_game(driver).buy(Upgrade("product1", "Grandma", 100))
assert bought is True
assert driver.scripts == [(constants.JS_CLICK_SCRIPT, element)]
def test_buy_returns_false_for_a_stale_product() -> None:
driver = FakeDriver(
{"product1": FakeElement(click_error=StaleElementReferenceException())}
)
assert make_game(driver).buy(Upgrade("product1", "Grandma", 100)) is False
assert driver.scripts == []
def test_save_screenshot_creates_the_folder_and_calls_the_driver(
tmp_path: Path,
) -> None:
driver = FakeDriver()
target = tmp_path / "images" / "shot.png"
make_game(driver).save_screenshot(target)
assert target.parent.is_dir()
assert driver.screenshots == [str(target)]
def test_close_quits_the_driver() -> None:
driver = FakeDriver()
make_game(driver).close()
assert driver.quit_called
+216
View File
@@ -0,0 +1,216 @@
"""@file test_cli.py
@brief Tests of the command line and the run sequence with a fake game.
"""
from pathlib import Path
import pytest
from cookie_clicker_bot import constants
from cookie_clicker_bot.application.ports import GamePort
from cookie_clicker_bot.cli import main, parse_args
from cookie_clicker_bot.domain.upgrades import Upgrade
class RecordingGame:
"""A game that records every call in order."""
def __init__(self, fail_on_open: bool = False, buy_succeeds: bool = True) -> None:
self._buy_succeeds = buy_succeeds
self.calls: list[str] = []
self.screenshots: list[Path] = []
self._fail_on_open = fail_on_open
def open_game(self) -> None:
self.calls.append("open_game")
if self._fail_on_open:
message = "boom"
raise RuntimeError(message)
def dismiss_language(self) -> None:
self.calls.append("dismiss_language")
def click_cookie(self) -> None:
self.calls.append("click_cookie")
def cookie_count(self) -> int:
return 1000
def cookies_per_second(self) -> float:
return 8.5
def upgrades(self) -> list[Upgrade]:
return [Upgrade("product0", "Cursor", 15)]
def buy(self, upgrade: Upgrade) -> bool:
self.calls.append("buy")
return self._buy_succeeds
def save_screenshot(self, path: Path) -> None:
self.calls.append("save_screenshot")
self.screenshots.append(path)
def close(self) -> None:
self.calls.append("close")
def test_parse_args_uses_defaults() -> None:
args = parse_args([])
assert args.duration == constants.RUN_DURATION_SECONDS == 300
assert args.interval == constants.UPGRADE_CHECK_INTERVAL_SECONDS == 5
assert args.headless is False
assert args.screenshot_start is None
assert args.screenshot is None
def test_parse_args_reads_every_option() -> None:
args = parse_args(
[
"--headless",
"--duration",
"10",
"--interval",
"2.5",
"--screenshot-start",
"a.png",
"--screenshot",
"b.png",
]
)
assert args.headless is True
assert args.duration == 10
assert args.interval == 2.5
assert args.screenshot_start == Path("a.png")
assert args.screenshot == Path("b.png")
def test_main_opens_dismisses_and_closes() -> None:
game = RecordingGame()
code = main(["--duration", "0"], lambda _headless: game)
assert code == 0
assert game.calls == ["open_game", "dismiss_language", "close"]
def test_main_passes_headless_to_the_factory() -> None:
seen: list[bool] = []
def factory(headless: bool) -> GamePort:
seen.append(headless)
return RecordingGame()
main(["--headless", "--duration", "0"], factory)
assert seen == [True]
def test_main_takes_both_screenshots_when_asked() -> None:
game = RecordingGame()
main(
["--duration", "0", "--screenshot-start", "s.png", "--screenshot", "e.png"],
lambda _headless: game,
)
assert game.screenshots == [Path("s.png"), Path("e.png")]
assert game.calls[-1] == "close"
def test_main_takes_no_screenshot_by_default() -> None:
game = RecordingGame()
main(["--duration", "0"], lambda _headless: game)
assert game.screenshots == []
def test_main_closes_the_game_when_an_error_is_raised() -> None:
game = RecordingGame(fail_on_open=True)
with pytest.raises(RuntimeError, match="boom"):
main(["--duration", "0"], lambda _headless: game)
assert game.calls == ["open_game", "close"]
def test_main_prints_exactly_one_result_line_on_stdout(
capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture
) -> None:
caplog.set_level("INFO")
main(["--duration", "0"], lambda _headless: RecordingGame())
out = capsys.readouterr().out
assert out == "cookies/second: 8.5" + chr(10)
assert "clicked the cookie" not in out
assert "clicked the cookie" in caplog.text
def test_main_logs_bought_only_for_real_purchases(
caplog: pytest.LogCaptureFixture,
) -> None:
caplog.set_level("INFO")
main(["--duration", "0.05", "--interval", "0.001"], lambda _h: RecordingGame())
assert "bought Cursor" in caplog.text
caplog.clear()
game = RecordingGame(buy_succeeds=False)
main(["--duration", "0.05", "--interval", "0.001"], lambda _h: game)
assert "buy" in game.calls
assert "bought Cursor" not in caplog.text
assert "bought 0 upgrades" in caplog.text
def test_main_prints_nothing_on_stdout_when_the_run_fails(
capsys: pytest.CaptureFixture[str],
) -> None:
with pytest.raises(RuntimeError, match="boom"):
main(["--duration", "0"], lambda _headless: RecordingGame(fail_on_open=True))
assert capsys.readouterr().out == ""
def test_parse_args_buys_one_upgrade_unless_buy_all_is_given() -> None:
assert parse_args([]).buy_all is False
assert parse_args(["--buy-all"]).buy_all is True
@pytest.mark.parametrize("value", ["0", "-1", "abc", "nan"])
def test_parse_args_rejects_an_interval_that_is_not_positive(
value: str, capsys: pytest.CaptureFixture[str]
) -> None:
with pytest.raises(SystemExit) as exit_info:
parse_args(["--interval", value])
assert exit_info.value.code == 2
assert "greater than 0" in capsys.readouterr().err
def test_main_logs_the_interval_and_the_strategy(
caplog: pytest.LogCaptureFixture,
) -> None:
caplog.set_level("INFO")
main(["--duration", "0"], lambda _h: RecordingGame())
assert "every 5 seconds, strategy buy-one" in caplog.text
caplog.clear()
main(
["--duration", "0", "--interval", "2", "--buy-all"], lambda _h: RecordingGame()
)
assert "every 2.0 seconds, strategy buy-all" in caplog.text
def test_main_buy_all_buys_every_affordable_upgrade_per_check(
caplog: pytest.LogCaptureFixture,
) -> None:
caplog.set_level("INFO")
game = RecordingGame()
main(["--duration", "0.05", "--interval", "0.001", "--buy-all"], lambda _h: game)
assert "bought Cursor" in caplog.text
+17
View File
@@ -0,0 +1,17 @@
"""@file test_constants.py
@brief Smoke test: the assignment's timings are the ones in constants.py.
"""
from cookie_clicker_bot import constants
def test_run_lasts_five_minutes() -> None:
assert constants.RUN_DURATION_SECONDS == 5 * 60
def test_upgrades_are_checked_every_five_seconds() -> None:
assert constants.UPGRADE_CHECK_INTERVAL_SECONDS == 5
def test_game_address_is_https() -> None:
assert constants.GAME_URL.startswith("https://")
+250
View File
@@ -0,0 +1,250 @@
"""@file test_game_loop.py
@brief Tests of the game loop with a fake game and a fake clock.
"""
from pathlib import Path
from cookie_clicker_bot.application.game_loop import (
PlayResult,
buy_most_expensive_upgrade,
buy_upgrades,
click_and_buy,
click_cookie,
)
from cookie_clicker_bot.domain.buy_strategy import BuyStrategy
from cookie_clicker_bot.domain.upgrades import Upgrade
CURSOR = Upgrade("product0", "Cursor", 15)
GRANDMA = Upgrade("product1", "Grandma", 100)
class FakeClock:
"""A clock that only moves when the fake game is clicked."""
def __init__(self) -> None:
self.now = 0.0
def __call__(self) -> float:
return self.now
class FakeGame:
"""A game with a cookie count and upgrades; each click takes one second."""
def __init__(
self,
cookies: int = 0,
upgrades: list[Upgrade] | None = None,
clock: FakeClock | None = None,
buy_succeeds: bool = True,
) -> None:
self.buy_succeeds = buy_succeeds
self.clicks = 0
self.cookies = cookies
self.available = upgrades or []
self.bought: list[Upgrade] = []
self.buy_times: list[float] = []
self._clock = clock
def open_game(self) -> None:
pass
def dismiss_language(self) -> None:
pass
def click_cookie(self) -> None:
self.clicks += 1
if self._clock is not None:
self._clock.now += 1.0
def cookie_count(self) -> int:
return self.cookies
def cookies_per_second(self) -> float:
return 0.0
def upgrades(self) -> list[Upgrade]:
return self.available
def buy(self, upgrade: Upgrade) -> bool:
self.bought.append(upgrade)
if self._clock is not None:
self.buy_times.append(self._clock.now)
return self.buy_succeeds
def save_screenshot(self, path: Path) -> None:
pass
def close(self) -> None:
pass
def test_click_cookie_delegates_to_the_game() -> None:
game = FakeGame()
click_cookie(game)
assert game.clicks == 1
def test_buy_most_expensive_upgrade_buys_the_best_affordable() -> None:
game = FakeGame(cookies=105, upgrades=[CURSOR, GRANDMA])
bought = buy_most_expensive_upgrade(game)
assert bought == GRANDMA
assert game.bought == [GRANDMA]
def test_buy_most_expensive_upgrade_buys_nothing_when_too_poor() -> None:
game = FakeGame(cookies=10, upgrades=[CURSOR, GRANDMA])
assert buy_most_expensive_upgrade(game) is None
assert game.bought == []
def test_click_and_buy_checks_the_upgrades_every_interval() -> None:
clock = FakeClock()
game = FakeGame(cookies=105, upgrades=[CURSOR, GRANDMA], clock=clock)
result = click_and_buy(game, 12, 5, clock)
assert result == PlayResult(
clicks=12, purchases=("Grandma", "Grandma"), elapsed_seconds=12.0
)
# The checks fall in the iterations that start at 5 s and 10 s; each click then
# moves the fake clock on by one second.
assert game.buy_times == [6.0, 11.0]
def test_click_and_buy_does_not_check_before_the_first_interval() -> None:
clock = FakeClock()
game = FakeGame(cookies=105, upgrades=[GRANDMA], clock=clock)
result = click_and_buy(game, 4, 5, clock)
assert result == PlayResult(clicks=4, purchases=(), elapsed_seconds=4.0)
assert game.bought == []
def test_click_and_buy_records_only_real_purchases() -> None:
clock = FakeClock()
game = FakeGame(cookies=10, upgrades=[CURSOR, GRANDMA], clock=clock)
result = click_and_buy(game, 11, 5, clock)
assert result.clicks == 11
assert result.purchases == ()
def test_buy_most_expensive_upgrade_returns_none_when_the_click_fails() -> None:
game = FakeGame(cookies=105, upgrades=[CURSOR, GRANDMA], buy_succeeds=False)
assert buy_most_expensive_upgrade(game) is None
assert game.bought == [GRANDMA]
def test_click_and_buy_does_not_record_a_purchase_when_the_click_fails() -> None:
clock = FakeClock()
game = FakeGame(cookies=105, upgrades=[GRANDMA], clock=clock, buy_succeeds=False)
result = click_and_buy(game, 12, 5, clock)
assert game.buy_times == [6.0, 11.0]
assert result.purchases == ()
def test_click_and_buy_does_not_click_when_duration_is_zero() -> None:
clock = FakeClock()
game = FakeGame(clock=clock)
assert click_and_buy(game, 0, 5, clock) == PlayResult(
clicks=0, purchases=(), elapsed_seconds=0.0
)
def test_click_and_buy_stops_at_the_deadline_and_reports_elapsed_time() -> None:
clock = FakeClock()
game = FakeGame(clock=clock)
result = click_and_buy(game, 300, 5, clock)
assert result.clicks == 300
assert result.elapsed_seconds == 300.0
def test_buy_upgrades_buy_one_buys_only_the_best_affordable() -> None:
game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA])
assert buy_upgrades(game, BuyStrategy.BUY_ONE) == [GRANDMA]
assert game.bought == [GRANDMA]
def test_buy_upgrades_buy_all_buys_every_affordable_upgrade() -> None:
game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA])
assert buy_upgrades(game, BuyStrategy.BUY_ALL) == [GRANDMA, CURSOR]
assert game.bought == [GRANDMA, CURSOR]
def test_buy_upgrades_buy_all_buys_nothing_when_too_poor() -> None:
game = FakeGame(cookies=10, upgrades=[CURSOR, GRANDMA])
assert buy_upgrades(game, BuyStrategy.BUY_ALL) == []
assert game.bought == []
def test_buy_upgrades_buy_all_leaves_out_failed_buys() -> None:
game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA], buy_succeeds=False)
assert buy_upgrades(game, BuyStrategy.BUY_ALL) == []
assert game.bought == [GRANDMA, CURSOR]
def test_click_and_buy_defaults_to_buying_one_upgrade() -> None:
clock = FakeClock()
game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA], clock=clock)
result = click_and_buy(game, 6, 5, clock)
assert result.purchases == ("Grandma",)
def test_click_and_buy_buy_all_records_every_purchase_in_order() -> None:
clock = FakeClock()
game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA], clock=clock)
result = click_and_buy(game, 6, 5, clock, BuyStrategy.BUY_ALL)
assert result.purchases == ("Grandma", "Cursor")
def test_click_and_buy_buy_all_does_not_record_failed_buys() -> None:
clock = FakeClock()
game = FakeGame(
cookies=120, upgrades=[CURSOR, GRANDMA], clock=clock, buy_succeeds=False
)
result = click_and_buy(game, 6, 5, clock, BuyStrategy.BUY_ALL)
assert game.bought == [GRANDMA, CURSOR]
assert result.purchases == ()
def test_click_and_buy_checks_every_second_with_a_one_second_interval() -> None:
clock = FakeClock()
game = FakeGame(cookies=105, upgrades=[GRANDMA], clock=clock)
click_and_buy(game, 4, 1, clock)
assert game.buy_times == [2.0, 3.0, 4.0]
def test_click_and_buy_checks_every_ten_seconds_with_a_ten_second_interval() -> None:
clock = FakeClock()
game = FakeGame(cookies=105, upgrades=[GRANDMA], clock=clock)
result = click_and_buy(game, 25, 10, clock)
assert game.buy_times == [11.0, 21.0]
assert result.purchases == ("Grandma", "Grandma")
+82
View File
@@ -0,0 +1,82 @@
"""@file test_parsing.py
@brief Tests of the parsing of prices and cookie counts.
"""
import pytest
from cookie_clicker_bot.domain.parsing import (
parse_amount,
parse_cookie_count,
parse_cookies_per_second,
)
@pytest.mark.parametrize(
("text", "expected"),
[
("15", 15),
("0", 0),
("1,100", 1100),
("130,000", 130000),
(" 100 ", 100),
("1.4 million", 1_400_000),
("20 million", 20_000_000),
("1.235 million", 1_235_000),
("3.5 Billion", 3_500_000_000),
("2 thousand", 2000),
("1 decillion", 10**33),
],
)
def test_parse_amount_reads_prices(text: str, expected: int) -> None:
assert parse_amount(text) == expected
@pytest.mark.parametrize(
"text", ["", " ", "abc", "1.4 gazillion", "1 2 3", "-5", "nan", "million"]
)
def test_parse_amount_rejects_garbage(text: str) -> None:
with pytest.raises(ValueError, match="not an amount of cookies"):
parse_amount(text)
@pytest.mark.parametrize(
("text", "expected"),
[
("120 cookies\nper second: 0", 120),
("5,000 cookies\nper second: 0", 5000),
("1 cookie\nper second: 0", 1),
("0 cookies\nper second: 0", 0),
("1.235\nmillion cookies\nper second: 0", 1_235_000),
("3.500\nbillion cookies\nper second: 12.5", 3_500_000_000),
("120 cookies", 120),
],
)
def test_parse_cookie_count_reads_the_count_element(text: str, expected: int) -> None:
assert parse_cookie_count(text) == expected
def test_parse_cookie_count_rejects_text_without_a_count() -> None:
with pytest.raises(ValueError, match="not an amount of cookies"):
parse_cookie_count("per second: 0")
@pytest.mark.parametrize(
("text", "expected"),
[
("per second: 0", 0.0),
("per second: 8.5", 8.5),
("per second: 1,234.5", 1234.5),
("per second: 1.2 million", 1_200_000.0),
(" Per second: 3 ", 3.0),
],
)
def test_parse_cookies_per_second_reads_the_element(text: str, expected: float) -> None:
assert parse_cookies_per_second(text) == expected
@pytest.mark.parametrize(
"text", ["", "per second:", "per second: abc", "per second: -1"]
)
def test_parse_cookies_per_second_rejects_garbage(text: str) -> None:
with pytest.raises(ValueError, match="not an amount of cookies"):
parse_cookies_per_second(text)
+20
View File
@@ -0,0 +1,20 @@
"""@file test_report.py
@brief Tests of the result line.
"""
import pytest
from cookie_clicker_bot.application.report import format_result
@pytest.mark.parametrize(
("value", "expected"),
[
(8.5, "cookies/second: 8.5"),
(0.0, "cookies/second: 0"),
(1234.5, "cookies/second: 1234.5"),
(1_200_000.0, "cookies/second: 1200000"),
],
)
def test_format_result_writes_the_summary_line(value: float, expected: str) -> None:
assert format_result(value) == expected
+70
View File
@@ -0,0 +1,70 @@
"""@file test_upgrades.py
@brief Tests of the rule that picks the upgrade to buy.
"""
from cookie_clicker_bot.domain.upgrades import (
Upgrade,
choose_affordable_upgrades,
choose_most_expensive_affordable,
)
CURSOR = Upgrade("product0", "Cursor", 15)
GRANDMA = Upgrade("product1", "Grandma", 100)
FARM = Upgrade("product2", "Farm", 1100)
def test_choose_picks_the_most_expensive_affordable() -> None:
assert choose_most_expensive_affordable([CURSOR, GRANDMA, FARM], 105) == GRANDMA
def test_choose_returns_none_when_nothing_is_affordable() -> None:
assert choose_most_expensive_affordable([CURSOR, GRANDMA], 10) is None
def test_choose_returns_none_without_upgrades() -> None:
assert choose_most_expensive_affordable([], 1000) is None
def test_choose_accepts_a_price_equal_to_the_cookies() -> None:
assert choose_most_expensive_affordable([CURSOR, GRANDMA], 100) == GRANDMA
def test_choose_takes_the_first_on_a_tie() -> None:
twin = Upgrade("product9", "Twin", 100)
assert choose_most_expensive_affordable([GRANDMA, twin], 500) == GRANDMA
def test_choose_ignores_the_order_of_the_list() -> None:
assert choose_most_expensive_affordable([GRANDMA, CURSOR], 105) == GRANDMA
def test_plan_skips_an_upgrade_the_remaining_cookies_cannot_pay() -> None:
assert choose_affordable_upgrades([CURSOR, GRANDMA, FARM], 105) == [GRANDMA]
def test_plan_buys_several_upgrades_most_expensive_first() -> None:
assert choose_affordable_upgrades([CURSOR, GRANDMA, FARM], 120) == [GRANDMA, CURSOR]
def test_plan_is_empty_when_nothing_is_affordable() -> None:
assert choose_affordable_upgrades([CURSOR, GRANDMA], 10) == []
def test_plan_is_empty_without_upgrades() -> None:
assert choose_affordable_upgrades([], 1000) == []
def test_plan_spends_the_whole_budget_on_equal_prices() -> None:
twin = Upgrade("product9", "Twin", 100)
assert choose_affordable_upgrades([GRANDMA, twin], 250) == [GRANDMA, twin]
assert choose_affordable_upgrades([twin, GRANDMA], 150) == [twin]
def test_plan_does_not_change_the_given_list() -> None:
upgrades = [CURSOR, GRANDMA]
choose_affordable_upgrades(upgrades, 1000)
assert upgrades == [CURSOR, GRANDMA]