Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c2f6002fa | ||
|
|
afcb7d35a8 | ||
|
|
9764da1def | ||
|
|
3f611ab584 | ||
|
|
e3155de9b5 | ||
|
|
18b1b4f282 | ||
|
|
9d933074f2 | ||
|
|
97215c4137 | ||
|
|
e28096100b |
@@ -16,7 +16,7 @@ document of a type. `Primary File` may contain a glob (e.g.
|
|||||||
| PP | Project Plan | docs/project-plan.md | 002 |
|
| PP | Project Plan | docs/project-plan.md | 002 |
|
||||||
| MIL | Milestone / Gateway | docs/milestones/*.md | 008 |
|
| MIL | Milestone / Gateway | docs/milestones/*.md | 008 |
|
||||||
| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 |
|
| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 |
|
||||||
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 016 |
|
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 019 |
|
||||||
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
|
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
|
||||||
| DICT | Domain Dictionary (PO and IT terms) | docs/dictionary.md | 002 |
|
| DICT | Domain Dictionary (PO and IT terms) | docs/dictionary.md | 002 |
|
||||||
|
|
||||||
|
|||||||
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 |
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -43,6 +43,9 @@ Tracks backward and forward links between artifact instances so that the Busines
|
|||||||
| [MIL-007] | Milestone | en | it | [BC-001], [PP-001] | - | RC-013 |
|
| [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-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-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 Coverage
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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** · [Dansk](Step-5-Improve-the-Strategy-da) · [Bahasa Malaysia](Step-5-Improve-the-Strategy-ms)
|
||||||
|
|
||||||
|
# Lecture step 5: tweak the algorithm
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -4,8 +4,27 @@
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from cookie_clicker_bot.application.ports import GamePort
|
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:
|
def click_cookie(game: GamePort) -> None:
|
||||||
@@ -16,21 +35,62 @@ def click_cookie(game: GamePort) -> None:
|
|||||||
game.click_cookie()
|
game.click_cookie()
|
||||||
|
|
||||||
|
|
||||||
def click_cookie_for(
|
def buy_most_expensive_upgrade(game: GamePort) -> Upgrade | None:
|
||||||
game: GamePort,
|
"""@brief Buy the most expensive upgrade the player can afford (lecture step 3).
|
||||||
duration_seconds: float,
|
|
||||||
clock: Callable[[], float] = time.monotonic,
|
|
||||||
) -> int:
|
|
||||||
"""@brief Click the cookie as fast as possible until the time is up.
|
|
||||||
|
|
||||||
@param game The game to play.
|
@param game The game to play.
|
||||||
@param duration_seconds How long to keep clicking.
|
@return The upgrade that was bought, or None when nothing was affordable or
|
||||||
@param clock Monotonic clock in seconds; a test passes a fake one.
|
the click did not reach the product.
|
||||||
@return The number of clicks made.
|
|
||||||
"""
|
"""
|
||||||
deadline = clock() + duration_seconds
|
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
|
clicks = 0
|
||||||
while clock() < deadline:
|
purchases: list[str] = []
|
||||||
|
while (now := clock()) < deadline:
|
||||||
click_cookie(game)
|
click_cookie(game)
|
||||||
clicks += 1
|
clicks += 1
|
||||||
return clicks
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
|
|
||||||
|
from cookie_clicker_bot.domain.upgrades import Upgrade
|
||||||
|
|
||||||
|
|
||||||
class GamePort(Protocol):
|
class GamePort(Protocol):
|
||||||
"""@brief What the bot needs from the game, whatever drives the browser."""
|
"""@brief What the bot needs from the game, whatever drives the browser."""
|
||||||
@@ -18,6 +20,35 @@ class GamePort(Protocol):
|
|||||||
def click_cookie(self) -> None:
|
def click_cookie(self) -> None:
|
||||||
"""@brief Click the big cookie once."""
|
"""@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:
|
def save_screenshot(self, path: Path) -> None:
|
||||||
"""@brief Write a screenshot of the game to a PNG file.
|
"""@brief Write a screenshot of the game to a PNG file.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -8,8 +8,10 @@ from collections.abc import Callable, Sequence
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from cookie_clicker_bot import constants
|
from cookie_clicker_bot import constants
|
||||||
from cookie_clicker_bot.application.game_loop import click_cookie_for
|
from cookie_clicker_bot.application.game_loop import click_and_buy
|
||||||
from cookie_clicker_bot.application.ports import GamePort
|
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.browser import create_driver
|
||||||
from cookie_clicker_bot.infrastructure.selenium_game import SeleniumGame
|
from cookie_clicker_bot.infrastructure.selenium_game import SeleniumGame
|
||||||
|
|
||||||
@@ -25,6 +27,23 @@ def create_game(headless: bool) -> GamePort:
|
|||||||
return SeleniumGame(create_driver(headless))
|
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:
|
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
||||||
"""@brief Read the command line.
|
"""@brief Read the command line.
|
||||||
|
|
||||||
@@ -33,7 +52,10 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
|||||||
"""
|
"""
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="cookie-clicker-bot",
|
prog="cookie-clicker-bot",
|
||||||
description="Plays Cookie Clicker: clicks the big cookie as fast as it can.",
|
description=(
|
||||||
|
"Plays Cookie Clicker: clicks the big cookie as fast as it can "
|
||||||
|
"and buys upgrades."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--duration",
|
"--duration",
|
||||||
@@ -41,6 +63,20 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
|||||||
default=constants.RUN_DURATION_SECONDS,
|
default=constants.RUN_DURATION_SECONDS,
|
||||||
help="seconds to play (default: %(default)s)",
|
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(
|
parser.add_argument(
|
||||||
"--headless",
|
"--headless",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@@ -77,10 +113,27 @@ def main(
|
|||||||
game.dismiss_language()
|
game.dismiss_language()
|
||||||
if args.screenshot_start is not None:
|
if args.screenshot_start is not None:
|
||||||
game.save_screenshot(args.screenshot_start)
|
game.save_screenshot(args.screenshot_start)
|
||||||
clicks = click_cookie_for(game, args.duration)
|
strategy = BuyStrategy.BUY_ALL if args.buy_all else BuyStrategy.BUY_ONE
|
||||||
logger.info("clicked the cookie %d times in %s seconds", clicks, args.duration)
|
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:
|
if args.screenshot is not None:
|
||||||
game.save_screenshot(args.screenshot)
|
game.save_screenshot(args.screenshot)
|
||||||
finally:
|
finally:
|
||||||
game.close()
|
game.close()
|
||||||
|
# The program's required result: the only line written to stdout (logs use stderr).
|
||||||
|
print(format_result(cookies_per_second))
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ RUN_DURATION_SECONDS: Final[int] = 300
|
|||||||
## Seconds between two checks for an affordable upgrade (lecture step 3).
|
## Seconds between two checks for an affordable upgrade (lecture step 3).
|
||||||
UPGRADE_CHECK_INTERVAL_SECONDS: Final[int] = 5
|
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.
|
## Longest time to wait for an element of the page, in seconds.
|
||||||
WAIT_TIMEOUT_SECONDS: Final[int] = 20
|
WAIT_TIMEOUT_SECONDS: Final[int] = 20
|
||||||
|
|
||||||
@@ -30,9 +33,51 @@ BIG_COOKIE_ID: Final[str] = "bigCookie"
|
|||||||
## Id of the element that shows the cookie count and the cookies per second.
|
## Id of the element that shows the cookie count and the cookies per second.
|
||||||
COOKIE_COUNT_ID: Final[str] = "cookies"
|
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.
|
## Seconds between two polls while waiting for an element.
|
||||||
POLL_INTERVAL_SECONDS: Final[float] = 0.2
|
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).
|
## Environment variable that names the Chromium binary (set in the Docker image).
|
||||||
CHROME_BIN_ENV: Final[str] = "CHROME_BIN"
|
CHROME_BIN_ENV: Final[str] = "CHROME_BIN"
|
||||||
|
|
||||||
@@ -40,7 +85,10 @@ CHROME_BIN_ENV: Final[str] = "CHROME_BIN"
|
|||||||
CHROMEDRIVER_PATH_ENV: Final[str] = "CHROMEDRIVER_PATH"
|
CHROMEDRIVER_PATH_ENV: Final[str] = "CHROMEDRIVER_PATH"
|
||||||
|
|
||||||
## Size of the browser window in pixels, so the screenshots look the same every time.
|
## Size of the browser window in pixels, so the screenshots look the same every time.
|
||||||
WINDOW_SIZE: Final[str] = "1366,768"
|
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.
|
## Chromium arguments needed to run headless inside a container.
|
||||||
HEADLESS_BROWSER_ARGUMENTS: Final[tuple[str, ...]] = (
|
HEADLESS_BROWSER_ARGUMENTS: Final[tuple[str, ...]] = (
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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}"
|
||||||
@@ -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
|
||||||
@@ -3,9 +3,9 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable, Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Protocol, TypeVar
|
from typing import Any, Protocol, TypeVar
|
||||||
|
|
||||||
from selenium.common.exceptions import (
|
from selenium.common.exceptions import (
|
||||||
ElementClickInterceptedException,
|
ElementClickInterceptedException,
|
||||||
@@ -15,6 +15,12 @@ from selenium.common.exceptions import (
|
|||||||
from selenium.webdriver.common.by import By
|
from selenium.webdriver.common.by import By
|
||||||
|
|
||||||
from cookie_clicker_bot import constants
|
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")
|
T = TypeVar("T")
|
||||||
|
|
||||||
@@ -34,6 +40,14 @@ class ElementLike(Protocol):
|
|||||||
"""@brief True when the element is visible."""
|
"""@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):
|
class DriverLike(Protocol):
|
||||||
"""@brief The part of a Selenium driver that the adapter uses."""
|
"""@brief The part of a Selenium driver that the adapter uses."""
|
||||||
@@ -45,10 +59,18 @@ class DriverLike(Protocol):
|
|||||||
"""@brief Find one element."""
|
"""@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:
|
def save_screenshot(self, filename: str) -> bool:
|
||||||
"""@brief Write a PNG screenshot."""
|
"""@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:
|
def quit(self) -> None:
|
||||||
"""@brief Close the browser."""
|
"""@brief Close the browser."""
|
||||||
|
|
||||||
@@ -93,6 +115,73 @@ class SeleniumGame:
|
|||||||
# A banner or a re-render covered the cookie for a moment; try again.
|
# A banner or a re-render covered the cookie for a moment; try again.
|
||||||
return
|
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:
|
def save_screenshot(self, path: Path) -> None:
|
||||||
"""@brief Write a screenshot of the game to a PNG file.
|
"""@brief Write a screenshot of the game to a PNG file.
|
||||||
|
|
||||||
@@ -105,13 +194,22 @@ class SeleniumGame:
|
|||||||
"""@brief Close the browser."""
|
"""@brief Close the browser."""
|
||||||
self._driver.quit()
|
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:
|
def _visible(self, element_id: str) -> ElementLike | None:
|
||||||
"""@brief Return the element with this id when it exists and is displayed."""
|
"""@brief Return the element with this id when it exists and is displayed."""
|
||||||
try:
|
try:
|
||||||
element = self._driver.find_element(By.ID, element_id)
|
element = self._driver.find_element(By.ID, element_id)
|
||||||
except NoSuchElementException:
|
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
|
return None
|
||||||
return element if element.is_displayed() else None
|
|
||||||
|
|
||||||
def _wait_for(self, locate: Callable[[], T | None]) -> T:
|
def _wait_for(self, locate: Callable[[], T | None]) -> T:
|
||||||
"""@brief Poll until `locate` returns a value.
|
"""@brief Poll until `locate` returns a value.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from selenium.common.exceptions import (
|
from selenium.common.exceptions import (
|
||||||
@@ -13,6 +14,7 @@ from selenium.common.exceptions import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from cookie_clicker_bot import constants
|
from cookie_clicker_bot import constants
|
||||||
|
from cookie_clicker_bot.domain.upgrades import Upgrade
|
||||||
from cookie_clicker_bot.infrastructure.selenium_game import SeleniumGame
|
from cookie_clicker_bot.infrastructure.selenium_game import SeleniumGame
|
||||||
|
|
||||||
|
|
||||||
@@ -23,11 +25,24 @@ class FakeElement:
|
|||||||
self,
|
self,
|
||||||
displayed: Callable[[], bool] = lambda: True,
|
displayed: Callable[[], bool] = lambda: True,
|
||||||
click_error: Exception | None = None,
|
click_error: Exception | None = None,
|
||||||
|
text: str = "",
|
||||||
|
attributes: dict[str, str] | None = None,
|
||||||
|
children: dict[str, "FakeElement"] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.text = ""
|
self.text = text
|
||||||
self.clicks = 0
|
self.clicks = 0
|
||||||
self._displayed = displayed
|
self._displayed = displayed
|
||||||
self._click_error = click_error
|
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:
|
def click(self) -> None:
|
||||||
if self._click_error is not None:
|
if self._click_error is not None:
|
||||||
@@ -43,9 +58,14 @@ class FakeDriver:
|
|||||||
|
|
||||||
def __init__(self, elements: dict[str, FakeElement] | None = None) -> None:
|
def __init__(self, elements: dict[str, FakeElement] | None = None) -> None:
|
||||||
self.elements = elements or {}
|
self.elements = elements or {}
|
||||||
|
self.groups: dict[str, list[FakeElement]] = {}
|
||||||
self.loaded: list[str] = []
|
self.loaded: list[str] = []
|
||||||
self.screenshots: list[str] = []
|
self.screenshots: list[str] = []
|
||||||
self.quit_called = False
|
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:
|
def get(self, url: str) -> None:
|
||||||
self.loaded.append(url)
|
self.loaded.append(url)
|
||||||
@@ -55,6 +75,11 @@ class FakeDriver:
|
|||||||
raise NoSuchElementException(value)
|
raise NoSuchElementException(value)
|
||||||
return self.elements[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:
|
def save_screenshot(self, filename: str) -> bool:
|
||||||
self.screenshots.append(filename)
|
self.screenshots.append(filename)
|
||||||
return True
|
return True
|
||||||
@@ -123,6 +148,55 @@ def test_dismiss_language_polls_until_the_button_is_visible() -> None:
|
|||||||
assert fake_time.sleeps == [constants.POLL_INTERVAL_SECONDS] * 2
|
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:
|
def test_dismiss_language_times_out_when_the_button_never_shows() -> None:
|
||||||
fake_time = FakeTime()
|
fake_time = FakeTime()
|
||||||
|
|
||||||
@@ -157,6 +231,184 @@ def test_click_cookie_ignores_a_covered_or_stale_cookie(error: Exception) -> Non
|
|||||||
make_game(driver).click_cookie()
|
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(
|
def test_save_screenshot_creates_the_folder_and_calls_the_driver(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
+100
-1
@@ -9,12 +9,14 @@ import pytest
|
|||||||
from cookie_clicker_bot import constants
|
from cookie_clicker_bot import constants
|
||||||
from cookie_clicker_bot.application.ports import GamePort
|
from cookie_clicker_bot.application.ports import GamePort
|
||||||
from cookie_clicker_bot.cli import main, parse_args
|
from cookie_clicker_bot.cli import main, parse_args
|
||||||
|
from cookie_clicker_bot.domain.upgrades import Upgrade
|
||||||
|
|
||||||
|
|
||||||
class RecordingGame:
|
class RecordingGame:
|
||||||
"""A game that records every call in order."""
|
"""A game that records every call in order."""
|
||||||
|
|
||||||
def __init__(self, fail_on_open: bool = False) -> None:
|
def __init__(self, fail_on_open: bool = False, buy_succeeds: bool = True) -> None:
|
||||||
|
self._buy_succeeds = buy_succeeds
|
||||||
self.calls: list[str] = []
|
self.calls: list[str] = []
|
||||||
self.screenshots: list[Path] = []
|
self.screenshots: list[Path] = []
|
||||||
self._fail_on_open = fail_on_open
|
self._fail_on_open = fail_on_open
|
||||||
@@ -31,6 +33,19 @@ class RecordingGame:
|
|||||||
def click_cookie(self) -> None:
|
def click_cookie(self) -> None:
|
||||||
self.calls.append("click_cookie")
|
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:
|
def save_screenshot(self, path: Path) -> None:
|
||||||
self.calls.append("save_screenshot")
|
self.calls.append("save_screenshot")
|
||||||
self.screenshots.append(path)
|
self.screenshots.append(path)
|
||||||
@@ -43,6 +58,7 @@ def test_parse_args_uses_defaults() -> None:
|
|||||||
args = parse_args([])
|
args = parse_args([])
|
||||||
|
|
||||||
assert args.duration == constants.RUN_DURATION_SECONDS == 300
|
assert args.duration == constants.RUN_DURATION_SECONDS == 300
|
||||||
|
assert args.interval == constants.UPGRADE_CHECK_INTERVAL_SECONDS == 5
|
||||||
assert args.headless is False
|
assert args.headless is False
|
||||||
assert args.screenshot_start is None
|
assert args.screenshot_start is None
|
||||||
assert args.screenshot is None
|
assert args.screenshot is None
|
||||||
@@ -54,6 +70,8 @@ def test_parse_args_reads_every_option() -> None:
|
|||||||
"--headless",
|
"--headless",
|
||||||
"--duration",
|
"--duration",
|
||||||
"10",
|
"10",
|
||||||
|
"--interval",
|
||||||
|
"2.5",
|
||||||
"--screenshot-start",
|
"--screenshot-start",
|
||||||
"a.png",
|
"a.png",
|
||||||
"--screenshot",
|
"--screenshot",
|
||||||
@@ -63,6 +81,7 @@ def test_parse_args_reads_every_option() -> None:
|
|||||||
|
|
||||||
assert args.headless is True
|
assert args.headless is True
|
||||||
assert args.duration == 10
|
assert args.duration == 10
|
||||||
|
assert args.interval == 2.5
|
||||||
assert args.screenshot_start == Path("a.png")
|
assert args.screenshot_start == Path("a.png")
|
||||||
assert args.screenshot == Path("b.png")
|
assert args.screenshot == Path("b.png")
|
||||||
|
|
||||||
@@ -115,3 +134,83 @@ def test_main_closes_the_game_when_an_error_is_raised() -> None:
|
|||||||
main(["--duration", "0"], lambda _headless: game)
|
main(["--duration", "0"], lambda _headless: game)
|
||||||
|
|
||||||
assert game.calls == ["open_game", "close"]
|
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
|
||||||
|
|||||||
+209
-25
@@ -4,14 +4,47 @@
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from cookie_clicker_bot.application.game_loop import click_cookie, click_cookie_for
|
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:
|
class FakeGame:
|
||||||
"""A game that only counts clicks."""
|
"""A game with a cookie count and upgrades; each click takes one second."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
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.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:
|
def open_game(self) -> None:
|
||||||
pass
|
pass
|
||||||
@@ -21,6 +54,23 @@ class FakeGame:
|
|||||||
|
|
||||||
def click_cookie(self) -> None:
|
def click_cookie(self) -> None:
|
||||||
self.clicks += 1
|
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:
|
def save_screenshot(self, path: Path) -> None:
|
||||||
pass
|
pass
|
||||||
@@ -29,18 +79,6 @@ class FakeGame:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class FakeClock:
|
|
||||||
"""A clock that moves forward one second each time it is read."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.now = 0.0
|
|
||||||
|
|
||||||
def __call__(self) -> float:
|
|
||||||
value = self.now
|
|
||||||
self.now += 1.0
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def test_click_cookie_delegates_to_the_game() -> None:
|
def test_click_cookie_delegates_to_the_game() -> None:
|
||||||
game = FakeGame()
|
game = FakeGame()
|
||||||
|
|
||||||
@@ -49,18 +87,164 @@ def test_click_cookie_delegates_to_the_game() -> None:
|
|||||||
assert game.clicks == 1
|
assert game.clicks == 1
|
||||||
|
|
||||||
|
|
||||||
def test_click_cookie_for_clicks_until_the_deadline() -> None:
|
def test_buy_most_expensive_upgrade_buys_the_best_affordable() -> None:
|
||||||
game = FakeGame()
|
game = FakeGame(cookies=105, upgrades=[CURSOR, GRANDMA])
|
||||||
|
|
||||||
# Reads: deadline = 0 + 5; the loop reads 1, 2, 3, 4 (clicks) and 5 (stops).
|
bought = buy_most_expensive_upgrade(game)
|
||||||
clicks = click_cookie_for(game, 5, FakeClock())
|
|
||||||
|
|
||||||
assert clicks == 4
|
assert bought == GRANDMA
|
||||||
assert game.clicks == 4
|
assert game.bought == [GRANDMA]
|
||||||
|
|
||||||
|
|
||||||
def test_click_cookie_for_does_not_click_when_duration_is_zero() -> None:
|
def test_buy_most_expensive_upgrade_buys_nothing_when_too_poor() -> None:
|
||||||
game = FakeGame()
|
game = FakeGame(cookies=10, upgrades=[CURSOR, GRANDMA])
|
||||||
|
|
||||||
assert click_cookie_for(game, 0, FakeClock()) == 0
|
assert buy_most_expensive_upgrade(game) is None
|
||||||
assert game.clicks == 0
|
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")
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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]
|
||||||
Reference in New Issue
Block a user