From 9a6796f775680effe37a54f2609c3915140c8c0e Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 28 Oct 2026 23:16:46 +0800 Subject: [PATCH 1/5] Plan MIL-008: smarter purchase rule --- docs/artifact-registry.md | 4 +- docs/milestones/mil-008-smarter-rule.md | 75 ++++++++++ docs/project-plan.md | 5 +- docs/sqa/reviews/rc-019-mil-008.md | 64 ++++++++ docs/sqa/traceability-matrix.md | 187 ++++++++++++------------ 5 files changed, 240 insertions(+), 95 deletions(-) create mode 100644 docs/milestones/mil-008-smarter-rule.md create mode 100644 docs/sqa/reviews/rc-019-mil-008.md diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index 5bbfefe..3fcddc9 100644 --- a/docs/artifact-registry.md +++ b/docs/artifact-registry.md @@ -14,9 +14,9 @@ document of a type. `Primary File` may contain a glob (e.g. | BC | Business Case | docs/business-case.md | 002 | | SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 | | PP | Project Plan | docs/project-plan.md | 002 | -| MIL | Milestone / Gateway | docs/milestones/*.md | 008 | +| MIL | Milestone / Gateway | docs/milestones/*.md | 009 | | ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 | -| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 019 | +| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 020 | | TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 | | DICT | Domain Dictionary (PO and IT terms) | docs/dictionary.md | 002 | diff --git a/docs/milestones/mil-008-smarter-rule.md b/docs/milestones/mil-008-smarter-rule.md new file mode 100644 index 0000000..2968b42 --- /dev/null +++ b/docs/milestones/mil-008-smarter-rule.md @@ -0,0 +1,75 @@ +# MIL-008 Smarter purchase rule + +## Metadata +| Key | Value | +| --- | --- | +| ID | MIL-008 | +| CrossReference | [BC-001] | +| Language | en | +| Domain | it | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Initial version | pending | + +--- + +## Purpose + +Decide whether a purchase rule that looks at what an upgrade earns, not only at its price, beats the assignment's rule ("most expensive affordable", 18.9 cookies/second alone on the computer) in a measured container run. + +## Deliverable + +A "best value" strategy next to buy-one and buy-all (the lowest price per cookie per second among the affordable upgrades), selectable from the command line, unit tested, with a recorded comparison and a wiki note for each language. The defaults stay the assignment's behaviour. + +## Go / No-Go Criteria + +| # | Criterion (objectively checkable) | Go | No-Go | +| --- | --- | --- | --- | +| 1 | The new strategy is selectable from the command line and the defaults are unchanged (5 seconds, buy one) | Defaults unchanged | Defaults changed | +| 2 | The rule is a pure function in `domain/` with no Selenium import, and unit tests cover it | 0 failures, coverage of the logic modules at 80% or more | A test fails or coverage drops | +| 3 | Baseline and new strategy are each run at least twice in the same container conditions and the table is recorded | Table with 4 or more rows | Fewer rows | +| 4 | The result is stated as measured, also when the new rule is not better | Result named | Claim without a measurement | +| 5 | The wiki records the result in English, Danish and Bahasa Malaysia | 3 languages | A language missing | +| 6 | The code review against `qc-programming-python.md` is `Go` | `RC-*` is `Go` | `No-Go` | + +## Dependencies + +| Depends on | Reason | +| --- | --- | +| MIL-006 | Extends the strategy options and the comparison method | + +## Traceability + +| Business Case objective / KPI / user story | Reference | +| --- | --- | +| O4 | [BC-001] | +| CN-003 | [SA-001] | + +## Ownership + +| Role | Stakeholder ID (SA) | +| --- | --- | +| Owner | S01 | +| Approving reviewer | S02 (AI as stand-in, autonomous mode, not independent) | + +## Target Date + +2026-11-06 — after the planned window of [PP-001]; the Business Case sets no deadline. + +## Tasks + +| # | Task | Summary | Needs its own Use Case/User Story? | Reference | +| --- | --- | --- | --- | --- | +| 1 | Read the production of each upgrade | Add to the game port a read of how many cookies per second one unit of each visible upgrade makes (from the game's own data), and add it to `Upgrade`. Unit tested with a fake game. | No | O4 | +| 2 | Add the best value rule | Add a pure function that picks the affordable upgrade with the lowest price per cookie per second, and a `BEST_VALUE` strategy. Unit tested, including ties and unknown production. | No | O4 | +| 3 | Select the strategy from the command line | Add the option, keep buy-one as the default, and update the README option table. | No | O4 | +| 4 | Compare with container runs | Run baseline and best value at least twice each in Docker under the same conditions, record cookies per second and name the better rule. | No | O4 | +| 5 | Record the result in the wiki | Add the result and what was gained to Step 5 in English, Danish and Bahasa Malaysia. | No | O4 | + +--- + +[BC-001]: ../business-case.md +[PP-001]: ../project-plan.md +[SA-001]: ../stakeholder-analysis.md diff --git a/docs/project-plan.md b/docs/project-plan.md index cb12ce4..ac60a99 100644 --- a/docs/project-plan.md +++ b/docs/project-plan.md @@ -12,12 +12,13 @@ | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | | 2026-10-28 | Accepted | Jens Tirsvad Nielsen | S01 | Initial version: seven gateways; accepted by S01 in chat | [cadc5ef] | +| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | S01 | Add MIL-008 (smarter purchase rule); accepted by S01 in chat | pending | --- ## Purpose -This plan schedules seven gateways (`MIL-001` to `MIL-007`) that take the Cookie Clicker bot from an accepted baseline to a documented, tested and published repository. The Business Case sets no deadline, so the window 2026-10-28 to 2026-11-04 is a planning assumption of this plan. Each gateway ends in a decision and a deliverable and is delivered by one pull request on its own branch. +This plan schedules eight gateways (`MIL-001` to `MIL-008`) that take the Cookie Clicker bot from an accepted baseline to a documented, tested and published repository. The Business Case sets no deadline, so the window 2026-10-28 to 2026-11-04 is a planning assumption of this plan. Each gateway ends in a decision and a deliverable and is delivered by one pull request on its own branch. ## Planning Assumptions @@ -38,6 +39,7 @@ This plan schedules seven gateways (`MIL-001` to `MIL-007`) that take the Cookie | Lecture step 4 | [MIL-005] | 2026-10-31 to 2026-11-01 | 2026-11-01 | S01 | none (technical tasks) | 5 minute run, cookies per second, screenshot 4 | [Milestone 5] | | Lecture step 5 | [MIL-006] | 2026-11-01 to 2026-11-02 | 2026-11-02 | S01 | none (technical tasks) | Strategy options, comparison, screenshot 5 | [Milestone 6] | | Documentation, wiki and release | [MIL-007] | 2026-11-02 to 2026-11-04 | 2026-11-04 | S01 | none (documentation) | README, Doxygen, wiki in en, da and ms, final checks | [Milestone 7] | +| Smarter purchase rule | [MIL-008] | 2026-11-05 to 2026-11-06 | 2026-11-06 | S01 | none (technical tasks) | Best value strategy, measured comparison, wiki note in en, da and ms | [Milestone 8] | ```plantuml @startgantt @@ -103,6 +105,7 @@ A No-Go on a gateway stops all later gateways until it is fixed and re-reviewed. [MIL-005]: ./milestones/mil-005-timed-run.md [MIL-006]: ./milestones/mil-006-strategy.md [MIL-007]: ./milestones/mil-007-documentation.md +[MIL-008]: ./milestones/mil-008-smarter-rule.md [cadc5ef]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/commit/cadc5eff21d9afc09799d4bc2e3f6ac17ab2612a [Milestone 1]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/milestone/290 [Milestone 2]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/milestone/291 diff --git a/docs/sqa/reviews/rc-019-mil-008.md b/docs/sqa/reviews/rc-019-mil-008.md new file mode 100644 index 0000000..c309f14 --- /dev/null +++ b/docs/sqa/reviews/rc-019-mil-008.md @@ -0,0 +1,64 @@ +# SQA Review Record + +## Metadata +| Key | Value | +| --- | --- | +| ID | RC-019 | +| CrossReference | [MIL-008], [QC-MIL-001] | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Review record, full review | pending | + +--- + +## Artifact Under Review + +- Instance reviewed: [MIL-008] +- Checklist used: [QC-MIL-001] (`QC-MIL-001`) +- Scope: full review +- Language and domain: en / it +- Language reviewer: none +- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S02`) + +## Checklist Results + +| # | Criterion | Status | Evidence/Notes | +| --- | --- | --- | --- | +| 1 | A concrete deliverable is defined for every gate | Pass | Deliverable names the strategy, the comparison and the wiki note. | +| 2 | Explicit Go/No-Go criteria are stated for each gate | Pass | Go/No-Go table has objectively checkable criteria with Go and No-Go columns. | +| 3 | Dependencies on other milestones are explicitly mapped | Pass | Dependencies table names the preceding gateway. | +| 4 | Each milestone is traceable to a Business Case objective or KPI | Pass | Traceability maps to Business Case objectives and success criteria. | +| 5 | Milestone owner and approving reviewer are identified | Pass | Ownership: owner S01, approving reviewer S02 (AI stand-in). | +| 6 | Milestone has a defined target date consistent with project constraints | Pass | Target Date lies after the plan window in PP-001 (the plan is extended in this change); the Business Case sets no deadline. | + +## Language and Domain Results + +| # | Criterion | Status | Evidence/Notes | +| --- | --- | --- | --- | +| 1 | The Metadata table has a `Language` row and a `Domain` row, and neither is a placeholder | Pass | Metadata has `Language` = en and `Domain` = it. | +| 2 | `Language` is a BCP 47 code and `Domain` is a value from the registry's domain list | Pass | `en` is BCP 47; `it` is in the registry domain list. | +| 3 | The content (prose and table cells) is written in the stated language | Pass | All prose and table cells are English. | +| 4 | The register matches the one the registry gives for the artifact type | Pass | Register of the type is IT Executive English (registry, Languages). | +| 5 | Domain terms are the PO terms of the domain's dictionary, with no synonyms | Pass | Terms follow DICT-001: bot, cookie, cookies per second, lecture step, upgrade. | +| 6 | Metadata keys, section headings, IDs and statuses are in English | Pass | Keys, headings, IDs and statuses are English. | +| 7 | No translated twin (`..md`) exists beside the document | Pass | No translated twin exists in `docs/`. | +| 8 | A change of language or domain since the previous accepted version has a Version History row and was reviewed again | N-A | First version; no language change. | +| 9 | A reviewer competent in the domain, and in the language, has confirmed that the domain terms are used correctly | Pass | The stand-in reviewer reads English and checked the IT terms in context. | +| 10 | Abbreviations are spelled out on first use, in the stated language | Pass | Abbreviations (ADR, QC, ISO/IEC, S-IDs) are spelled out or defined where first used, or are the framework's own IDs. | + +## Overall Verdict + +Go — MIL-008 meets every Mandatory criterion. No blocking finding. The review is not independent: the AI authored 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-008]: ../../milestones/mil-008-smarter-rule.md +[QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.md diff --git a/docs/sqa/traceability-matrix.md b/docs/sqa/traceability-matrix.md index 906bd46..6e0e77c 100644 --- a/docs/sqa/traceability-matrix.md +++ b/docs/sqa/traceability-matrix.md @@ -1,92 +1,95 @@ -# Traceability Matrix - -## Metadata -| Key | Value | -| --- | --- | -| ID | TM-001 | -| CrossReference | [BC-001] | - -## Version History -| Date | Status | Author | Reviewer | Change | Commit | -| --- | --- | --- | --- | --- | --- | -| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Initial version: baseline artifacts | [cadc5ef] | - ---- - -## Purpose - -Tracks backward/forward links between artifact instances so that the Business Case's -cross-artifact traceability success criterion is measurable. A row is added or -updated whenever an artifact instance is created or reviewed. - -## Purpose - -Tracks backward and forward links between artifact instances so that the Business Case's traceability can be read in one place. A row is added or updated whenever an artifact is created or reviewed. Rows for code and tests are added by the milestones that create them. - -## Traceability Table - -| Artifact Instance | Type | Language | Domain | Upstream (Backward Link) | Downstream (Forward Link) | Last Reviewed (RC-ID) | -| --- | --- | --- | --- | --- | --- | --- | -| [BC-001] | Business Case | en | it | - | [SA-001], [PP-001] | RC-001 | -| [SA-001] | Stakeholder Analysis | en | it | [BC-001] | [PP-001] | RC-002 | -| [PP-001] | Project Plan | en | it | [BC-001], [SA-001] | [MIL-001] to [MIL-007] | - (accepted by S01 in chat; no checklist) | -| [DICT-001] | Domain Dictionary | en | it | [BC-001], [SA-001] | - | RC-003 | -| [ADR-0001] | ADR | - | - | [BC-001] | [PP-001] | RC-004 | -| [ADR-0002] | ADR | - | - | [BC-001], [SA-001] | code in MIL-002 onward | RC-005 | -| [ADR-0003] | ADR | - | - | [BC-001] | [MIL-002], [MIL-003] | RC-006 | -| [MIL-001] | Milestone | en | it | [BC-001], [PP-001] | [MIL-002] | RC-007 | -| [MIL-002] | Milestone | en | it | [BC-001], [PP-001] | [MIL-003] | RC-008 | -| [MIL-003] | Milestone | en | it | [BC-001], [PP-001] | [MIL-004] | RC-009 | -| [MIL-004] | Milestone | en | it | [BC-001], [PP-001] | [MIL-005] | RC-010 | -| [MIL-005] | Milestone | en | it | [BC-001], [PP-001] | [MIL-006] | RC-011 | -| [MIL-006] | Milestone | en | it | [BC-001], [PP-001] | [MIL-007] | RC-012 | -| [MIL-007] | Milestone | en | it | [BC-001], [PP-001] | - | RC-013 | -| Source and tests of [MIL-002] | Python source code | - | - | [MIL-002], [ADR-0002] | - | RC-014 | -| Source and tests of [MIL-003] | Python source code | - | - | [MIL-003], [ADR-0002] | - | RC-015 | -| Source and tests of [MIL-004] | Python source code | - | - | [MIL-004], [ADR-0002] | - | RC-016 | -| Source and tests of [MIL-005] | Python source code | - | - | [MIL-005], [ADR-0002] | - | RC-017 | -| Source and tests of [MIL-006] | Python source code | - | - | [MIL-006], [ADR-0002] | - | RC-018 | - -## Concern Coverage - -| Concern (CN-ID) | Views that answer it | Decisions (ADR) | Quality check (TP) | Status | -| --- | --- | --- | --- | --- | -| CN-001 | [BC-001], [MIL-003], [MIL-004], [MIL-005] | [ADR-0002] | Unit tests per gateway | Covered | -| CN-002 | [PP-001], [MIL-001] | [ADR-0001] | Review records | Covered | -| CN-003 | [MIL-006] | - | Unit tests per gateway | Covered | -| CN-004 | [MIL-002], [MIL-003] | [ADR-0002] | Code review | Covered | -| CN-005 | [MIL-002], [MIL-007] | - | Clean clone walk-through | Covered | -| CN-006 | [MIL-007] | - | Wiki check | Covered | -| CN-007 | [MIL-001], [MIL-007] | - | Repository API check | Covered | -| CN-008 | [MIL-002] | [ADR-0002] | pyproject check | Covered | -| CN-009 | [MIL-001] | - | Review of .gitignore and history | Covered | - -## Open Inconsistencies - -| # | Views | Inconsistency | Found in (RC-ID) | Status | Resolved in | -| --- | --- | --- | --- | --- | --- | -| - | - | None found | - | Closed | - | - -## Coverage Notes - -- No test plan (`TP`), architecture description (`AD`) or design artifacts exist: ADR-0001 skips stories and use cases, and the code is small. Quality checks in Concern Coverage name the check used instead. -- `-` in Upstream means foundational, in Downstream that nothing is built on it yet, and in Last Reviewed that no `RC-*` exists. -- Every concern is covered once MIL-007 is merged; the quality check column names the check that was run. - ---- - -[BC-001]: ../business-case.md -[SA-001]: ../stakeholder-analysis.md -[PP-001]: ../project-plan.md -[DICT-001]: ../dictionary.md -[ADR-0001]: ../adr/adr-0001-skip-stories-use-cases.md -[ADR-0002]: ../adr/adr-0002-light-clean-architecture.md -[ADR-0003]: ../adr/adr-0003-docker-headless-browser.md -[MIL-001]: ../milestones/mil-001-baseline.md -[MIL-002]: ../milestones/mil-002-scaffold.md -[MIL-003]: ../milestones/mil-003-click-bot.md -[MIL-004]: ../milestones/mil-004-buy-upgrades.md -[MIL-005]: ../milestones/mil-005-timed-run.md -[MIL-006]: ../milestones/mil-006-strategy.md -[MIL-007]: ../milestones/mil-007-documentation.md -[cadc5ef]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/commit/cadc5eff21d9afc09799d4bc2e3f6ac17ab2612a +# Traceability Matrix + +## Metadata +| Key | Value | +| --- | --- | +| ID | TM-001 | +| CrossReference | [BC-001] | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Initial version: baseline artifacts | [cadc5ef] | +| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Add MIL-008 and RC-019 | pending | + +--- + +## Purpose + +Tracks backward/forward links between artifact instances so that the Business Case's +cross-artifact traceability success criterion is measurable. A row is added or +updated whenever an artifact instance is created or reviewed. + +## Purpose + +Tracks backward and forward links between artifact instances so that the Business Case's traceability can be read in one place. A row is added or updated whenever an artifact is created or reviewed. Rows for code and tests are added by the milestones that create them. + +## Traceability Table + +| Artifact Instance | Type | Language | Domain | Upstream (Backward Link) | Downstream (Forward Link) | Last Reviewed (RC-ID) | +| --- | --- | --- | --- | --- | --- | --- | +| [BC-001] | Business Case | en | it | - | [SA-001], [PP-001] | RC-001 | +| [SA-001] | Stakeholder Analysis | en | it | [BC-001] | [PP-001] | RC-002 | +| [PP-001] | Project Plan | en | it | [BC-001], [SA-001] | [MIL-001] to [MIL-008] | - (accepted by S01 in chat; no checklist) | +| [DICT-001] | Domain Dictionary | en | it | [BC-001], [SA-001] | - | RC-003 | +| [ADR-0001] | ADR | - | - | [BC-001] | [PP-001] | RC-004 | +| [ADR-0002] | ADR | - | - | [BC-001], [SA-001] | code in MIL-002 onward | RC-005 | +| [ADR-0003] | ADR | - | - | [BC-001] | [MIL-002], [MIL-003] | RC-006 | +| [MIL-001] | Milestone | en | it | [BC-001], [PP-001] | [MIL-002] | RC-007 | +| [MIL-002] | Milestone | en | it | [BC-001], [PP-001] | [MIL-003] | RC-008 | +| [MIL-003] | Milestone | en | it | [BC-001], [PP-001] | [MIL-004] | RC-009 | +| [MIL-004] | Milestone | en | it | [BC-001], [PP-001] | [MIL-005] | RC-010 | +| [MIL-005] | Milestone | en | it | [BC-001], [PP-001] | [MIL-006] | RC-011 | +| [MIL-006] | Milestone | en | it | [BC-001], [PP-001] | [MIL-007] | RC-012 | +| [MIL-007] | Milestone | en | it | [BC-001], [PP-001] | [MIL-008] | RC-013 | +| [MIL-008] | Milestone | en | it | [BC-001], [PP-001] | - | RC-019 | +| Source and tests of [MIL-002] | Python source code | - | - | [MIL-002], [ADR-0002] | - | RC-014 | +| Source and tests of [MIL-003] | Python source code | - | - | [MIL-003], [ADR-0002] | - | RC-015 | +| Source and tests of [MIL-004] | Python source code | - | - | [MIL-004], [ADR-0002] | - | RC-016 | +| Source and tests of [MIL-005] | Python source code | - | - | [MIL-005], [ADR-0002] | - | RC-017 | +| Source and tests of [MIL-006] | Python source code | - | - | [MIL-006], [ADR-0002] | - | RC-018 | + +## Concern Coverage + +| Concern (CN-ID) | Views that answer it | Decisions (ADR) | Quality check (TP) | Status | +| --- | --- | --- | --- | --- | +| CN-001 | [BC-001], [MIL-003], [MIL-004], [MIL-005] | [ADR-0002] | Unit tests per gateway | Covered | +| CN-002 | [PP-001], [MIL-001] | [ADR-0001] | Review records | Covered | +| CN-003 | [MIL-006], [MIL-008] | - | Unit tests per gateway | Covered | +| CN-004 | [MIL-002], [MIL-003] | [ADR-0002] | Code review | Covered | +| CN-005 | [MIL-002], [MIL-007] | - | Clean clone walk-through | Covered | +| CN-006 | [MIL-007] | - | Wiki check | Covered | +| CN-007 | [MIL-001], [MIL-007] | - | Repository API check | Covered | +| CN-008 | [MIL-002] | [ADR-0002] | pyproject check | Covered | +| CN-009 | [MIL-001] | - | Review of .gitignore and history | Covered | + +## Open Inconsistencies + +| # | Views | Inconsistency | Found in (RC-ID) | Status | Resolved in | +| --- | --- | --- | --- | --- | --- | +| - | - | None found | - | Closed | - | + +## Coverage Notes + +- No test plan (`TP`), architecture description (`AD`) or design artifacts exist: ADR-0001 skips stories and use cases, and the code is small. Quality checks in Concern Coverage name the check used instead. +- `-` in Upstream means foundational, in Downstream that nothing is built on it yet, and in Last Reviewed that no `RC-*` exists. +- Every concern is covered once MIL-007 is merged; the quality check column names the check that was run. + +--- + +[BC-001]: ../business-case.md +[SA-001]: ../stakeholder-analysis.md +[PP-001]: ../project-plan.md +[DICT-001]: ../dictionary.md +[ADR-0001]: ../adr/adr-0001-skip-stories-use-cases.md +[ADR-0002]: ../adr/adr-0002-light-clean-architecture.md +[ADR-0003]: ../adr/adr-0003-docker-headless-browser.md +[MIL-001]: ../milestones/mil-001-baseline.md +[MIL-002]: ../milestones/mil-002-scaffold.md +[MIL-003]: ../milestones/mil-003-click-bot.md +[MIL-004]: ../milestones/mil-004-buy-upgrades.md +[MIL-005]: ../milestones/mil-005-timed-run.md +[MIL-006]: ../milestones/mil-006-strategy.md +[MIL-007]: ../milestones/mil-007-documentation.md +[MIL-008]: ../milestones/mil-008-smarter-rule.md +[cadc5ef]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/commit/cadc5eff21d9afc09799d4bc2e3f6ac17ab2612a -- 2.54.0 From 014d9ad3d9fc5dbdebd13c24ba056421bd4afeb6 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 28 Oct 2026 23:17:17 +0800 Subject: [PATCH 2/5] Link the milestone and resolve commits for MIL-008 --- docs/milestones/mil-008-smarter-rule.md | 3 +- docs/project-plan.md | 234 ++++++++++++------------ docs/sqa/reviews/rc-019-mil-008.md | 3 +- docs/sqa/traceability-matrix.md | 3 +- 4 files changed, 124 insertions(+), 119 deletions(-) diff --git a/docs/milestones/mil-008-smarter-rule.md b/docs/milestones/mil-008-smarter-rule.md index 2968b42..261836b 100644 --- a/docs/milestones/mil-008-smarter-rule.md +++ b/docs/milestones/mil-008-smarter-rule.md @@ -11,7 +11,7 @@ ## Version History | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | -| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Initial version | pending | +| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Initial version | [9a6796f] | --- @@ -73,3 +73,4 @@ A "best value" strategy next to buy-one and buy-all (the lowest price per cookie [BC-001]: ../business-case.md [PP-001]: ../project-plan.md [SA-001]: ../stakeholder-analysis.md +[9a6796f]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/commit/9a6796f775680effe37a54f2609c3915140c8c0e diff --git a/docs/project-plan.md b/docs/project-plan.md index ac60a99..3fb87c7 100644 --- a/docs/project-plan.md +++ b/docs/project-plan.md @@ -1,116 +1,118 @@ -# Project Plan: Cookie Clicker Bot - -## Metadata -| Key | Value | -| --- | --- | -| ID | PP-001 | -| CrossReference | [BC-001], [SA-001] | -| Language | en | -| Domain | it | - -## Version History -| Date | Status | Author | Reviewer | Change | Commit | -| --- | --- | --- | --- | --- | --- | -| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | S01 | Initial version: seven gateways; accepted by S01 in chat | [cadc5ef] | -| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | S01 | Add MIL-008 (smarter purchase rule); accepted by S01 in chat | pending | - ---- - -## Purpose - -This plan schedules eight gateways (`MIL-001` to `MIL-008`) that take the Cookie Clicker bot from an accepted baseline to a documented, tested and published repository. The Business Case sets no deadline, so the window 2026-10-28 to 2026-11-04 is a planning assumption of this plan. Each gateway ends in a decision and a deliverable and is delivered by one pull request on its own branch. - -## Planning Assumptions - -- Week 1 starts 2026-10-28; the plan ends by 2026-11-04 (a planning assumption, see Open Issues). -- Phase length: one day per gateway, except the baseline and documentation, which are longer. Gateways 003 to 006 follow the Udemy lecture steps 1 to 5. -- One branch and one pull request per gateway, each closing the issues of its tasks. -- The user stories and use case steps of the journey are skipped for this project (ADR-0001), so no gateway lists stories. -- Autonomous mode: S01 accepts this plan in chat; the AI acts as stand-in reviewer and records it in each `RC-*`. - -## Gateway Schedule - -| Gateway | Document | Window | Decision date | Owner | Stories | Main deliverable | Milestone | -| --- | --- | --- | --- | --- | --- | --- | --- | -| Baseline and repository set-up | [MIL-001] | 2026-10-28 | 2026-10-28 | S01 | none (documents) | Accepted baseline, ADRs, repository description and topics | [Milestone 1] | -| Project scaffold and tooling | [MIL-002] | 2026-10-28 to 2026-10-29 | 2026-10-29 | S01 | none (technical tasks) | `pyproject.toml`, layout, `.gitignore`, Doxyfile, README, Docker image | [Milestone 2] | -| Lecture steps 1 and 2 | [MIL-003] | 2026-10-29 to 2026-10-30 | 2026-10-30 | S01 | none (technical tasks) | Open game, dismiss language, click cookie, screenshots 1 and 2 | [Milestone 3] | -| Lecture step 3 | [MIL-004] | 2026-10-30 to 2026-10-31 | 2026-10-31 | S01 | none (technical tasks) | Parsing, selection rule, purchase every 5 s, screenshot 3 | [Milestone 4] | -| Lecture step 4 | [MIL-005] | 2026-10-31 to 2026-11-01 | 2026-11-01 | S01 | none (technical tasks) | 5 minute run, cookies per second, screenshot 4 | [Milestone 5] | -| Lecture step 5 | [MIL-006] | 2026-11-01 to 2026-11-02 | 2026-11-02 | S01 | none (technical tasks) | Strategy options, comparison, screenshot 5 | [Milestone 6] | -| Documentation, wiki and release | [MIL-007] | 2026-11-02 to 2026-11-04 | 2026-11-04 | S01 | none (documentation) | README, Doxygen, wiki in en, da and ms, final checks | [Milestone 7] | -| Smarter purchase rule | [MIL-008] | 2026-11-05 to 2026-11-06 | 2026-11-06 | S01 | none (technical tasks) | Best value strategy, measured comparison, wiki note in en, da and ms | [Milestone 8] | - -```plantuml -@startgantt -Project starts 2026-10-28 -[Baseline] starts 2026-10-28 and ends 2026-10-28 -[Scaffold] starts 2026-10-28 and ends 2026-10-29 -[Steps 1 and 2] starts 2026-10-29 and ends 2026-10-30 -[Step 3] starts 2026-10-30 and ends 2026-10-31 -[Step 4] starts 2026-10-31 and ends 2026-11-01 -[Step 5] starts 2026-11-01 and ends 2026-11-02 -[Documentation] starts 2026-11-02 and ends 2026-11-04 -[Baseline Go/No-Go] happens 2026-10-28 -[Scaffold Go/No-Go] happens 2026-10-29 -[Steps 1 and 2 Go/No-Go] happens 2026-10-30 -[Step 3 Go/No-Go] happens 2026-10-31 -[Step 4 Go/No-Go] happens 2026-11-01 -[Step 5 Go/No-Go] happens 2026-11-02 -[Release Go/No-Go] happens 2026-11-04 -@endgantt -``` - -## Scope Coverage - -| Business Case scope item | Gateway | -| --- | --- | -| Selenium bot for the game, with the assignment's function names | MIL-003, MIL-004, MIL-005 | -| Lecture steps 1 to 5 | MIL-003 (1 and 2), MIL-004 (3), MIL-005 (4), MIL-006 (5) | -| Python project files: `src/`, `tests/`, `docs/`, `pyproject.toml`, `constants.py`, `.gitignore`, Doxyfile, README | MIL-002, MIL-007 | -| Unit tests with `pytest` | MIL-002 to MIL-006 | -| One screenshot per lecture step in README and wiki | MIL-003 to MIL-006 (capture), MIL-007 (use) | -| Wiki in en, da and ms | MIL-007 | -| Repository description and topics | MIL-001 | - -## Dependencies - -``` -MIL-001 → MIL-002 → MIL-003 → MIL-004 → MIL-005 → MIL-006 → MIL-007 -``` - -A No-Go on a gateway stops all later gateways until it is fixed and re-reviewed. The end date then moves by the days spent on the rework; S01 decides a move of the end date. - -## Plan Risks - -| Risk | Impact | Mitigation | -| --- | --- | --- | -| The game changes its page structure (Business Case risk: page changes) | Steps 1 to 4 need rework | Selectors only in `constants.py`; the first task records the page as seen | -| The headless container cannot run the game well | Screenshots and the 5 minute run are late | Try the container in MIL-002; pin Chromium; fall back to a shorter duration constant for tests only | -| Stand-in review misses a defect | Defect reaches `main` | `RC-*` marks the review as not independent; S01 reviews the final summary | -| Translations take longer than planned | MIL-007 slips | Write English first; da and ms follow page by page; no extra pages | - -## Open Issues - -- Resolved: S01 accepted the plan in chat on 2026-10-28; the target repository is `origin`, as S01 confirmed. - ---- - -[BC-001]: ./business-case.md -[SA-001]: ./stakeholder-analysis.md -[MIL-001]: ./milestones/mil-001-baseline.md -[MIL-002]: ./milestones/mil-002-scaffold.md -[MIL-003]: ./milestones/mil-003-click-bot.md -[MIL-004]: ./milestones/mil-004-buy-upgrades.md -[MIL-005]: ./milestones/mil-005-timed-run.md -[MIL-006]: ./milestones/mil-006-strategy.md -[MIL-007]: ./milestones/mil-007-documentation.md -[MIL-008]: ./milestones/mil-008-smarter-rule.md -[cadc5ef]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/commit/cadc5eff21d9afc09799d4bc2e3f6ac17ab2612a -[Milestone 1]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/milestone/290 -[Milestone 2]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/milestone/291 -[Milestone 3]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/milestone/292 -[Milestone 4]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/milestone/293 -[Milestone 5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/milestone/294 -[Milestone 6]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/milestone/295 -[Milestone 7]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/milestone/296 +# Project Plan: Cookie Clicker Bot + +## Metadata +| Key | Value | +| --- | --- | +| ID | PP-001 | +| CrossReference | [BC-001], [SA-001] | +| Language | en | +| Domain | it | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | S01 | Initial version: seven gateways; accepted by S01 in chat | [cadc5ef] | +| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | S01 | Add MIL-008 (smarter purchase rule); accepted by S01 in chat | [9a6796f] | + +--- + +## Purpose + +This plan schedules eight gateways (`MIL-001` to `MIL-008`) that take the Cookie Clicker bot from an accepted baseline to a documented, tested and published repository. The Business Case sets no deadline, so the window 2026-10-28 to 2026-11-04 is a planning assumption of this plan. Each gateway ends in a decision and a deliverable and is delivered by one pull request on its own branch. + +## Planning Assumptions + +- Week 1 starts 2026-10-28; the plan ends by 2026-11-04 (a planning assumption, see Open Issues). +- Phase length: one day per gateway, except the baseline and documentation, which are longer. Gateways 003 to 006 follow the Udemy lecture steps 1 to 5. +- One branch and one pull request per gateway, each closing the issues of its tasks. +- The user stories and use case steps of the journey are skipped for this project (ADR-0001), so no gateway lists stories. +- Autonomous mode: S01 accepts this plan in chat; the AI acts as stand-in reviewer and records it in each `RC-*`. + +## Gateway Schedule + +| Gateway | Document | Window | Decision date | Owner | Stories | Main deliverable | Milestone | +| --- | --- | --- | --- | --- | --- | --- | --- | +| Baseline and repository set-up | [MIL-001] | 2026-10-28 | 2026-10-28 | S01 | none (documents) | Accepted baseline, ADRs, repository description and topics | [Milestone 1] | +| Project scaffold and tooling | [MIL-002] | 2026-10-28 to 2026-10-29 | 2026-10-29 | S01 | none (technical tasks) | `pyproject.toml`, layout, `.gitignore`, Doxyfile, README, Docker image | [Milestone 2] | +| Lecture steps 1 and 2 | [MIL-003] | 2026-10-29 to 2026-10-30 | 2026-10-30 | S01 | none (technical tasks) | Open game, dismiss language, click cookie, screenshots 1 and 2 | [Milestone 3] | +| Lecture step 3 | [MIL-004] | 2026-10-30 to 2026-10-31 | 2026-10-31 | S01 | none (technical tasks) | Parsing, selection rule, purchase every 5 s, screenshot 3 | [Milestone 4] | +| Lecture step 4 | [MIL-005] | 2026-10-31 to 2026-11-01 | 2026-11-01 | S01 | none (technical tasks) | 5 minute run, cookies per second, screenshot 4 | [Milestone 5] | +| Lecture step 5 | [MIL-006] | 2026-11-01 to 2026-11-02 | 2026-11-02 | S01 | none (technical tasks) | Strategy options, comparison, screenshot 5 | [Milestone 6] | +| Documentation, wiki and release | [MIL-007] | 2026-11-02 to 2026-11-04 | 2026-11-04 | S01 | none (documentation) | README, Doxygen, wiki in en, da and ms, final checks | [Milestone 7] | +| Smarter purchase rule | [MIL-008] | 2026-11-05 to 2026-11-06 | 2026-11-06 | S01 | none (technical tasks) | Best value strategy, measured comparison, wiki note in en, da and ms | [Milestone 8] | + +```plantuml +@startgantt +Project starts 2026-10-28 +[Baseline] starts 2026-10-28 and ends 2026-10-28 +[Scaffold] starts 2026-10-28 and ends 2026-10-29 +[Steps 1 and 2] starts 2026-10-29 and ends 2026-10-30 +[Step 3] starts 2026-10-30 and ends 2026-10-31 +[Step 4] starts 2026-10-31 and ends 2026-11-01 +[Step 5] starts 2026-11-01 and ends 2026-11-02 +[Documentation] starts 2026-11-02 and ends 2026-11-04 +[Baseline Go/No-Go] happens 2026-10-28 +[Scaffold Go/No-Go] happens 2026-10-29 +[Steps 1 and 2 Go/No-Go] happens 2026-10-30 +[Step 3 Go/No-Go] happens 2026-10-31 +[Step 4 Go/No-Go] happens 2026-11-01 +[Step 5 Go/No-Go] happens 2026-11-02 +[Release Go/No-Go] happens 2026-11-04 +@endgantt +``` + +## Scope Coverage + +| Business Case scope item | Gateway | +| --- | --- | +| Selenium bot for the game, with the assignment's function names | MIL-003, MIL-004, MIL-005 | +| Lecture steps 1 to 5 | MIL-003 (1 and 2), MIL-004 (3), MIL-005 (4), MIL-006 (5) | +| Python project files: `src/`, `tests/`, `docs/`, `pyproject.toml`, `constants.py`, `.gitignore`, Doxyfile, README | MIL-002, MIL-007 | +| Unit tests with `pytest` | MIL-002 to MIL-006 | +| One screenshot per lecture step in README and wiki | MIL-003 to MIL-006 (capture), MIL-007 (use) | +| Wiki in en, da and ms | MIL-007 | +| Repository description and topics | MIL-001 | + +## Dependencies + +``` +MIL-001 → MIL-002 → MIL-003 → MIL-004 → MIL-005 → MIL-006 → MIL-007 +``` + +A No-Go on a gateway stops all later gateways until it is fixed and re-reviewed. The end date then moves by the days spent on the rework; S01 decides a move of the end date. + +## Plan Risks + +| Risk | Impact | Mitigation | +| --- | --- | --- | +| The game changes its page structure (Business Case risk: page changes) | Steps 1 to 4 need rework | Selectors only in `constants.py`; the first task records the page as seen | +| The headless container cannot run the game well | Screenshots and the 5 minute run are late | Try the container in MIL-002; pin Chromium; fall back to a shorter duration constant for tests only | +| Stand-in review misses a defect | Defect reaches `main` | `RC-*` marks the review as not independent; S01 reviews the final summary | +| Translations take longer than planned | MIL-007 slips | Write English first; da and ms follow page by page; no extra pages | + +## Open Issues + +- Resolved: S01 accepted the plan in chat on 2026-10-28; the target repository is `origin`, as S01 confirmed. + +--- + +[BC-001]: ./business-case.md +[SA-001]: ./stakeholder-analysis.md +[MIL-001]: ./milestones/mil-001-baseline.md +[MIL-002]: ./milestones/mil-002-scaffold.md +[MIL-003]: ./milestones/mil-003-click-bot.md +[MIL-004]: ./milestones/mil-004-buy-upgrades.md +[MIL-005]: ./milestones/mil-005-timed-run.md +[MIL-006]: ./milestones/mil-006-strategy.md +[MIL-007]: ./milestones/mil-007-documentation.md +[MIL-008]: ./milestones/mil-008-smarter-rule.md +[cadc5ef]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/commit/cadc5eff21d9afc09799d4bc2e3f6ac17ab2612a +[Milestone 1]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/milestone/290 +[Milestone 2]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/milestone/291 +[Milestone 3]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/milestone/292 +[Milestone 4]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/milestone/293 +[Milestone 5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/milestone/294 +[Milestone 6]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/milestone/295 +[Milestone 7]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/milestone/296 +[Milestone 8]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/milestone/308 +[9a6796f]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/commit/9a6796f775680effe37a54f2609c3915140c8c0e diff --git a/docs/sqa/reviews/rc-019-mil-008.md b/docs/sqa/reviews/rc-019-mil-008.md index c309f14..c6f04e9 100644 --- a/docs/sqa/reviews/rc-019-mil-008.md +++ b/docs/sqa/reviews/rc-019-mil-008.md @@ -9,7 +9,7 @@ ## Version History | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | -| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Review record, full review | pending | +| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Review record, full review | [9a6796f] | --- @@ -62,3 +62,4 @@ Go — MIL-008 meets every Mandatory criterion. No blocking finding. The review [MIL-008]: ../../milestones/mil-008-smarter-rule.md [QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.md +[9a6796f]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/commit/9a6796f775680effe37a54f2609c3915140c8c0e diff --git a/docs/sqa/traceability-matrix.md b/docs/sqa/traceability-matrix.md index 6e0e77c..d20f1f6 100644 --- a/docs/sqa/traceability-matrix.md +++ b/docs/sqa/traceability-matrix.md @@ -10,7 +10,7 @@ | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | | 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Initial version: baseline artifacts | [cadc5ef] | -| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Add MIL-008 and RC-019 | pending | +| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Add MIL-008 and RC-019 | [9a6796f] | --- @@ -93,3 +93,4 @@ Tracks backward and forward links between artifact instances so that the Busines [MIL-007]: ../milestones/mil-007-documentation.md [MIL-008]: ../milestones/mil-008-smarter-rule.md [cadc5ef]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/commit/cadc5eff21d9afc09799d4bc2e3f6ac17ab2612a +[9a6796f]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/commit/9a6796f775680effe37a54f2609c3915140c8c0e -- 2.54.0 From ecbe0d26713c883cede898ef11030facdbbd046e Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 28 Oct 2026 23:19:42 +0800 Subject: [PATCH 3/5] Add the best value purchase rule and its command line option Task: MIL-008#1 Task: MIL-008#2 Task: MIL-008#3 --- README.md | 1 + .../application/game_loop.py | 212 ++-- src/cookie_clicker_bot/cli.py | 300 +++--- src/cookie_clicker_bot/constants.py | 6 + src/cookie_clicker_bot/domain/buy_strategy.py | 30 +- src/cookie_clicker_bot/domain/upgrades.py | 133 ++- .../infrastructure/selenium_game.py | 485 ++++----- tests/integration/test_selenium_game.py | 919 ++++++++++-------- tests/unit/test_cli.py | 457 +++++---- tests/unit/test_game_loop.py | 532 +++++----- tests/unit/test_upgrades.py | 184 ++-- 11 files changed, 1764 insertions(+), 1495 deletions(-) diff --git a/README.md b/README.md index 6ae9e24..d5ed567 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ The bot plays for 5 minutes, then prints one line on standard output, for exampl | `--duration SECONDS` | How long to play | 300 | | `--interval SECONDS` | Time between upgrade checks (greater than 0) | 5 | | `--buy-all` | Buy every affordable upgrade, most expensive first, instead of one | off | +| `--best-value` | Buy the affordable upgrade with the lowest price per cookie per second (not together with `--buy-all`) | off | | `--headless` | Run Chromium without a window | off (on in Docker) | | `--screenshot PATH` | Save a screenshot when the run ends | none | | `--screenshot-start PATH` | Save a screenshot before the first click | none | diff --git a/src/cookie_clicker_bot/application/game_loop.py b/src/cookie_clicker_bot/application/game_loop.py index ad94f11..e314019 100644 --- a/src/cookie_clicker_bot/application/game_loop.py +++ b/src/cookie_clicker_bot/application/game_loop.py @@ -1,96 +1,116 @@ -"""@file game_loop.py -@brief The game loop of the bot, written against the game port. -""" - -import time -from collections.abc import Callable -from dataclasses import dataclass - -from cookie_clicker_bot.application.ports import GamePort -from cookie_clicker_bot.domain.buy_strategy import BuyStrategy -from cookie_clicker_bot.domain.upgrades import ( - Upgrade, - choose_affordable_upgrades, - choose_most_expensive_affordable, -) - - -@dataclass(frozen=True, slots=True) -class PlayResult: - """@brief What a run did.""" - - ## Number of clicks on the big cookie. - clicks: int - ## Names of the upgrades bought, in the order they were bought. - purchases: tuple[str, ...] - ## Seconds the run lasted, measured with the clock given to `click_and_buy`. - elapsed_seconds: float - - -def click_cookie(game: GamePort) -> None: - """@brief Click the big cookie once (lecture step 2). - - @param game The game to play. - """ - game.click_cookie() - - -def buy_most_expensive_upgrade(game: GamePort) -> Upgrade | None: - """@brief Buy the most expensive upgrade the player can afford (lecture step 3). - - @param game The game to play. - @return The upgrade that was bought, or None when nothing was affordable or - the click did not reach the product. - """ - chosen = choose_most_expensive_affordable(game.upgrades(), game.cookie_count()) - if chosen is not None and game.buy(chosen): - return chosen - return None - - -def buy_upgrades(game: GamePort, strategy: BuyStrategy) -> list[Upgrade]: - """@brief Do one purchase round with the given strategy (lecture step 5). - - @param game The game to play. - @param strategy BUY_ONE buys the most expensive affordable upgrade, BUY_ALL buys - every upgrade the cookies pay for. - @return The upgrades really bought, in order; a click that missed is left out. - """ - if strategy is BuyStrategy.BUY_ONE: - bought = buy_most_expensive_upgrade(game) - return [] if bought is None else [bought] - plan = choose_affordable_upgrades(game.upgrades(), game.cookie_count()) - return [upgrade for upgrade in plan if game.buy(upgrade)] - - -def click_and_buy( - game: GamePort, - duration_seconds: float, - check_interval_seconds: float, - clock: Callable[[], float] = time.monotonic, - strategy: BuyStrategy = BuyStrategy.BUY_ONE, -) -> PlayResult: - """@brief Click the cookie nonstop and check the upgrades at every interval. - - @param game The game to play. - @param duration_seconds How long to play. - @param check_interval_seconds Time between two upgrade checks; the first check - comes one interval after the start. - @param clock Monotonic clock in seconds; a test passes a fake one. - @param strategy What to buy at each check. - @return The clicks, the names of the upgrades bought in order, and the time spent. - """ - start = clock() - deadline = start + duration_seconds - next_check = start + check_interval_seconds - clicks = 0 - purchases: list[str] = [] - while (now := clock()) < deadline: - click_cookie(game) - clicks += 1 - if now >= next_check: - next_check = now + check_interval_seconds - purchases.extend(upgrade.name for upgrade in buy_upgrades(game, strategy)) - return PlayResult( - clicks=clicks, purchases=tuple(purchases), elapsed_seconds=clock() - start - ) +"""@file game_loop.py +@brief The game loop of the bot, written against the game port. +""" + +import time +from collections.abc import Callable +from dataclasses import dataclass + +from cookie_clicker_bot.application.ports import GamePort +from cookie_clicker_bot.domain.buy_strategy import BuyStrategy +from cookie_clicker_bot.domain.upgrades import ( + Upgrade, + choose_affordable_upgrades, + choose_best_value, + choose_most_expensive_affordable, +) + + +@dataclass(frozen=True, slots=True) +class PlayResult: + """@brief What a run did.""" + + ## Number of clicks on the big cookie. + clicks: int + ## Names of the upgrades bought, in the order they were bought. + purchases: tuple[str, ...] + ## Seconds the run lasted, measured with the clock given to `click_and_buy`. + elapsed_seconds: float + + +def click_cookie(game: GamePort) -> None: + """@brief Click the big cookie once (lecture step 2). + + @param game The game to play. + """ + game.click_cookie() + + +def buy_most_expensive_upgrade(game: GamePort) -> Upgrade | None: + """@brief Buy the most expensive upgrade the player can afford (lecture step 3). + + @param game The game to play. + @return The upgrade that was bought, or None when nothing was affordable or + the click did not reach the product. + """ + chosen = choose_most_expensive_affordable(game.upgrades(), game.cookie_count()) + if chosen is not None and game.buy(chosen): + return chosen + return None + + +def buy_best_value_upgrade(game: GamePort) -> Upgrade | None: + """@brief Buy the affordable upgrade with the lowest price per cookie per second. + + @param game The game to play. + @return The upgrade that was bought, or None when nothing was affordable or + the click did not reach the product. + """ + chosen = choose_best_value(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, BEST_VALUE buys the one with the lowest + price per cookie per second. + @return The upgrades really bought, in order; a click that missed is left out. + """ + if strategy is not BuyStrategy.BUY_ALL: + buy_one = ( + buy_best_value_upgrade + if strategy is BuyStrategy.BEST_VALUE + else buy_most_expensive_upgrade + ) + bought = buy_one(game) + return [] if bought is None else [bought] + plan = choose_affordable_upgrades(game.upgrades(), game.cookie_count()) + return [upgrade for upgrade in plan if game.buy(upgrade)] + + +def click_and_buy( + game: GamePort, + duration_seconds: float, + check_interval_seconds: float, + clock: Callable[[], float] = time.monotonic, + strategy: BuyStrategy = BuyStrategy.BUY_ONE, +) -> PlayResult: + """@brief Click the cookie nonstop and check the upgrades at every interval. + + @param game The game to play. + @param duration_seconds How long to play. + @param check_interval_seconds Time between two upgrade checks; the first check + comes one interval after the start. + @param clock Monotonic clock in seconds; a test passes a fake one. + @param strategy What to buy at each check. + @return The clicks, the names of the upgrades bought in order, and the time spent. + """ + start = clock() + deadline = start + duration_seconds + next_check = start + check_interval_seconds + clicks = 0 + purchases: list[str] = [] + while (now := clock()) < deadline: + click_cookie(game) + clicks += 1 + if now >= next_check: + next_check = now + check_interval_seconds + purchases.extend(upgrade.name for upgrade in buy_upgrades(game, strategy)) + return PlayResult( + clicks=clicks, purchases=tuple(purchases), elapsed_seconds=clock() - start + ) diff --git a/src/cookie_clicker_bot/cli.py b/src/cookie_clicker_bot/cli.py index a7cdce4..a7437e6 100644 --- a/src/cookie_clicker_bot/cli.py +++ b/src/cookie_clicker_bot/cli.py @@ -1,139 +1,161 @@ -"""@file cli.py -@brief Command-line entry point (the composition root of the bot). -""" - -import argparse -import logging -from collections.abc import Callable, Sequence -from pathlib import Path - -from cookie_clicker_bot import constants -from cookie_clicker_bot.application.game_loop import click_and_buy -from cookie_clicker_bot.application.ports import GamePort -from cookie_clicker_bot.application.report import format_result -from cookie_clicker_bot.domain.buy_strategy import BuyStrategy -from cookie_clicker_bot.infrastructure.browser import create_driver -from cookie_clicker_bot.infrastructure.selenium_game import SeleniumGame - -logger = logging.getLogger(__name__) - - -def create_game(headless: bool) -> GamePort: - """@brief Build the Selenium game. - - @param headless True to run Chromium without a window. - @return The game, ready to open. - """ - return SeleniumGame(create_driver(headless)) - - -def positive_float(text: str) -> float: - """@brief Read a number greater than zero from the command line. - - @param text The argument text. - @return The number. - @throws argparse.ArgumentTypeError When the text is not a number above zero. - """ - message = constants.POSITIVE_NUMBER_ERROR_TEMPLATE.format(value=text) - try: - value = float(text) - except ValueError: - raise argparse.ArgumentTypeError(message) from None - if not value > 0: - raise argparse.ArgumentTypeError(message) - return value - - -def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: - """@brief Read the command line. - - @param argv Arguments without the program name; None reads `sys.argv`. - @return The parsed options. - """ - parser = argparse.ArgumentParser( - prog="cookie-clicker-bot", - description=( - "Plays Cookie Clicker: clicks the big cookie as fast as it can " - "and buys upgrades." - ), - ) - parser.add_argument( - "--duration", - type=float, - default=constants.RUN_DURATION_SECONDS, - help="seconds to play (default: %(default)s)", - ) - parser.add_argument( - "--interval", - type=positive_float, - default=constants.UPGRADE_CHECK_INTERVAL_SECONDS, - help="seconds between upgrade checks, greater than 0 (default: %(default)s)", - ) - parser.add_argument( - "--buy-all", - action="store_true", - help=( - "at each check buy every affordable upgrade, most expensive first " - "(default: buy only the most expensive affordable one)" - ), - ) - parser.add_argument( - "--headless", - action="store_true", - help="run Chromium without a window (the Docker image does this)", - ) - parser.add_argument( - "--screenshot-start", - type=Path, - help="write a screenshot of the game before the first click", - ) - parser.add_argument( - "--screenshot", - type=Path, - help="write a screenshot of the game when the run ends", - ) - return parser.parse_args(argv) - - -def main( - argv: Sequence[str] | None = None, - game_factory: Callable[[bool], GamePort] = create_game, -) -> int: - """@brief Start the bot. - - @param argv Arguments without the program name; None reads `sys.argv`. - @param game_factory Builds the game; a test passes a fake. - @return Process exit code, 0 when the run finished. - """ - logging.basicConfig(level=logging.INFO, format="%(message)s") - args = parse_args(argv) - game = game_factory(args.headless) - try: - game.open_game() - game.dismiss_language() - if args.screenshot_start is not None: - game.save_screenshot(args.screenshot_start) - strategy = BuyStrategy.BUY_ALL if args.buy_all else BuyStrategy.BUY_ONE - logger.info( - "checking upgrades every %s seconds, strategy %s", - args.interval, - strategy.value, - ) - result = click_and_buy(game, args.duration, args.interval, strategy=strategy) - for name in result.purchases: - logger.info("bought %s", name) - logger.info( - "clicked the cookie %d times and bought %d upgrades in %.1f seconds", - result.clicks, - len(result.purchases), - result.elapsed_seconds, - ) - cookies_per_second = game.cookies_per_second() - logger.info("%s", format_result(cookies_per_second)) - if args.screenshot is not None: - game.save_screenshot(args.screenshot) - finally: - game.close() - # The program's required result: the only line written to stdout (logs use stderr). - print(format_result(cookies_per_second)) - return 0 +"""@file cli.py +@brief Command-line entry point (the composition root of the bot). +""" + +import argparse +import logging +from collections.abc import Callable, Sequence +from pathlib import Path + +from cookie_clicker_bot import constants +from cookie_clicker_bot.application.game_loop import click_and_buy +from cookie_clicker_bot.application.ports import GamePort +from cookie_clicker_bot.application.report import format_result +from cookie_clicker_bot.domain.buy_strategy import BuyStrategy +from cookie_clicker_bot.infrastructure.browser import create_driver +from cookie_clicker_bot.infrastructure.selenium_game import SeleniumGame + +logger = logging.getLogger(__name__) + + +def create_game(headless: bool) -> GamePort: + """@brief Build the Selenium game. + + @param headless True to run Chromium without a window. + @return The game, ready to open. + """ + return SeleniumGame(create_driver(headless)) + + +def positive_float(text: str) -> float: + """@brief Read a number greater than zero from the command line. + + @param text The argument text. + @return The number. + @throws argparse.ArgumentTypeError When the text is not a number above zero. + """ + message = constants.POSITIVE_NUMBER_ERROR_TEMPLATE.format(value=text) + try: + value = float(text) + except ValueError: + raise argparse.ArgumentTypeError(message) from None + if not value > 0: + raise argparse.ArgumentTypeError(message) + return value + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + """@brief Read the command line. + + @param argv Arguments without the program name; None reads `sys.argv`. + @return The parsed options. + """ + parser = argparse.ArgumentParser( + prog="cookie-clicker-bot", + description=( + "Plays Cookie Clicker: clicks the big cookie as fast as it can " + "and buys upgrades." + ), + ) + parser.add_argument( + "--duration", + type=float, + default=constants.RUN_DURATION_SECONDS, + help="seconds to play (default: %(default)s)", + ) + parser.add_argument( + "--interval", + type=positive_float, + default=constants.UPGRADE_CHECK_INTERVAL_SECONDS, + help="seconds between upgrade checks, greater than 0 (default: %(default)s)", + ) + choice = parser.add_mutually_exclusive_group() + choice.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)" + ), + ) + choice.add_argument( + "--best-value", + action="store_true", + help=( + "at each check buy the affordable upgrade with the lowest price per " + "cookie per second" + ), + ) + parser.add_argument( + "--headless", + action="store_true", + help="run Chromium without a window (the Docker image does this)", + ) + parser.add_argument( + "--screenshot-start", + type=Path, + help="write a screenshot of the game before the first click", + ) + parser.add_argument( + "--screenshot", + type=Path, + help="write a screenshot of the game when the run ends", + ) + return parser.parse_args(argv) + + +def choose_strategy(args: argparse.Namespace) -> BuyStrategy: + """@brief Turn the purchase options into a strategy. + + @param args The parsed options. + @return BUY_ALL, BEST_VALUE or, by default, BUY_ONE. + """ + if args.buy_all: + return BuyStrategy.BUY_ALL + if args.best_value: + return BuyStrategy.BEST_VALUE + return BuyStrategy.BUY_ONE + + +def main( + argv: Sequence[str] | None = None, + game_factory: Callable[[bool], GamePort] = create_game, +) -> int: + """@brief Start the bot. + + @param argv Arguments without the program name; None reads `sys.argv`. + @param game_factory Builds the game; a test passes a fake. + @return Process exit code, 0 when the run finished. + """ + logging.basicConfig(level=logging.INFO, format="%(message)s") + args = parse_args(argv) + game = game_factory(args.headless) + try: + game.open_game() + game.dismiss_language() + if args.screenshot_start is not None: + game.save_screenshot(args.screenshot_start) + strategy = choose_strategy(args) + logger.info( + "checking upgrades every %s seconds, strategy %s", + args.interval, + strategy.value, + ) + result = click_and_buy(game, args.duration, args.interval, strategy=strategy) + for name in result.purchases: + logger.info("bought %s", name) + logger.info( + "clicked the cookie %d times and bought %d upgrades in %.1f seconds", + result.clicks, + len(result.purchases), + result.elapsed_seconds, + ) + cookies_per_second = game.cookies_per_second() + logger.info("%s", format_result(cookies_per_second)) + if args.screenshot is not None: + game.save_screenshot(args.screenshot) + finally: + game.close() + # The program's required result: the only line written to stdout (logs use stderr). + print(format_result(cookies_per_second)) + return 0 diff --git a/src/cookie_clicker_bot/constants.py b/src/cookie_clicker_bot/constants.py index f484f79..e820bb0 100644 --- a/src/cookie_clicker_bot/constants.py +++ b/src/cookie_clicker_bot/constants.py @@ -90,6 +90,12 @@ 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();" +## Script that reads the cookies per second of one unit of each product, by index. +JS_PRODUCTION_SCRIPT: Final[str] = "return Game.ObjectsById.map(o => o.storedCps);" + +## Prefix of a product element id; the rest is the index into the script's list. +PRODUCT_ID_PREFIX: Final[str] = "product" + ## Chromium arguments needed to run headless inside a container. HEADLESS_BROWSER_ARGUMENTS: Final[tuple[str, ...]] = ( "--headless=new", diff --git a/src/cookie_clicker_bot/domain/buy_strategy.py b/src/cookie_clicker_bot/domain/buy_strategy.py index 3aa1d2f..8ad8d2d 100644 --- a/src/cookie_clicker_bot/domain/buy_strategy.py +++ b/src/cookie_clicker_bot/domain/buy_strategy.py @@ -1,14 +1,16 @@ -"""@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" +"""@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" + ## Buy the affordable upgrade with the lowest price per cookie per second. + BEST_VALUE = "best-value" diff --git a/src/cookie_clicker_bot/domain/upgrades.py b/src/cookie_clicker_bot/domain/upgrades.py index 571ccb9..a0e5421 100644 --- a/src/cookie_clicker_bot/domain/upgrades.py +++ b/src/cookie_clicker_bot/domain/upgrades.py @@ -1,52 +1,81 @@ -"""@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 +"""@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 + ## Cookies per second one unit makes; None when the page did not tell. + cookies_per_second: float | None = None + + +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_best_value(upgrades: Sequence[Upgrade], cookies: int) -> Upgrade | None: + """@brief Pick the affordable upgrade that pays for itself fastest. + + The value of an upgrade is its price divided by the cookies per second it makes, + so the lowest ratio wins. Upgrades with unknown or zero production are skipped; + when none of the affordable ones has a known production the rule falls back to + the most expensive affordable upgrade. + + @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. + """ + producing = [ + upgrade + for upgrade in upgrades + if upgrade.price <= cookies + and upgrade.cookies_per_second is not None + and upgrade.cookies_per_second > 0 + ] + if not producing: + return choose_most_expensive_affordable(upgrades, cookies) + return min( + producing, + key=lambda upgrade: upgrade.price / (upgrade.cookies_per_second or 1.0), + ) + + +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 diff --git a/src/cookie_clicker_bot/infrastructure/selenium_game.py b/src/cookie_clicker_bot/infrastructure/selenium_game.py index 7ae7932..b13f9e8 100644 --- a/src/cookie_clicker_bot/infrastructure/selenium_game.py +++ b/src/cookie_clicker_bot/infrastructure/selenium_game.py @@ -1,229 +1,256 @@ -"""@file selenium_game.py -@brief Selenium adapter that implements the game port. -""" - -import time -from collections.abc import Callable, Sequence -from pathlib import Path -from typing import Any, Protocol, TypeVar - -from selenium.common.exceptions import ( - ElementClickInterceptedException, - NoSuchElementException, - StaleElementReferenceException, -) -from selenium.webdriver.common.by import By - -from cookie_clicker_bot import constants -from cookie_clicker_bot.domain.parsing import ( - parse_amount, - parse_cookie_count, - parse_cookies_per_second, -) -from cookie_clicker_bot.domain.upgrades import Upgrade - -T = TypeVar("T") - - -class ElementLike(Protocol): - """@brief The part of a Selenium element that the adapter uses.""" - - @property - def text(self) -> str: - """@brief Visible text of the element.""" - ... - - def click(self) -> None: - """@brief Click the element.""" - - def is_displayed(self) -> bool: - """@brief True when the element is visible.""" - ... - - def get_attribute(self, name: str) -> str | None: - """@brief Read an attribute of the element.""" - ... - - def find_element(self, by: str, value: str) -> "ElementLike": - """@brief Find one element inside this element.""" - ... - - -class DriverLike(Protocol): - """@brief The part of a Selenium driver that the adapter uses.""" - - def get(self, url: str) -> None: - """@brief Load a page.""" - - def find_element(self, by: str, value: str) -> ElementLike: - """@brief Find one element.""" - ... - - def find_elements(self, by: str, value: str) -> Sequence[ElementLike]: - """@brief Find every element that matches.""" - ... - - def save_screenshot(self, filename: str) -> bool: - """@brief Write a PNG screenshot.""" - ... - - def execute_script(self, script: str, *args: Any) -> Any: # noqa: ANN401 - """@brief Run JavaScript in the page; the arguments are `arguments[n]`.""" - ... - - def quit(self) -> None: - """@brief Close the browser.""" - - -class SeleniumGame: - """@brief Plays the game page through a Selenium driver.""" - - def __init__( - self, - driver: DriverLike, - timeout_seconds: float = constants.WAIT_TIMEOUT_SECONDS, - clock: Callable[[], float] = time.monotonic, - sleep: Callable[[float], None] = time.sleep, - ) -> None: - """@brief Wrap a driver. - - @param driver The Selenium driver (or a fake in tests). - @param timeout_seconds Longest wait for an element. - @param clock Monotonic clock in seconds. - @param sleep Pause function used between polls. - """ - self._driver = driver - self._timeout = timeout_seconds - self._clock = clock - self._sleep = sleep - - def open_game(self) -> None: - """@brief Load the game page.""" - self._driver.get(constants.GAME_URL) - - def dismiss_language(self) -> None: - """@brief Pick English, then wait until the big cookie can be clicked.""" - english = self._wait_for(lambda: self._visible(constants.LANGUAGE_ENGLISH_ID)) - english.click() - self._wait_for(lambda: self._visible(constants.BIG_COOKIE_ID)) - - def click_cookie(self) -> None: - """@brief Click the big cookie once.""" - try: - self._driver.find_element(By.ID, constants.BIG_COOKIE_ID).click() - except (ElementClickInterceptedException, StaleElementReferenceException): - # A banner or a re-render covered the cookie for a moment; try again. - return - - def cookie_count(self) -> int: - """@brief Read how many cookies the player has. - - @return The cookie count. - """ - return self._read_again_if_stale( - lambda: parse_cookie_count( - self._driver.find_element(By.ID, constants.COOKIE_COUNT_ID).text - ) - ) - - def cookies_per_second(self) -> float: - """@brief Read how many cookies the player makes each second. - - @return The cookies per second. - """ - return self._read_again_if_stale( - lambda: parse_cookies_per_second( - self._driver.find_element(By.ID, constants.COOKIES_PER_SECOND_ID).text - ) - ) - - def upgrades(self) -> list[Upgrade]: - """@brief Read the upgrades the player can see in the right-hand pane. - - @return The visible upgrades, affordable or not, in store order. - """ - return self._read_again_if_stale(self._read_upgrades) - - def _read_upgrades(self) -> list[Upgrade]: - """@brief Find the visible products and read each one.""" - products = self._driver.find_elements( - By.CSS_SELECTOR, constants.VISIBLE_PRODUCT_SELECTOR - ) - return [self._read_upgrade(product) for product in products] - - def _read_again_if_stale(self, read: Callable[[], T]) -> T: - """@brief Run a read; repeat it while the page re-renders under it. - - @throws StaleElementReferenceException When every attempt went stale. - """ - for _ in range(constants.STALE_READ_ATTEMPTS - 1): - try: - return read() - except StaleElementReferenceException: - self._sleep(constants.POLL_INTERVAL_SECONDS) - return read() - - def buy(self, upgrade: Upgrade) -> bool: - """@brief Buy an upgrade by clicking its product. - - @param upgrade The upgrade to buy. - @return True only when a click really reached the product. - """ - try: - element = self._driver.find_element(By.ID, upgrade.key) - try: - element.click() - except ElementClickInterceptedException: - # The cookie-consent banner covers the lower products. Click from - # inside the page instead; the banner itself is never clicked. - self._driver.execute_script(constants.JS_CLICK_SCRIPT, element) - except StaleElementReferenceException: - # The pane re-rendered under us; the next check retries. - return False - return True - - def save_screenshot(self, path: Path) -> None: - """@brief Write a screenshot of the game to a PNG file. - - @param path Target file; its folder is created when missing. - """ - path.parent.mkdir(parents=True, exist_ok=True) - self._driver.save_screenshot(str(path)) - - def close(self) -> None: - """@brief Close the browser.""" - self._driver.quit() - - @staticmethod - def _read_upgrade(product: ElementLike) -> Upgrade: - """@brief Read the id, name and price of one product element.""" - name = product.find_element(By.CSS_SELECTOR, constants.PRODUCT_NAME_SELECTOR) - price = product.find_element(By.CSS_SELECTOR, constants.PRODUCT_PRICE_SELECTOR) - key = product.get_attribute(constants.ID_ATTRIBUTE) or "" - return Upgrade(key=key, name=name.text.strip(), price=parse_amount(price.text)) - - def _visible(self, element_id: str) -> ElementLike | None: - """@brief Return the element with this id when it exists and is displayed.""" - try: - element = self._driver.find_element(By.ID, element_id) - return element if element.is_displayed() else None - except (NoSuchElementException, StaleElementReferenceException): - # Not there yet, or the page re-rendered under us: poll again. - return None - - def _wait_for(self, locate: Callable[[], T | None]) -> T: - """@brief Poll until `locate` returns a value. - - @param locate Function that returns the value, or None while not ready. - @return The value. - @throws TimeoutError When the value is not there within the timeout. - """ - deadline = self._clock() + self._timeout - while True: - found = locate() - if found is not None: - return found - if self._clock() >= deadline: - message = f"the game page was not ready within {self._timeout} s" - raise TimeoutError(message) - self._sleep(constants.POLL_INTERVAL_SECONDS) +"""@file selenium_game.py +@brief Selenium adapter that implements the game port. +""" + +import time +from collections.abc import Callable, Sequence +from pathlib import Path +from typing import Any, Protocol, TypeVar + +from selenium.common.exceptions import ( + ElementClickInterceptedException, + JavascriptException, + NoSuchElementException, + StaleElementReferenceException, +) +from selenium.webdriver.common.by import By + +from cookie_clicker_bot import constants +from cookie_clicker_bot.domain.parsing import ( + parse_amount, + parse_cookie_count, + parse_cookies_per_second, +) +from cookie_clicker_bot.domain.upgrades import Upgrade + +T = TypeVar("T") + + +class ElementLike(Protocol): + """@brief The part of a Selenium element that the adapter uses.""" + + @property + def text(self) -> str: + """@brief Visible text of the element.""" + ... + + def click(self) -> None: + """@brief Click the element.""" + + def is_displayed(self) -> bool: + """@brief True when the element is visible.""" + ... + + def get_attribute(self, name: str) -> str | None: + """@brief Read an attribute of the element.""" + ... + + def find_element(self, by: str, value: str) -> "ElementLike": + """@brief Find one element inside this element.""" + ... + + +class DriverLike(Protocol): + """@brief The part of a Selenium driver that the adapter uses.""" + + def get(self, url: str) -> None: + """@brief Load a page.""" + + def find_element(self, by: str, value: str) -> ElementLike: + """@brief Find one element.""" + ... + + def find_elements(self, by: str, value: str) -> Sequence[ElementLike]: + """@brief Find every element that matches.""" + ... + + def save_screenshot(self, filename: str) -> bool: + """@brief Write a PNG screenshot.""" + ... + + def execute_script(self, script: str, *args: Any) -> Any: # noqa: ANN401 + """@brief Run JavaScript in the page; the arguments are `arguments[n]`.""" + ... + + def quit(self) -> None: + """@brief Close the browser.""" + + +class SeleniumGame: + """@brief Plays the game page through a Selenium driver.""" + + def __init__( + self, + driver: DriverLike, + timeout_seconds: float = constants.WAIT_TIMEOUT_SECONDS, + clock: Callable[[], float] = time.monotonic, + sleep: Callable[[float], None] = time.sleep, + ) -> None: + """@brief Wrap a driver. + + @param driver The Selenium driver (or a fake in tests). + @param timeout_seconds Longest wait for an element. + @param clock Monotonic clock in seconds. + @param sleep Pause function used between polls. + """ + self._driver = driver + self._timeout = timeout_seconds + self._clock = clock + self._sleep = sleep + + def open_game(self) -> None: + """@brief Load the game page.""" + self._driver.get(constants.GAME_URL) + + def dismiss_language(self) -> None: + """@brief Pick English, then wait until the big cookie can be clicked.""" + english = self._wait_for(lambda: self._visible(constants.LANGUAGE_ENGLISH_ID)) + english.click() + self._wait_for(lambda: self._visible(constants.BIG_COOKIE_ID)) + + def click_cookie(self) -> None: + """@brief Click the big cookie once.""" + try: + self._driver.find_element(By.ID, constants.BIG_COOKIE_ID).click() + except (ElementClickInterceptedException, StaleElementReferenceException): + # A banner or a re-render covered the cookie for a moment; try again. + return + + def cookie_count(self) -> int: + """@brief Read how many cookies the player has. + + @return The cookie count. + """ + return self._read_again_if_stale( + lambda: parse_cookie_count( + self._driver.find_element(By.ID, constants.COOKIE_COUNT_ID).text + ) + ) + + def cookies_per_second(self) -> float: + """@brief Read how many cookies the player makes each second. + + @return The cookies per second. + """ + return self._read_again_if_stale( + lambda: parse_cookies_per_second( + self._driver.find_element(By.ID, constants.COOKIES_PER_SECOND_ID).text + ) + ) + + def upgrades(self) -> list[Upgrade]: + """@brief Read the upgrades the player can see in the right-hand pane. + + @return The visible upgrades, affordable or not, in store order. + """ + return self._read_again_if_stale(self._read_upgrades) + + def _read_upgrades(self) -> list[Upgrade]: + """@brief Find the visible products and read each one.""" + products = self._driver.find_elements( + By.CSS_SELECTOR, constants.VISIBLE_PRODUCT_SELECTOR + ) + production = self._read_production() + return [self._read_upgrade(product, production) for product in products] + + def _read_production(self) -> list[float]: + """@brief Read the cookies per second of one unit of every product. + + @return The numbers by product index; empty when the page does not tell. + """ + try: + production = self._driver.execute_script(constants.JS_PRODUCTION_SCRIPT) + except JavascriptException: + return [] + return production if isinstance(production, list) else [] + + def _read_again_if_stale(self, read: Callable[[], T]) -> T: + """@brief Run a read; repeat it while the page re-renders under it. + + @throws StaleElementReferenceException When every attempt went stale. + """ + for _ in range(constants.STALE_READ_ATTEMPTS - 1): + try: + return read() + except StaleElementReferenceException: + self._sleep(constants.POLL_INTERVAL_SECONDS) + return read() + + def buy(self, upgrade: Upgrade) -> bool: + """@brief Buy an upgrade by clicking its product. + + @param upgrade The upgrade to buy. + @return True only when a click really reached the product. + """ + try: + element = self._driver.find_element(By.ID, upgrade.key) + try: + element.click() + except ElementClickInterceptedException: + # The cookie-consent banner covers the lower products. Click from + # inside the page instead; the banner itself is never clicked. + self._driver.execute_script(constants.JS_CLICK_SCRIPT, element) + except StaleElementReferenceException: + # The pane re-rendered under us; the next check retries. + return False + return True + + def save_screenshot(self, path: Path) -> None: + """@brief Write a screenshot of the game to a PNG file. + + @param path Target file; its folder is created when missing. + """ + path.parent.mkdir(parents=True, exist_ok=True) + self._driver.save_screenshot(str(path)) + + def close(self) -> None: + """@brief Close the browser.""" + self._driver.quit() + + @staticmethod + def _read_upgrade(product: ElementLike, production: Sequence[float]) -> Upgrade: + """@brief Read the id, name, price and production 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), + cookies_per_second=SeleniumGame._production_of(key, production), + ) + + @staticmethod + def _production_of(key: str, production: Sequence[float]) -> float | None: + """@brief Look up the production of the product with this element id.""" + index = key.removeprefix(constants.PRODUCT_ID_PREFIX) + if not index.isdigit() or int(index) >= len(production): + return None + value = production[int(index)] + return float(value) if isinstance(value, int | float) else None + + def _visible(self, element_id: str) -> ElementLike | None: + """@brief Return the element with this id when it exists and is displayed.""" + try: + element = self._driver.find_element(By.ID, element_id) + return element if element.is_displayed() else None + except (NoSuchElementException, StaleElementReferenceException): + # Not there yet, or the page re-rendered under us: poll again. + return None + + def _wait_for(self, locate: Callable[[], T | None]) -> T: + """@brief Poll until `locate` returns a value. + + @param locate Function that returns the value, or None while not ready. + @return The value. + @throws TimeoutError When the value is not there within the timeout. + """ + deadline = self._clock() + self._timeout + while True: + found = locate() + if found is not None: + return found + if self._clock() >= deadline: + message = f"the game page was not ready within {self._timeout} s" + raise TimeoutError(message) + self._sleep(constants.POLL_INTERVAL_SECONDS) diff --git a/tests/integration/test_selenium_game.py b/tests/integration/test_selenium_game.py index 0b672b4..fa978a9 100644 --- a/tests/integration/test_selenium_game.py +++ b/tests/integration/test_selenium_game.py @@ -1,429 +1,490 @@ -"""@file test_selenium_game.py -@brief Tests of the Selenium adapter against a fake driver (no browser). -""" - -from collections.abc import Callable -from pathlib import Path -from typing import Any - -import pytest -from selenium.common.exceptions import ( - ElementClickInterceptedException, - NoSuchElementException, - StaleElementReferenceException, -) - -from cookie_clicker_bot import constants -from cookie_clicker_bot.domain.upgrades import Upgrade -from cookie_clicker_bot.infrastructure.selenium_game import SeleniumGame - - -class FakeElement: - """An element that records clicks and may raise on click.""" - - def __init__( - self, - displayed: Callable[[], bool] = lambda: True, - click_error: Exception | None = None, - text: str = "", - attributes: dict[str, str] | None = None, - children: dict[str, "FakeElement"] | None = None, - ) -> None: - self.text = text - self.clicks = 0 - self._displayed = displayed - self._click_error = click_error - self._attributes = attributes or {} - self._children = children or {} - - def get_attribute(self, name: str) -> str | None: - return self._attributes.get(name) - - def find_element(self, by: str, value: str) -> "FakeElement": - if by != "css selector" or value not in self._children: - raise NoSuchElementException(value) - return self._children[value] - - def click(self) -> None: - if self._click_error is not None: - raise self._click_error - self.clicks += 1 - - def is_displayed(self) -> bool: - return self._displayed() - - -class FakeDriver: - """A driver with elements by id.""" - - def __init__(self, elements: dict[str, FakeElement] | None = None) -> None: - self.elements = elements or {} - self.groups: dict[str, list[FakeElement]] = {} - self.loaded: list[str] = [] - self.screenshots: list[str] = [] - self.quit_called = False - self.scripts: list[tuple[str, FakeElement]] = [] - - def execute_script(self, script: str, *args: Any) -> None: # noqa: ANN401 - self.scripts.append((script, args[0])) - - def get(self, url: str) -> None: - self.loaded.append(url) - - def find_element(self, by: str, value: str) -> FakeElement: - if by != "id" or value not in self.elements: - raise NoSuchElementException(value) - return self.elements[value] - - def find_elements(self, by: str, value: str) -> list[FakeElement]: - if by != "css selector": - return [] - return self.groups.get(value, []) - - def save_screenshot(self, filename: str) -> bool: - self.screenshots.append(filename) - return True - - def quit(self) -> None: - self.quit_called = True - - -class FakeTime: - """A clock that only moves when the fake sleep is called.""" - - def __init__(self) -> None: - self.now = 0.0 - self.sleeps: list[float] = [] - - def clock(self) -> float: - return self.now - - def sleep(self, seconds: float) -> None: - self.sleeps.append(seconds) - self.now += seconds - - -def make_game(driver: FakeDriver, fake_time: FakeTime | None = None) -> SeleniumGame: - fake_time = fake_time or FakeTime() - return SeleniumGame( - driver, timeout_seconds=1.0, clock=fake_time.clock, sleep=fake_time.sleep - ) - - -def ready_driver() -> FakeDriver: - return FakeDriver( - { - constants.LANGUAGE_ENGLISH_ID: FakeElement(), - constants.BIG_COOKIE_ID: FakeElement(), - } - ) - - -def test_open_game_loads_the_game_address() -> None: - driver = FakeDriver() - - make_game(driver).open_game() - - assert driver.loaded == [constants.GAME_URL] - - -def test_dismiss_language_clicks_english_and_waits_for_the_cookie() -> None: - driver = ready_driver() - - make_game(driver).dismiss_language() - - assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1 - - -def test_dismiss_language_polls_until_the_button_is_visible() -> None: - fake_time = FakeTime() - driver = ready_driver() - driver.elements[constants.LANGUAGE_ENGLISH_ID] = FakeElement( - displayed=lambda: fake_time.now >= 0.4 - ) - - make_game(driver, fake_time).dismiss_language() - - assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1 - assert fake_time.sleeps == [constants.POLL_INTERVAL_SECONDS] * 2 - - -def stale_once() -> Callable[[], bool]: - """Return an `is_displayed` that goes stale on the first call only.""" - calls: list[int] = [] - - def displayed() -> bool: - calls.append(1) - if len(calls) == 1: - raise StaleElementReferenceException - return True - - return displayed - - -def test_dismiss_language_survives_a_cookie_that_goes_stale_once() -> None: - driver = ready_driver() - driver.elements[constants.BIG_COOKIE_ID] = FakeElement(displayed=stale_once()) - - make_game(driver).dismiss_language() - - assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1 - - -def test_dismiss_language_survives_a_button_that_goes_stale_once() -> None: - driver = ready_driver() - driver.elements[constants.LANGUAGE_ENGLISH_ID] = FakeElement(displayed=stale_once()) - - make_game(driver).dismiss_language() - - assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1 - - -def test_dismiss_language_survives_a_lookup_that_goes_stale_once() -> None: - driver = ready_driver() - real_find = driver.find_element - calls: list[int] = [] - - def find_element(by: str, value: str) -> FakeElement: - calls.append(1) - if len(calls) == 1: - raise StaleElementReferenceException - return real_find(by, value) - - driver.find_element = find_element # type: ignore[method-assign] - - make_game(driver).dismiss_language() - - assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1 - - -def test_dismiss_language_times_out_when_the_button_never_shows() -> None: - fake_time = FakeTime() - - with pytest.raises(TimeoutError): - make_game(FakeDriver(), fake_time).dismiss_language() - - assert fake_time.now >= 1.0 - - -def test_dismiss_language_times_out_when_the_cookie_never_shows() -> None: - driver = FakeDriver({constants.LANGUAGE_ENGLISH_ID: FakeElement()}) - - with pytest.raises(TimeoutError): - make_game(driver).dismiss_language() - - -def test_click_cookie_clicks_the_big_cookie() -> None: - driver = ready_driver() - - make_game(driver).click_cookie() - - assert driver.elements[constants.BIG_COOKIE_ID].clicks == 1 - - -@pytest.mark.parametrize( - "error", - [ElementClickInterceptedException(), StaleElementReferenceException()], -) -def test_click_cookie_ignores_a_covered_or_stale_cookie(error: Exception) -> None: - driver = FakeDriver({constants.BIG_COOKIE_ID: FakeElement(click_error=error)}) - - make_game(driver).click_cookie() - - -def make_product(key: str, name: str, price: str) -> FakeElement: - return FakeElement( - attributes={"id": key}, - children={ - constants.PRODUCT_NAME_SELECTOR: FakeElement(text=name), - constants.PRODUCT_PRICE_SELECTOR: FakeElement(text=price), - }, - ) - - -def test_cookie_count_reads_the_cookie_counter() -> None: - driver = FakeDriver( - { - constants.COOKIE_COUNT_ID: FakeElement( - text="1.235\nmillion cookies\nper second: 0" - ) - } - ) - - assert make_game(driver).cookie_count() == 1_235_000 - - -def test_cookies_per_second_reads_its_element() -> None: - driver = FakeDriver( - {constants.COOKIES_PER_SECOND_ID: FakeElement(text="per second: 1,234.5")} - ) - - assert make_game(driver).cookies_per_second() == 1234.5 - - -def test_upgrades_reads_the_visible_products() -> None: - driver = FakeDriver() - driver.groups[constants.VISIBLE_PRODUCT_SELECTOR] = [ - make_product("product0", "Cursor", "15"), - make_product("product2", "Farm", "1,100"), - ] - - assert make_game(driver).upgrades() == [ - Upgrade("product0", "Cursor", 15), - Upgrade("product2", "Farm", 1100), - ] - - -class StaleOnceText(FakeElement): - """An element whose text goes stale on the first read.""" - - def __init__(self, text: str) -> None: - super().__init__() - self._text = text - self._reads = 0 - - @property - def text(self) -> str: - self._reads += 1 - if self._reads == 1: - raise StaleElementReferenceException - return self._text - - @text.setter - def text(self, value: str) -> None: - self._text = value - - -def test_cookie_count_retries_once_when_the_counter_goes_stale() -> None: - driver = FakeDriver({constants.COOKIE_COUNT_ID: StaleOnceText("42 cookies")}) - - assert make_game(driver).cookie_count() == 42 - - -def test_cookies_per_second_retries_once_when_the_element_goes_stale() -> None: - driver = FakeDriver( - {constants.COOKIES_PER_SECOND_ID: StaleOnceText("per second: 3.5")} - ) - - assert make_game(driver).cookies_per_second() == 3.5 - - -def test_upgrades_retries_once_when_a_product_goes_stale() -> None: - product = make_product("product0", "Cursor", "15") - product.get_attribute = _stale_once_then(product.get_attribute) # type: ignore[assignment] - driver = FakeDriver() - driver.groups[constants.VISIBLE_PRODUCT_SELECTOR] = [product] - - assert make_game(driver).upgrades() == [Upgrade("product0", "Cursor", 15)] - - -def test_cookies_per_second_succeeds_after_four_stale_attempts() -> None: - driver = FakeDriver() - attempts = _stale_for(4, FakeElement(text="per second: 3.5")) - driver.find_element = attempts.find_element # type: ignore[method-assign] - fake_time = FakeTime() - - assert make_game(driver, fake_time).cookies_per_second() == 3.5 - assert attempts.calls == 5 - assert fake_time.sleeps == [constants.POLL_INTERVAL_SECONDS] * 4 - - -def test_cookie_count_gives_up_when_the_counter_stays_stale() -> None: - driver = FakeDriver() - attempts = _stale_for(100, FakeElement(text="42 cookies")) - driver.find_element = attempts.find_element # type: ignore[method-assign] - fake_time = FakeTime() - - with pytest.raises(StaleElementReferenceException): - make_game(driver, fake_time).cookie_count() - - assert attempts.calls == constants.STALE_READ_ATTEMPTS - assert fake_time.sleeps == [constants.POLL_INTERVAL_SECONDS] * ( - constants.STALE_READ_ATTEMPTS - 1 - ) - - -class _StaleFor: - """A `find_element` that goes stale for the first `stale` calls.""" - - def __init__(self, stale: int, element: FakeElement) -> None: - self._stale = stale - self._element = element - self.calls = 0 - - def find_element(self, by: str, value: str) -> FakeElement: - self.calls += 1 - if self.calls <= self._stale: - raise StaleElementReferenceException - return self._element - - -def _stale_for(stale: int, element: FakeElement) -> _StaleFor: - return _StaleFor(stale, element) - - -def _stale_once_then( - original: Callable[[str], str | None], -) -> Callable[[str], str | None]: - calls: list[int] = [] - - def get_attribute(name: str) -> str | None: - calls.append(1) - if len(calls) == 1: - raise StaleElementReferenceException - return original(name) - - return get_attribute - - -def test_upgrades_is_empty_when_no_product_is_visible() -> None: - assert make_game(FakeDriver()).upgrades() == [] - - -def test_buy_clicks_the_product_with_the_upgrade_key() -> None: - driver = FakeDriver({"product1": FakeElement()}) - - bought = make_game(driver).buy(Upgrade("product1", "Grandma", 100)) - - assert bought is True - assert driver.elements["product1"].clicks == 1 - assert driver.scripts == [] - - -def test_buy_clicks_through_javascript_when_a_banner_covers_the_product() -> None: - element = FakeElement(click_error=ElementClickInterceptedException()) - driver = FakeDriver({"product1": element}) - - bought = make_game(driver).buy(Upgrade("product1", "Grandma", 100)) - - assert bought is True - assert driver.scripts == [(constants.JS_CLICK_SCRIPT, element)] - - -def test_buy_returns_false_for_a_stale_product() -> None: - driver = FakeDriver( - {"product1": FakeElement(click_error=StaleElementReferenceException())} - ) - - assert make_game(driver).buy(Upgrade("product1", "Grandma", 100)) is False - assert driver.scripts == [] - - -def test_save_screenshot_creates_the_folder_and_calls_the_driver( - tmp_path: Path, -) -> None: - driver = FakeDriver() - target = tmp_path / "images" / "shot.png" - - make_game(driver).save_screenshot(target) - - assert target.parent.is_dir() - assert driver.screenshots == [str(target)] - - -def test_close_quits_the_driver() -> None: - driver = FakeDriver() - - make_game(driver).close() - - assert driver.quit_called +"""@file test_selenium_game.py +@brief Tests of the Selenium adapter against a fake driver (no browser). +""" + +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import pytest +from selenium.common.exceptions import ( + ElementClickInterceptedException, + JavascriptException, + NoSuchElementException, + StaleElementReferenceException, +) + +from cookie_clicker_bot import constants +from cookie_clicker_bot.domain.upgrades import Upgrade +from cookie_clicker_bot.infrastructure.selenium_game import SeleniumGame + + +class FakeElement: + """An element that records clicks and may raise on click.""" + + def __init__( + self, + displayed: Callable[[], bool] = lambda: True, + click_error: Exception | None = None, + text: str = "", + attributes: dict[str, str] | None = None, + children: dict[str, "FakeElement"] | None = None, + ) -> None: + self.text = text + self.clicks = 0 + self._displayed = displayed + self._click_error = click_error + self._attributes = attributes or {} + self._children = children or {} + + def get_attribute(self, name: str) -> str | None: + return self._attributes.get(name) + + def find_element(self, by: str, value: str) -> "FakeElement": + if by != "css selector" or value not in self._children: + raise NoSuchElementException(value) + return self._children[value] + + def click(self) -> None: + if self._click_error is not None: + raise self._click_error + self.clicks += 1 + + def is_displayed(self) -> bool: + return self._displayed() + + +class FakeDriver: + """A driver with elements by id.""" + + def __init__(self, elements: dict[str, FakeElement] | None = None) -> None: + self.elements = elements or {} + self.groups: dict[str, list[FakeElement]] = {} + self.loaded: list[str] = [] + self.screenshots: list[str] = [] + self.quit_called = False + self.scripts: list[tuple[str, FakeElement]] = [] + self.production: Any = [] + + def execute_script(self, script: str, *args: Any) -> Any: # noqa: ANN401 + if script == constants.JS_PRODUCTION_SCRIPT: + if isinstance(self.production, Exception): + raise self.production + return self.production + self.scripts.append((script, args[0])) + + def get(self, url: str) -> None: + self.loaded.append(url) + + def find_element(self, by: str, value: str) -> FakeElement: + if by != "id" or value not in self.elements: + raise NoSuchElementException(value) + return self.elements[value] + + def find_elements(self, by: str, value: str) -> list[FakeElement]: + if by != "css selector": + return [] + return self.groups.get(value, []) + + def save_screenshot(self, filename: str) -> bool: + self.screenshots.append(filename) + return True + + def quit(self) -> None: + self.quit_called = True + + +class FakeTime: + """A clock that only moves when the fake sleep is called.""" + + def __init__(self) -> None: + self.now = 0.0 + self.sleeps: list[float] = [] + + def clock(self) -> float: + return self.now + + def sleep(self, seconds: float) -> None: + self.sleeps.append(seconds) + self.now += seconds + + +def make_game(driver: FakeDriver, fake_time: FakeTime | None = None) -> SeleniumGame: + fake_time = fake_time or FakeTime() + return SeleniumGame( + driver, timeout_seconds=1.0, clock=fake_time.clock, sleep=fake_time.sleep + ) + + +def ready_driver() -> FakeDriver: + return FakeDriver( + { + constants.LANGUAGE_ENGLISH_ID: FakeElement(), + constants.BIG_COOKIE_ID: FakeElement(), + } + ) + + +def test_open_game_loads_the_game_address() -> None: + driver = FakeDriver() + + make_game(driver).open_game() + + assert driver.loaded == [constants.GAME_URL] + + +def test_dismiss_language_clicks_english_and_waits_for_the_cookie() -> None: + driver = ready_driver() + + make_game(driver).dismiss_language() + + assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1 + + +def test_dismiss_language_polls_until_the_button_is_visible() -> None: + fake_time = FakeTime() + driver = ready_driver() + driver.elements[constants.LANGUAGE_ENGLISH_ID] = FakeElement( + displayed=lambda: fake_time.now >= 0.4 + ) + + make_game(driver, fake_time).dismiss_language() + + assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1 + assert fake_time.sleeps == [constants.POLL_INTERVAL_SECONDS] * 2 + + +def stale_once() -> Callable[[], bool]: + """Return an `is_displayed` that goes stale on the first call only.""" + calls: list[int] = [] + + def displayed() -> bool: + calls.append(1) + if len(calls) == 1: + raise StaleElementReferenceException + return True + + return displayed + + +def test_dismiss_language_survives_a_cookie_that_goes_stale_once() -> None: + driver = ready_driver() + driver.elements[constants.BIG_COOKIE_ID] = FakeElement(displayed=stale_once()) + + make_game(driver).dismiss_language() + + assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1 + + +def test_dismiss_language_survives_a_button_that_goes_stale_once() -> None: + driver = ready_driver() + driver.elements[constants.LANGUAGE_ENGLISH_ID] = FakeElement(displayed=stale_once()) + + make_game(driver).dismiss_language() + + assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1 + + +def test_dismiss_language_survives_a_lookup_that_goes_stale_once() -> None: + driver = ready_driver() + real_find = driver.find_element + calls: list[int] = [] + + def find_element(by: str, value: str) -> FakeElement: + calls.append(1) + if len(calls) == 1: + raise StaleElementReferenceException + return real_find(by, value) + + driver.find_element = find_element # type: ignore[method-assign] + + make_game(driver).dismiss_language() + + assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1 + + +def test_dismiss_language_times_out_when_the_button_never_shows() -> None: + fake_time = FakeTime() + + with pytest.raises(TimeoutError): + make_game(FakeDriver(), fake_time).dismiss_language() + + assert fake_time.now >= 1.0 + + +def test_dismiss_language_times_out_when_the_cookie_never_shows() -> None: + driver = FakeDriver({constants.LANGUAGE_ENGLISH_ID: FakeElement()}) + + with pytest.raises(TimeoutError): + make_game(driver).dismiss_language() + + +def test_click_cookie_clicks_the_big_cookie() -> None: + driver = ready_driver() + + make_game(driver).click_cookie() + + assert driver.elements[constants.BIG_COOKIE_ID].clicks == 1 + + +@pytest.mark.parametrize( + "error", + [ElementClickInterceptedException(), StaleElementReferenceException()], +) +def test_click_cookie_ignores_a_covered_or_stale_cookie(error: Exception) -> None: + driver = FakeDriver({constants.BIG_COOKIE_ID: FakeElement(click_error=error)}) + + make_game(driver).click_cookie() + + +def make_product(key: str, name: str, price: str) -> FakeElement: + return FakeElement( + attributes={"id": key}, + children={ + constants.PRODUCT_NAME_SELECTOR: FakeElement(text=name), + constants.PRODUCT_PRICE_SELECTOR: FakeElement(text=price), + }, + ) + + +def test_cookie_count_reads_the_cookie_counter() -> None: + driver = FakeDriver( + { + constants.COOKIE_COUNT_ID: FakeElement( + text="1.235\nmillion cookies\nper second: 0" + ) + } + ) + + assert make_game(driver).cookie_count() == 1_235_000 + + +def test_cookies_per_second_reads_its_element() -> None: + driver = FakeDriver( + {constants.COOKIES_PER_SECOND_ID: FakeElement(text="per second: 1,234.5")} + ) + + assert make_game(driver).cookies_per_second() == 1234.5 + + +def test_upgrades_reads_the_visible_products() -> None: + driver = FakeDriver() + driver.groups[constants.VISIBLE_PRODUCT_SELECTOR] = [ + make_product("product0", "Cursor", "15"), + make_product("product2", "Farm", "1,100"), + ] + + assert make_game(driver).upgrades() == [ + Upgrade("product0", "Cursor", 15), + Upgrade("product2", "Farm", 1100), + ] + + +class StaleOnceText(FakeElement): + """An element whose text goes stale on the first read.""" + + def __init__(self, text: str) -> None: + super().__init__() + self._text = text + self._reads = 0 + + @property + def text(self) -> str: + self._reads += 1 + if self._reads == 1: + raise StaleElementReferenceException + return self._text + + @text.setter + def text(self, value: str) -> None: + self._text = value + + +def test_cookie_count_retries_once_when_the_counter_goes_stale() -> None: + driver = FakeDriver({constants.COOKIE_COUNT_ID: StaleOnceText("42 cookies")}) + + assert make_game(driver).cookie_count() == 42 + + +def test_cookies_per_second_retries_once_when_the_element_goes_stale() -> None: + driver = FakeDriver( + {constants.COOKIES_PER_SECOND_ID: StaleOnceText("per second: 3.5")} + ) + + assert make_game(driver).cookies_per_second() == 3.5 + + +def test_upgrades_retries_once_when_a_product_goes_stale() -> None: + product = make_product("product0", "Cursor", "15") + product.get_attribute = _stale_once_then(product.get_attribute) # type: ignore[assignment] + driver = FakeDriver() + driver.groups[constants.VISIBLE_PRODUCT_SELECTOR] = [product] + + assert make_game(driver).upgrades() == [Upgrade("product0", "Cursor", 15)] + + +def test_cookies_per_second_succeeds_after_four_stale_attempts() -> None: + driver = FakeDriver() + attempts = _stale_for(4, FakeElement(text="per second: 3.5")) + driver.find_element = attempts.find_element # type: ignore[method-assign] + fake_time = FakeTime() + + assert make_game(driver, fake_time).cookies_per_second() == 3.5 + assert attempts.calls == 5 + assert fake_time.sleeps == [constants.POLL_INTERVAL_SECONDS] * 4 + + +def test_cookie_count_gives_up_when_the_counter_stays_stale() -> None: + driver = FakeDriver() + attempts = _stale_for(100, FakeElement(text="42 cookies")) + driver.find_element = attempts.find_element # type: ignore[method-assign] + fake_time = FakeTime() + + with pytest.raises(StaleElementReferenceException): + make_game(driver, fake_time).cookie_count() + + assert attempts.calls == constants.STALE_READ_ATTEMPTS + assert fake_time.sleeps == [constants.POLL_INTERVAL_SECONDS] * ( + constants.STALE_READ_ATTEMPTS - 1 + ) + + +class _StaleFor: + """A `find_element` that goes stale for the first `stale` calls.""" + + def __init__(self, stale: int, element: FakeElement) -> None: + self._stale = stale + self._element = element + self.calls = 0 + + def find_element(self, by: str, value: str) -> FakeElement: + self.calls += 1 + if self.calls <= self._stale: + raise StaleElementReferenceException + return self._element + + +def _stale_for(stale: int, element: FakeElement) -> _StaleFor: + return _StaleFor(stale, element) + + +def _stale_once_then( + original: Callable[[str], str | None], +) -> Callable[[str], str | None]: + calls: list[int] = [] + + def get_attribute(name: str) -> str | None: + calls.append(1) + if len(calls) == 1: + raise StaleElementReferenceException + return original(name) + + return get_attribute + + +def test_upgrades_is_empty_when_no_product_is_visible() -> None: + assert make_game(FakeDriver()).upgrades() == [] + + +def test_buy_clicks_the_product_with_the_upgrade_key() -> None: + driver = FakeDriver({"product1": FakeElement()}) + + bought = make_game(driver).buy(Upgrade("product1", "Grandma", 100)) + + assert bought is True + assert driver.elements["product1"].clicks == 1 + assert driver.scripts == [] + + +def test_buy_clicks_through_javascript_when_a_banner_covers_the_product() -> None: + element = FakeElement(click_error=ElementClickInterceptedException()) + driver = FakeDriver({"product1": element}) + + bought = make_game(driver).buy(Upgrade("product1", "Grandma", 100)) + + assert bought is True + assert driver.scripts == [(constants.JS_CLICK_SCRIPT, element)] + + +def test_buy_returns_false_for_a_stale_product() -> None: + driver = FakeDriver( + {"product1": FakeElement(click_error=StaleElementReferenceException())} + ) + + assert make_game(driver).buy(Upgrade("product1", "Grandma", 100)) is False + assert driver.scripts == [] + + +def test_save_screenshot_creates_the_folder_and_calls_the_driver( + tmp_path: Path, +) -> None: + driver = FakeDriver() + target = tmp_path / "images" / "shot.png" + + make_game(driver).save_screenshot(target) + + assert target.parent.is_dir() + assert driver.screenshots == [str(target)] + + +def test_close_quits_the_driver() -> None: + driver = FakeDriver() + + make_game(driver).close() + + assert driver.quit_called + + +def _driver_with_two_products() -> FakeDriver: + driver = FakeDriver() + driver.groups[constants.VISIBLE_PRODUCT_SELECTOR] = [ + make_product("product0", "Cursor", "15"), + make_product("product1", "Grandma", "100"), + ] + return driver + + +def test_upgrades_reads_the_production_of_each_product() -> None: + driver = _driver_with_two_products() + driver.production = [0.1, 1] + + assert make_game(driver).upgrades() == [ + Upgrade("product0", "Cursor", 15, 0.1), + Upgrade("product1", "Grandma", 100, 1.0), + ] + + +def test_upgrades_leaves_the_production_unknown_when_the_script_fails() -> None: + driver = _driver_with_two_products() + driver.production = JavascriptException("Game is not defined") + + upgrades = make_game(driver).upgrades() + + assert [upgrade.cookies_per_second for upgrade in upgrades] == [None, None] + + +def test_upgrades_leaves_the_production_unknown_for_a_product_the_list_lacks() -> None: + driver = _driver_with_two_products() + driver.production = [0.1] + + upgrades = make_game(driver).upgrades() + + assert [upgrade.cookies_per_second for upgrade in upgrades] == [0.1, None] + + +def test_upgrades_leaves_a_production_that_is_not_a_number_unknown() -> None: + driver = _driver_with_two_products() + driver.production = [{}, 1] + + upgrades = make_game(driver).upgrades() + + assert [upgrade.cookies_per_second for upgrade in upgrades] == [None, 1.0] + + +def test_upgrades_leaves_the_production_unknown_for_an_unexpected_answer() -> None: + driver = _driver_with_two_products() + driver.production = None + + upgrades = make_game(driver).upgrades() + + assert [upgrade.cookies_per_second for upgrade in upgrades] == [None, None] diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 9a3bfc4..dd946d1 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -1,216 +1,241 @@ -"""@file test_cli.py -@brief Tests of the command line and the run sequence with a fake game. -""" - -from pathlib import Path - -import pytest - -from cookie_clicker_bot import constants -from cookie_clicker_bot.application.ports import GamePort -from cookie_clicker_bot.cli import main, parse_args -from cookie_clicker_bot.domain.upgrades import Upgrade - - -class RecordingGame: - """A game that records every call in order.""" - - def __init__(self, fail_on_open: bool = False, buy_succeeds: bool = True) -> None: - self._buy_succeeds = buy_succeeds - self.calls: list[str] = [] - self.screenshots: list[Path] = [] - self._fail_on_open = fail_on_open - - def open_game(self) -> None: - self.calls.append("open_game") - if self._fail_on_open: - message = "boom" - raise RuntimeError(message) - - def dismiss_language(self) -> None: - self.calls.append("dismiss_language") - - def click_cookie(self) -> None: - self.calls.append("click_cookie") - - def cookie_count(self) -> int: - return 1000 - - def cookies_per_second(self) -> float: - return 8.5 - - def upgrades(self) -> list[Upgrade]: - return [Upgrade("product0", "Cursor", 15)] - - def buy(self, upgrade: Upgrade) -> bool: - self.calls.append("buy") - return self._buy_succeeds - - def save_screenshot(self, path: Path) -> None: - self.calls.append("save_screenshot") - self.screenshots.append(path) - - def close(self) -> None: - self.calls.append("close") - - -def test_parse_args_uses_defaults() -> None: - args = parse_args([]) - - assert args.duration == constants.RUN_DURATION_SECONDS == 300 - assert args.interval == constants.UPGRADE_CHECK_INTERVAL_SECONDS == 5 - assert args.headless is False - assert args.screenshot_start is None - assert args.screenshot is None - - -def test_parse_args_reads_every_option() -> None: - args = parse_args( - [ - "--headless", - "--duration", - "10", - "--interval", - "2.5", - "--screenshot-start", - "a.png", - "--screenshot", - "b.png", - ] - ) - - assert args.headless is True - assert args.duration == 10 - assert args.interval == 2.5 - assert args.screenshot_start == Path("a.png") - assert args.screenshot == Path("b.png") - - -def test_main_opens_dismisses_and_closes() -> None: - game = RecordingGame() - - code = main(["--duration", "0"], lambda _headless: game) - - assert code == 0 - assert game.calls == ["open_game", "dismiss_language", "close"] - - -def test_main_passes_headless_to_the_factory() -> None: - seen: list[bool] = [] - - def factory(headless: bool) -> GamePort: - seen.append(headless) - return RecordingGame() - - main(["--headless", "--duration", "0"], factory) - - assert seen == [True] - - -def test_main_takes_both_screenshots_when_asked() -> None: - game = RecordingGame() - - main( - ["--duration", "0", "--screenshot-start", "s.png", "--screenshot", "e.png"], - lambda _headless: game, - ) - - assert game.screenshots == [Path("s.png"), Path("e.png")] - assert game.calls[-1] == "close" - - -def test_main_takes_no_screenshot_by_default() -> None: - game = RecordingGame() - - main(["--duration", "0"], lambda _headless: game) - - assert game.screenshots == [] - - -def test_main_closes_the_game_when_an_error_is_raised() -> None: - game = RecordingGame(fail_on_open=True) - - with pytest.raises(RuntimeError, match="boom"): - main(["--duration", "0"], lambda _headless: game) - - assert game.calls == ["open_game", "close"] - - -def test_main_prints_exactly_one_result_line_on_stdout( - capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture -) -> None: - caplog.set_level("INFO") - - main(["--duration", "0"], lambda _headless: RecordingGame()) - - out = capsys.readouterr().out - assert out == "cookies/second: 8.5" + chr(10) - assert "clicked the cookie" not in out - assert "clicked the cookie" in caplog.text - - -def test_main_logs_bought_only_for_real_purchases( - caplog: pytest.LogCaptureFixture, -) -> None: - caplog.set_level("INFO") - - main(["--duration", "0.05", "--interval", "0.001"], lambda _h: RecordingGame()) - assert "bought Cursor" in caplog.text - - caplog.clear() - game = RecordingGame(buy_succeeds=False) - main(["--duration", "0.05", "--interval", "0.001"], lambda _h: game) - assert "buy" in game.calls - assert "bought Cursor" not in caplog.text - assert "bought 0 upgrades" in caplog.text - - -def test_main_prints_nothing_on_stdout_when_the_run_fails( - capsys: pytest.CaptureFixture[str], -) -> None: - with pytest.raises(RuntimeError, match="boom"): - main(["--duration", "0"], lambda _headless: RecordingGame(fail_on_open=True)) - - assert capsys.readouterr().out == "" - - -def test_parse_args_buys_one_upgrade_unless_buy_all_is_given() -> None: - assert parse_args([]).buy_all is False - assert parse_args(["--buy-all"]).buy_all is True - - -@pytest.mark.parametrize("value", ["0", "-1", "abc", "nan"]) -def test_parse_args_rejects_an_interval_that_is_not_positive( - value: str, capsys: pytest.CaptureFixture[str] -) -> None: - with pytest.raises(SystemExit) as exit_info: - parse_args(["--interval", value]) - - assert exit_info.value.code == 2 - assert "greater than 0" in capsys.readouterr().err - - -def test_main_logs_the_interval_and_the_strategy( - caplog: pytest.LogCaptureFixture, -) -> None: - caplog.set_level("INFO") - - main(["--duration", "0"], lambda _h: RecordingGame()) - assert "every 5 seconds, strategy buy-one" in caplog.text - - caplog.clear() - main( - ["--duration", "0", "--interval", "2", "--buy-all"], lambda _h: RecordingGame() - ) - assert "every 2.0 seconds, strategy buy-all" in caplog.text - - -def test_main_buy_all_buys_every_affordable_upgrade_per_check( - caplog: pytest.LogCaptureFixture, -) -> None: - caplog.set_level("INFO") - game = RecordingGame() - - main(["--duration", "0.05", "--interval", "0.001", "--buy-all"], lambda _h: game) - - assert "bought Cursor" in caplog.text +"""@file test_cli.py +@brief Tests of the command line and the run sequence with a fake game. +""" + +from pathlib import Path + +import pytest + +from cookie_clicker_bot import constants +from cookie_clicker_bot.application.ports import GamePort +from cookie_clicker_bot.cli import main, parse_args +from cookie_clicker_bot.domain.upgrades import Upgrade + + +class RecordingGame: + """A game that records every call in order.""" + + def __init__(self, fail_on_open: bool = False, buy_succeeds: bool = True) -> None: + self._buy_succeeds = buy_succeeds + self.calls: list[str] = [] + self.screenshots: list[Path] = [] + self._fail_on_open = fail_on_open + + def open_game(self) -> None: + self.calls.append("open_game") + if self._fail_on_open: + message = "boom" + raise RuntimeError(message) + + def dismiss_language(self) -> None: + self.calls.append("dismiss_language") + + def click_cookie(self) -> None: + self.calls.append("click_cookie") + + def cookie_count(self) -> int: + return 1000 + + def cookies_per_second(self) -> float: + return 8.5 + + def upgrades(self) -> list[Upgrade]: + return [Upgrade("product0", "Cursor", 15)] + + def buy(self, upgrade: Upgrade) -> bool: + self.calls.append("buy") + return self._buy_succeeds + + def save_screenshot(self, path: Path) -> None: + self.calls.append("save_screenshot") + self.screenshots.append(path) + + def close(self) -> None: + self.calls.append("close") + + +def test_parse_args_uses_defaults() -> None: + args = parse_args([]) + + assert args.duration == constants.RUN_DURATION_SECONDS == 300 + assert args.interval == constants.UPGRADE_CHECK_INTERVAL_SECONDS == 5 + assert args.headless is False + assert args.screenshot_start is None + assert args.screenshot is None + + +def test_parse_args_reads_every_option() -> None: + args = parse_args( + [ + "--headless", + "--duration", + "10", + "--interval", + "2.5", + "--screenshot-start", + "a.png", + "--screenshot", + "b.png", + ] + ) + + assert args.headless is True + assert args.duration == 10 + assert args.interval == 2.5 + assert args.screenshot_start == Path("a.png") + assert args.screenshot == Path("b.png") + + +def test_main_opens_dismisses_and_closes() -> None: + game = RecordingGame() + + code = main(["--duration", "0"], lambda _headless: game) + + assert code == 0 + assert game.calls == ["open_game", "dismiss_language", "close"] + + +def test_main_passes_headless_to_the_factory() -> None: + seen: list[bool] = [] + + def factory(headless: bool) -> GamePort: + seen.append(headless) + return RecordingGame() + + main(["--headless", "--duration", "0"], factory) + + assert seen == [True] + + +def test_main_takes_both_screenshots_when_asked() -> None: + game = RecordingGame() + + main( + ["--duration", "0", "--screenshot-start", "s.png", "--screenshot", "e.png"], + lambda _headless: game, + ) + + assert game.screenshots == [Path("s.png"), Path("e.png")] + assert game.calls[-1] == "close" + + +def test_main_takes_no_screenshot_by_default() -> None: + game = RecordingGame() + + main(["--duration", "0"], lambda _headless: game) + + assert game.screenshots == [] + + +def test_main_closes_the_game_when_an_error_is_raised() -> None: + game = RecordingGame(fail_on_open=True) + + with pytest.raises(RuntimeError, match="boom"): + main(["--duration", "0"], lambda _headless: game) + + assert game.calls == ["open_game", "close"] + + +def test_main_prints_exactly_one_result_line_on_stdout( + capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level("INFO") + + main(["--duration", "0"], lambda _headless: RecordingGame()) + + out = capsys.readouterr().out + assert out == "cookies/second: 8.5" + chr(10) + assert "clicked the cookie" not in out + assert "clicked the cookie" in caplog.text + + +def test_main_logs_bought_only_for_real_purchases( + caplog: pytest.LogCaptureFixture, +) -> None: + caplog.set_level("INFO") + + main(["--duration", "0.05", "--interval", "0.001"], lambda _h: RecordingGame()) + assert "bought Cursor" in caplog.text + + caplog.clear() + game = RecordingGame(buy_succeeds=False) + main(["--duration", "0.05", "--interval", "0.001"], lambda _h: game) + assert "buy" in game.calls + assert "bought Cursor" not in caplog.text + assert "bought 0 upgrades" in caplog.text + + +def test_main_prints_nothing_on_stdout_when_the_run_fails( + capsys: pytest.CaptureFixture[str], +) -> None: + with pytest.raises(RuntimeError, match="boom"): + main(["--duration", "0"], lambda _headless: RecordingGame(fail_on_open=True)) + + assert capsys.readouterr().out == "" + + +def test_parse_args_buys_one_upgrade_unless_buy_all_is_given() -> None: + assert parse_args([]).buy_all is False + assert parse_args(["--buy-all"]).buy_all is True + + +def test_parse_args_best_value_is_off_by_default() -> None: + assert parse_args([]).best_value is False + assert parse_args(["--best-value"]).best_value is True + + +def test_parse_args_rejects_buy_all_together_with_best_value( + capsys: pytest.CaptureFixture[str], +) -> None: + with pytest.raises(SystemExit) as exit_info: + parse_args(["--buy-all", "--best-value"]) + + assert exit_info.value.code == 2 + assert "not allowed with" in capsys.readouterr().err + + +def test_main_logs_the_best_value_strategy( + caplog: pytest.LogCaptureFixture, +) -> None: + caplog.set_level("INFO") + + main(["--duration", "0", "--best-value"], lambda _h: RecordingGame()) + + assert "strategy best-value" in caplog.text + + +@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 diff --git a/tests/unit/test_game_loop.py b/tests/unit/test_game_loop.py index 3fd477e..d53b3e8 100644 --- a/tests/unit/test_game_loop.py +++ b/tests/unit/test_game_loop.py @@ -1,250 +1,282 @@ -"""@file test_game_loop.py -@brief Tests of the game loop with a fake game and a fake clock. -""" - -from pathlib import Path - -from cookie_clicker_bot.application.game_loop import ( - PlayResult, - buy_most_expensive_upgrade, - buy_upgrades, - click_and_buy, - click_cookie, -) -from cookie_clicker_bot.domain.buy_strategy import BuyStrategy -from cookie_clicker_bot.domain.upgrades import Upgrade - -CURSOR = Upgrade("product0", "Cursor", 15) -GRANDMA = Upgrade("product1", "Grandma", 100) - - -class FakeClock: - """A clock that only moves when the fake game is clicked.""" - - def __init__(self) -> None: - self.now = 0.0 - - def __call__(self) -> float: - return self.now - - -class FakeGame: - """A game with a cookie count and upgrades; each click takes one second.""" - - def __init__( - self, - cookies: int = 0, - upgrades: list[Upgrade] | None = None, - clock: FakeClock | None = None, - buy_succeeds: bool = True, - ) -> None: - self.buy_succeeds = buy_succeeds - self.clicks = 0 - self.cookies = cookies - self.available = upgrades or [] - self.bought: list[Upgrade] = [] - self.buy_times: list[float] = [] - self._clock = clock - - def open_game(self) -> None: - pass - - def dismiss_language(self) -> None: - pass - - def click_cookie(self) -> None: - self.clicks += 1 - if self._clock is not None: - self._clock.now += 1.0 - - def cookie_count(self) -> int: - return self.cookies - - def cookies_per_second(self) -> float: - return 0.0 - - def upgrades(self) -> list[Upgrade]: - return self.available - - def buy(self, upgrade: Upgrade) -> bool: - self.bought.append(upgrade) - if self._clock is not None: - self.buy_times.append(self._clock.now) - return self.buy_succeeds - - def save_screenshot(self, path: Path) -> None: - pass - - def close(self) -> None: - pass - - -def test_click_cookie_delegates_to_the_game() -> None: - game = FakeGame() - - click_cookie(game) - - assert game.clicks == 1 - - -def test_buy_most_expensive_upgrade_buys_the_best_affordable() -> None: - game = FakeGame(cookies=105, upgrades=[CURSOR, GRANDMA]) - - bought = buy_most_expensive_upgrade(game) - - assert bought == GRANDMA - assert game.bought == [GRANDMA] - - -def test_buy_most_expensive_upgrade_buys_nothing_when_too_poor() -> None: - game = FakeGame(cookies=10, upgrades=[CURSOR, GRANDMA]) - - assert buy_most_expensive_upgrade(game) is None - assert game.bought == [] - - -def test_click_and_buy_checks_the_upgrades_every_interval() -> None: - clock = FakeClock() - game = FakeGame(cookies=105, upgrades=[CURSOR, GRANDMA], clock=clock) - - result = click_and_buy(game, 12, 5, clock) - - assert result == PlayResult( - clicks=12, purchases=("Grandma", "Grandma"), elapsed_seconds=12.0 - ) - # The checks fall in the iterations that start at 5 s and 10 s; each click then - # moves the fake clock on by one second. - assert game.buy_times == [6.0, 11.0] - - -def test_click_and_buy_does_not_check_before_the_first_interval() -> None: - clock = FakeClock() - game = FakeGame(cookies=105, upgrades=[GRANDMA], clock=clock) - - result = click_and_buy(game, 4, 5, clock) - - assert result == PlayResult(clicks=4, purchases=(), elapsed_seconds=4.0) - assert game.bought == [] - - -def test_click_and_buy_records_only_real_purchases() -> None: - clock = FakeClock() - game = FakeGame(cookies=10, upgrades=[CURSOR, GRANDMA], clock=clock) - - result = click_and_buy(game, 11, 5, clock) - - assert result.clicks == 11 - assert result.purchases == () - - -def test_buy_most_expensive_upgrade_returns_none_when_the_click_fails() -> None: - game = FakeGame(cookies=105, upgrades=[CURSOR, GRANDMA], buy_succeeds=False) - - assert buy_most_expensive_upgrade(game) is None - assert game.bought == [GRANDMA] - - -def test_click_and_buy_does_not_record_a_purchase_when_the_click_fails() -> None: - clock = FakeClock() - game = FakeGame(cookies=105, upgrades=[GRANDMA], clock=clock, buy_succeeds=False) - - result = click_and_buy(game, 12, 5, clock) - - assert game.buy_times == [6.0, 11.0] - assert result.purchases == () - - -def test_click_and_buy_does_not_click_when_duration_is_zero() -> None: - clock = FakeClock() - game = FakeGame(clock=clock) - - assert click_and_buy(game, 0, 5, clock) == PlayResult( - clicks=0, purchases=(), elapsed_seconds=0.0 - ) - - -def test_click_and_buy_stops_at_the_deadline_and_reports_elapsed_time() -> None: - clock = FakeClock() - game = FakeGame(clock=clock) - - result = click_and_buy(game, 300, 5, clock) - - assert result.clicks == 300 - assert result.elapsed_seconds == 300.0 - - -def test_buy_upgrades_buy_one_buys_only_the_best_affordable() -> None: - game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA]) - - assert buy_upgrades(game, BuyStrategy.BUY_ONE) == [GRANDMA] - assert game.bought == [GRANDMA] - - -def test_buy_upgrades_buy_all_buys_every_affordable_upgrade() -> None: - game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA]) - - assert buy_upgrades(game, BuyStrategy.BUY_ALL) == [GRANDMA, CURSOR] - assert game.bought == [GRANDMA, CURSOR] - - -def test_buy_upgrades_buy_all_buys_nothing_when_too_poor() -> None: - game = FakeGame(cookies=10, upgrades=[CURSOR, GRANDMA]) - - assert buy_upgrades(game, BuyStrategy.BUY_ALL) == [] - assert game.bought == [] - - -def test_buy_upgrades_buy_all_leaves_out_failed_buys() -> None: - game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA], buy_succeeds=False) - - assert buy_upgrades(game, BuyStrategy.BUY_ALL) == [] - assert game.bought == [GRANDMA, CURSOR] - - -def test_click_and_buy_defaults_to_buying_one_upgrade() -> None: - clock = FakeClock() - game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA], clock=clock) - - result = click_and_buy(game, 6, 5, clock) - - assert result.purchases == ("Grandma",) - - -def test_click_and_buy_buy_all_records_every_purchase_in_order() -> None: - clock = FakeClock() - game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA], clock=clock) - - result = click_and_buy(game, 6, 5, clock, BuyStrategy.BUY_ALL) - - assert result.purchases == ("Grandma", "Cursor") - - -def test_click_and_buy_buy_all_does_not_record_failed_buys() -> None: - clock = FakeClock() - game = FakeGame( - cookies=120, upgrades=[CURSOR, GRANDMA], clock=clock, buy_succeeds=False - ) - - result = click_and_buy(game, 6, 5, clock, BuyStrategy.BUY_ALL) - - assert game.bought == [GRANDMA, CURSOR] - assert result.purchases == () - - -def test_click_and_buy_checks_every_second_with_a_one_second_interval() -> None: - clock = FakeClock() - game = FakeGame(cookies=105, upgrades=[GRANDMA], clock=clock) - - click_and_buy(game, 4, 1, clock) - - assert game.buy_times == [2.0, 3.0, 4.0] - - -def test_click_and_buy_checks_every_ten_seconds_with_a_ten_second_interval() -> None: - clock = FakeClock() - game = FakeGame(cookies=105, upgrades=[GRANDMA], clock=clock) - - result = click_and_buy(game, 25, 10, clock) - - assert game.buy_times == [11.0, 21.0] - assert result.purchases == ("Grandma", "Grandma") +"""@file test_game_loop.py +@brief Tests of the game loop with a fake game and a fake clock. +""" + +from pathlib import Path + +from cookie_clicker_bot.application.game_loop import ( + PlayResult, + buy_best_value_upgrade, + 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 + +VALUE_CURSOR = Upgrade("product0", "Cursor", 15, 0.1) +VALUE_GRANDMA = Upgrade("product1", "Grandma", 100, 1.0) + +CURSOR = Upgrade("product0", "Cursor", 15) +GRANDMA = Upgrade("product1", "Grandma", 100) + + +class FakeClock: + """A clock that only moves when the fake game is clicked.""" + + def __init__(self) -> None: + self.now = 0.0 + + def __call__(self) -> float: + return self.now + + +class FakeGame: + """A game with a cookie count and upgrades; each click takes one second.""" + + def __init__( + self, + cookies: int = 0, + upgrades: list[Upgrade] | None = None, + clock: FakeClock | None = None, + buy_succeeds: bool = True, + ) -> None: + self.buy_succeeds = buy_succeeds + self.clicks = 0 + self.cookies = cookies + self.available = upgrades or [] + self.bought: list[Upgrade] = [] + self.buy_times: list[float] = [] + self._clock = clock + + def open_game(self) -> None: + pass + + def dismiss_language(self) -> None: + pass + + def click_cookie(self) -> None: + self.clicks += 1 + if self._clock is not None: + self._clock.now += 1.0 + + def cookie_count(self) -> int: + return self.cookies + + def cookies_per_second(self) -> float: + return 0.0 + + def upgrades(self) -> list[Upgrade]: + return self.available + + def buy(self, upgrade: Upgrade) -> bool: + self.bought.append(upgrade) + if self._clock is not None: + self.buy_times.append(self._clock.now) + return self.buy_succeeds + + def save_screenshot(self, path: Path) -> None: + pass + + def close(self) -> None: + pass + + +def test_click_cookie_delegates_to_the_game() -> None: + game = FakeGame() + + click_cookie(game) + + assert game.clicks == 1 + + +def test_buy_most_expensive_upgrade_buys_the_best_affordable() -> None: + game = FakeGame(cookies=105, upgrades=[CURSOR, GRANDMA]) + + bought = buy_most_expensive_upgrade(game) + + assert bought == GRANDMA + assert game.bought == [GRANDMA] + + +def test_buy_most_expensive_upgrade_buys_nothing_when_too_poor() -> None: + game = FakeGame(cookies=10, upgrades=[CURSOR, GRANDMA]) + + assert buy_most_expensive_upgrade(game) is None + assert game.bought == [] + + +def test_click_and_buy_checks_the_upgrades_every_interval() -> None: + clock = FakeClock() + game = FakeGame(cookies=105, upgrades=[CURSOR, GRANDMA], clock=clock) + + result = click_and_buy(game, 12, 5, clock) + + assert result == PlayResult( + clicks=12, purchases=("Grandma", "Grandma"), elapsed_seconds=12.0 + ) + # The checks fall in the iterations that start at 5 s and 10 s; each click then + # moves the fake clock on by one second. + assert game.buy_times == [6.0, 11.0] + + +def test_click_and_buy_does_not_check_before_the_first_interval() -> None: + clock = FakeClock() + game = FakeGame(cookies=105, upgrades=[GRANDMA], clock=clock) + + result = click_and_buy(game, 4, 5, clock) + + assert result == PlayResult(clicks=4, purchases=(), elapsed_seconds=4.0) + assert game.bought == [] + + +def test_click_and_buy_records_only_real_purchases() -> None: + clock = FakeClock() + game = FakeGame(cookies=10, upgrades=[CURSOR, GRANDMA], clock=clock) + + result = click_and_buy(game, 11, 5, clock) + + assert result.clicks == 11 + assert result.purchases == () + + +def test_buy_most_expensive_upgrade_returns_none_when_the_click_fails() -> None: + game = FakeGame(cookies=105, upgrades=[CURSOR, GRANDMA], buy_succeeds=False) + + assert buy_most_expensive_upgrade(game) is None + assert game.bought == [GRANDMA] + + +def test_click_and_buy_does_not_record_a_purchase_when_the_click_fails() -> None: + clock = FakeClock() + game = FakeGame(cookies=105, upgrades=[GRANDMA], clock=clock, buy_succeeds=False) + + result = click_and_buy(game, 12, 5, clock) + + assert game.buy_times == [6.0, 11.0] + assert result.purchases == () + + +def test_click_and_buy_does_not_click_when_duration_is_zero() -> None: + clock = FakeClock() + game = FakeGame(clock=clock) + + assert click_and_buy(game, 0, 5, clock) == PlayResult( + clicks=0, purchases=(), elapsed_seconds=0.0 + ) + + +def test_click_and_buy_stops_at_the_deadline_and_reports_elapsed_time() -> None: + clock = FakeClock() + game = FakeGame(clock=clock) + + result = click_and_buy(game, 300, 5, clock) + + assert result.clicks == 300 + assert result.elapsed_seconds == 300.0 + + +def test_buy_upgrades_buy_one_buys_only_the_best_affordable() -> None: + game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA]) + + assert buy_upgrades(game, BuyStrategy.BUY_ONE) == [GRANDMA] + assert game.bought == [GRANDMA] + + +def test_buy_upgrades_buy_all_buys_every_affordable_upgrade() -> None: + game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA]) + + assert buy_upgrades(game, BuyStrategy.BUY_ALL) == [GRANDMA, CURSOR] + assert game.bought == [GRANDMA, CURSOR] + + +def test_buy_upgrades_buy_all_buys_nothing_when_too_poor() -> None: + game = FakeGame(cookies=10, upgrades=[CURSOR, GRANDMA]) + + assert buy_upgrades(game, BuyStrategy.BUY_ALL) == [] + assert game.bought == [] + + +def test_buy_upgrades_buy_all_leaves_out_failed_buys() -> None: + game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA], buy_succeeds=False) + + assert buy_upgrades(game, BuyStrategy.BUY_ALL) == [] + assert game.bought == [GRANDMA, CURSOR] + + +def test_click_and_buy_defaults_to_buying_one_upgrade() -> None: + clock = FakeClock() + game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA], clock=clock) + + result = click_and_buy(game, 6, 5, clock) + + assert result.purchases == ("Grandma",) + + +def test_click_and_buy_buy_all_records_every_purchase_in_order() -> None: + clock = FakeClock() + game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA], clock=clock) + + result = click_and_buy(game, 6, 5, clock, BuyStrategy.BUY_ALL) + + assert result.purchases == ("Grandma", "Cursor") + + +def test_click_and_buy_buy_all_does_not_record_failed_buys() -> None: + clock = FakeClock() + game = FakeGame( + cookies=120, upgrades=[CURSOR, GRANDMA], clock=clock, buy_succeeds=False + ) + + result = click_and_buy(game, 6, 5, clock, BuyStrategy.BUY_ALL) + + assert game.bought == [GRANDMA, CURSOR] + assert result.purchases == () + + +def test_click_and_buy_checks_every_second_with_a_one_second_interval() -> None: + clock = FakeClock() + game = FakeGame(cookies=105, upgrades=[GRANDMA], clock=clock) + + click_and_buy(game, 4, 1, clock) + + assert game.buy_times == [2.0, 3.0, 4.0] + + +def test_click_and_buy_checks_every_ten_seconds_with_a_ten_second_interval() -> None: + clock = FakeClock() + game = FakeGame(cookies=105, upgrades=[GRANDMA], clock=clock) + + result = click_and_buy(game, 25, 10, clock) + + assert game.buy_times == [11.0, 21.0] + assert result.purchases == ("Grandma", "Grandma") + + +def test_buy_best_value_upgrade_buys_the_cheapest_per_cookie_per_second() -> None: + game = FakeGame(cookies=500, upgrades=[VALUE_CURSOR, VALUE_GRANDMA]) + + bought = buy_best_value_upgrade(game) + + assert bought == VALUE_GRANDMA + assert game.bought == [VALUE_GRANDMA] + + +def test_buy_best_value_upgrade_buys_nothing_when_too_poor() -> None: + game = FakeGame(cookies=10, upgrades=[VALUE_CURSOR, VALUE_GRANDMA]) + + assert buy_best_value_upgrade(game) is None + assert game.bought == [] + + +def test_buy_best_value_upgrade_leaves_out_a_click_that_missed() -> None: + game = FakeGame(cookies=500, upgrades=[VALUE_GRANDMA], buy_succeeds=False) + + assert buy_best_value_upgrade(game) is None + + +def test_buy_upgrades_with_best_value_buys_one_upgrade() -> None: + game = FakeGame(cookies=500, upgrades=[VALUE_CURSOR, VALUE_GRANDMA]) + + assert buy_upgrades(game, BuyStrategy.BEST_VALUE) == [VALUE_GRANDMA] diff --git a/tests/unit/test_upgrades.py b/tests/unit/test_upgrades.py index 63b7b69..c26f39a 100644 --- a/tests/unit/test_upgrades.py +++ b/tests/unit/test_upgrades.py @@ -1,70 +1,114 @@ -"""@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] +"""@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_best_value, + 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] + + +VALUE_CURSOR = Upgrade("product0", "Cursor", 15, 0.1) +VALUE_GRANDMA = Upgrade("product1", "Grandma", 100, 1.0) +VALUE_FARM = Upgrade("product2", "Farm", 1100, 8.0) + + +def test_best_value_picks_the_lowest_price_per_cookie_per_second() -> None: + upgrades = [VALUE_CURSOR, VALUE_GRANDMA, VALUE_FARM] + + assert choose_best_value(upgrades, 2000) == VALUE_GRANDMA + + +def test_best_value_ignores_what_the_cookies_cannot_pay() -> None: + upgrades = [VALUE_CURSOR, VALUE_GRANDMA, VALUE_FARM] + + assert choose_best_value(upgrades, 50) == VALUE_CURSOR + + +def test_best_value_returns_none_when_nothing_is_affordable() -> None: + assert choose_best_value([VALUE_CURSOR, VALUE_GRANDMA], 10) is None + + +def test_best_value_returns_none_without_upgrades() -> None: + assert choose_best_value([], 1000) is None + + +def test_best_value_takes_the_first_on_a_tie() -> None: + twin = Upgrade("product9", "Twin", 100, 1.0) + + assert choose_best_value([VALUE_GRANDMA, twin], 500) == VALUE_GRANDMA + + +def test_best_value_skips_an_upgrade_with_zero_production() -> None: + idle = Upgrade("product9", "Idle", 10, 0.0) + + assert choose_best_value([idle, VALUE_GRANDMA], 500) == VALUE_GRANDMA + + +def test_best_value_falls_back_to_the_most_expensive_when_production_is_unknown() -> ( + None +): + assert choose_best_value([CURSOR, GRANDMA, FARM], 1200) == FARM -- 2.54.0 From 2f1c7ff00a80bdcfa4ed4ff087227a3e4d4d591c Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 28 Oct 2026 23:43:01 +0800 Subject: [PATCH 4/5] Record the best value comparison in the wiki and review the code of MIL-008 Task: MIL-008#4 Task: MIL-008#5 --- docs/artifact-registry.md | 2 +- docs/sqa/reviews/rc-020-code-mil-008.md | 61 ++ docs/sqa/traceability-matrix.md | 193 ++-- docs/wiki/Step-5-Improve-the-Strategy-da.md | 20 + docs/wiki/Step-5-Improve-the-Strategy-ms.md | 20 + docs/wiki/Step-5-Improve-the-Strategy.md | 20 + .../application/game_loop.py | 232 ++--- src/cookie_clicker_bot/cli.py | 322 +++--- src/cookie_clicker_bot/domain/buy_strategy.py | 32 +- src/cookie_clicker_bot/domain/upgrades.py | 162 +-- .../infrastructure/selenium_game.py | 512 ++++----- tests/integration/test_selenium_game.py | 980 +++++++++--------- tests/unit/test_cli.py | 482 ++++----- tests/unit/test_game_loop.py | 564 +++++----- tests/unit/test_upgrades.py | 228 ++-- 15 files changed, 1976 insertions(+), 1854 deletions(-) create mode 100644 docs/sqa/reviews/rc-020-code-mil-008.md diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index 3fcddc9..bbca9d7 100644 --- a/docs/artifact-registry.md +++ b/docs/artifact-registry.md @@ -16,7 +16,7 @@ document of a type. `Primary File` may contain a glob (e.g. | PP | Project Plan | docs/project-plan.md | 002 | | MIL | Milestone / Gateway | docs/milestones/*.md | 009 | | ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 | -| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 020 | +| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 021 | | TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 | | DICT | Domain Dictionary (PO and IT terms) | docs/dictionary.md | 002 | diff --git a/docs/sqa/reviews/rc-020-code-mil-008.md b/docs/sqa/reviews/rc-020-code-mil-008.md new file mode 100644 index 0000000..c73ceb6 --- /dev/null +++ b/docs/sqa/reviews/rc-020-code-mil-008.md @@ -0,0 +1,61 @@ +# SQA Review Record + +## Metadata +| Key | Value | +| --- | --- | +| ID | RC-020 | +| CrossReference | [MIL-008], [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 | pending | + +--- + +## Artifact Under Review + +- Instance reviewed: [MIL-008] +- Checklist used: [QC-PY-001] (`QC-PY-001`) +- Scope: full review of the source and tests added by MIL-008: the best value rule `choose_best_value`, the `BEST_VALUE` strategy, the production read in the Selenium adapter, the `--best-value` option and 19 new tests; four 300 s container runs produced the comparison +- 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 | 135 tests pass with fakes; 100% coverage of the logic modules; tests cover the new rule, the production read and its failure paths. | +| 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-008 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-008]: ../../milestones/mil-008-smarter-rule.md +[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md diff --git a/docs/sqa/traceability-matrix.md b/docs/sqa/traceability-matrix.md index d20f1f6..37dfee1 100644 --- a/docs/sqa/traceability-matrix.md +++ b/docs/sqa/traceability-matrix.md @@ -1,96 +1,97 @@ -# Traceability Matrix - -## Metadata -| Key | Value | -| --- | --- | -| ID | TM-001 | -| CrossReference | [BC-001] | - -## Version History -| Date | Status | Author | Reviewer | Change | Commit | -| --- | --- | --- | --- | --- | --- | -| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Initial version: baseline artifacts | [cadc5ef] | -| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Add MIL-008 and RC-019 | [9a6796f] | - ---- - -## Purpose - -Tracks backward/forward links between artifact instances so that the Business Case's -cross-artifact traceability success criterion is measurable. A row is added or -updated whenever an artifact instance is created or reviewed. - -## Purpose - -Tracks backward and forward links between artifact instances so that the Business Case's traceability can be read in one place. A row is added or updated whenever an artifact is created or reviewed. Rows for code and tests are added by the milestones that create them. - -## Traceability Table - -| Artifact Instance | Type | Language | Domain | Upstream (Backward Link) | Downstream (Forward Link) | Last Reviewed (RC-ID) | -| --- | --- | --- | --- | --- | --- | --- | -| [BC-001] | Business Case | en | it | - | [SA-001], [PP-001] | RC-001 | -| [SA-001] | Stakeholder Analysis | en | it | [BC-001] | [PP-001] | RC-002 | -| [PP-001] | Project Plan | en | it | [BC-001], [SA-001] | [MIL-001] to [MIL-008] | - (accepted by S01 in chat; no checklist) | -| [DICT-001] | Domain Dictionary | en | it | [BC-001], [SA-001] | - | RC-003 | -| [ADR-0001] | ADR | - | - | [BC-001] | [PP-001] | RC-004 | -| [ADR-0002] | ADR | - | - | [BC-001], [SA-001] | code in MIL-002 onward | RC-005 | -| [ADR-0003] | ADR | - | - | [BC-001] | [MIL-002], [MIL-003] | RC-006 | -| [MIL-001] | Milestone | en | it | [BC-001], [PP-001] | [MIL-002] | RC-007 | -| [MIL-002] | Milestone | en | it | [BC-001], [PP-001] | [MIL-003] | RC-008 | -| [MIL-003] | Milestone | en | it | [BC-001], [PP-001] | [MIL-004] | RC-009 | -| [MIL-004] | Milestone | en | it | [BC-001], [PP-001] | [MIL-005] | RC-010 | -| [MIL-005] | Milestone | en | it | [BC-001], [PP-001] | [MIL-006] | RC-011 | -| [MIL-006] | Milestone | en | it | [BC-001], [PP-001] | [MIL-007] | RC-012 | -| [MIL-007] | Milestone | en | it | [BC-001], [PP-001] | [MIL-008] | RC-013 | -| [MIL-008] | Milestone | en | it | [BC-001], [PP-001] | - | RC-019 | -| Source and tests of [MIL-002] | Python source code | - | - | [MIL-002], [ADR-0002] | - | RC-014 | -| Source and tests of [MIL-003] | Python source code | - | - | [MIL-003], [ADR-0002] | - | RC-015 | -| Source and tests of [MIL-004] | Python source code | - | - | [MIL-004], [ADR-0002] | - | RC-016 | -| Source and tests of [MIL-005] | Python source code | - | - | [MIL-005], [ADR-0002] | - | RC-017 | -| Source and tests of [MIL-006] | Python source code | - | - | [MIL-006], [ADR-0002] | - | RC-018 | - -## Concern Coverage - -| Concern (CN-ID) | Views that answer it | Decisions (ADR) | Quality check (TP) | Status | -| --- | --- | --- | --- | --- | -| CN-001 | [BC-001], [MIL-003], [MIL-004], [MIL-005] | [ADR-0002] | Unit tests per gateway | Covered | -| CN-002 | [PP-001], [MIL-001] | [ADR-0001] | Review records | Covered | -| CN-003 | [MIL-006], [MIL-008] | - | Unit tests per gateway | Covered | -| CN-004 | [MIL-002], [MIL-003] | [ADR-0002] | Code review | Covered | -| CN-005 | [MIL-002], [MIL-007] | - | Clean clone walk-through | Covered | -| CN-006 | [MIL-007] | - | Wiki check | Covered | -| CN-007 | [MIL-001], [MIL-007] | - | Repository API check | Covered | -| CN-008 | [MIL-002] | [ADR-0002] | pyproject check | Covered | -| CN-009 | [MIL-001] | - | Review of .gitignore and history | Covered | - -## Open Inconsistencies - -| # | Views | Inconsistency | Found in (RC-ID) | Status | Resolved in | -| --- | --- | --- | --- | --- | --- | -| - | - | None found | - | Closed | - | - -## Coverage Notes - -- No test plan (`TP`), architecture description (`AD`) or design artifacts exist: ADR-0001 skips stories and use cases, and the code is small. Quality checks in Concern Coverage name the check used instead. -- `-` in Upstream means foundational, in Downstream that nothing is built on it yet, and in Last Reviewed that no `RC-*` exists. -- Every concern is covered once MIL-007 is merged; the quality check column names the check that was run. - ---- - -[BC-001]: ../business-case.md -[SA-001]: ../stakeholder-analysis.md -[PP-001]: ../project-plan.md -[DICT-001]: ../dictionary.md -[ADR-0001]: ../adr/adr-0001-skip-stories-use-cases.md -[ADR-0002]: ../adr/adr-0002-light-clean-architecture.md -[ADR-0003]: ../adr/adr-0003-docker-headless-browser.md -[MIL-001]: ../milestones/mil-001-baseline.md -[MIL-002]: ../milestones/mil-002-scaffold.md -[MIL-003]: ../milestones/mil-003-click-bot.md -[MIL-004]: ../milestones/mil-004-buy-upgrades.md -[MIL-005]: ../milestones/mil-005-timed-run.md -[MIL-006]: ../milestones/mil-006-strategy.md -[MIL-007]: ../milestones/mil-007-documentation.md -[MIL-008]: ../milestones/mil-008-smarter-rule.md -[cadc5ef]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/commit/cadc5eff21d9afc09799d4bc2e3f6ac17ab2612a -[9a6796f]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/commit/9a6796f775680effe37a54f2609c3915140c8c0e +# Traceability Matrix + +## Metadata +| Key | Value | +| --- | --- | +| ID | TM-001 | +| CrossReference | [BC-001] | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Add MIL-008 and RC-019 | [9a6796f] | +| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Add RC-020, the code review of MIL-008 | pending | + +--- + +## Purpose + +Tracks backward/forward links between artifact instances so that the Business Case's +cross-artifact traceability success criterion is measurable. A row is added or +updated whenever an artifact instance is created or reviewed. + +## Purpose + +Tracks backward and forward links between artifact instances so that the Business Case's traceability can be read in one place. A row is added or updated whenever an artifact is created or reviewed. Rows for code and tests are added by the milestones that create them. + +## Traceability Table + +| Artifact Instance | Type | Language | Domain | Upstream (Backward Link) | Downstream (Forward Link) | Last Reviewed (RC-ID) | +| --- | --- | --- | --- | --- | --- | --- | +| [BC-001] | Business Case | en | it | - | [SA-001], [PP-001] | RC-001 | +| [SA-001] | Stakeholder Analysis | en | it | [BC-001] | [PP-001] | RC-002 | +| [PP-001] | Project Plan | en | it | [BC-001], [SA-001] | [MIL-001] to [MIL-008] | - (accepted by S01 in chat; no checklist) | +| [DICT-001] | Domain Dictionary | en | it | [BC-001], [SA-001] | - | RC-003 | +| [ADR-0001] | ADR | - | - | [BC-001] | [PP-001] | RC-004 | +| [ADR-0002] | ADR | - | - | [BC-001], [SA-001] | code in MIL-002 onward | RC-005 | +| [ADR-0003] | ADR | - | - | [BC-001] | [MIL-002], [MIL-003] | RC-006 | +| [MIL-001] | Milestone | en | it | [BC-001], [PP-001] | [MIL-002] | RC-007 | +| [MIL-002] | Milestone | en | it | [BC-001], [PP-001] | [MIL-003] | RC-008 | +| [MIL-003] | Milestone | en | it | [BC-001], [PP-001] | [MIL-004] | RC-009 | +| [MIL-004] | Milestone | en | it | [BC-001], [PP-001] | [MIL-005] | RC-010 | +| [MIL-005] | Milestone | en | it | [BC-001], [PP-001] | [MIL-006] | RC-011 | +| [MIL-006] | Milestone | en | it | [BC-001], [PP-001] | [MIL-007] | RC-012 | +| [MIL-007] | Milestone | en | it | [BC-001], [PP-001] | [MIL-008] | RC-013 | +| [MIL-008] | Milestone | en | it | [BC-001], [PP-001] | - | RC-019 | +| Source and tests of [MIL-002] | Python source code | - | - | [MIL-002], [ADR-0002] | - | RC-014 | +| Source and tests of [MIL-003] | Python source code | - | - | [MIL-003], [ADR-0002] | - | RC-015 | +| Source and tests of [MIL-004] | Python source code | - | - | [MIL-004], [ADR-0002] | - | RC-016 | +| Source and tests of [MIL-005] | Python source code | - | - | [MIL-005], [ADR-0002] | - | RC-017 | +| Source and tests of [MIL-006] | Python source code | - | - | [MIL-006], [ADR-0002] | - | RC-018 | +| Source and tests of [MIL-008] | Python source code | - | - | [MIL-008], [ADR-0002] | - | RC-020 | + +## Concern Coverage + +| Concern (CN-ID) | Views that answer it | Decisions (ADR) | Quality check (TP) | Status | +| --- | --- | --- | --- | --- | +| CN-001 | [BC-001], [MIL-003], [MIL-004], [MIL-005] | [ADR-0002] | Unit tests per gateway | Covered | +| CN-002 | [PP-001], [MIL-001] | [ADR-0001] | Review records | Covered | +| CN-003 | [MIL-006], [MIL-008] | - | Unit tests per gateway | Covered | +| CN-004 | [MIL-002], [MIL-003] | [ADR-0002] | Code review | Covered | +| CN-005 | [MIL-002], [MIL-007] | - | Clean clone walk-through | Covered | +| CN-006 | [MIL-007] | - | Wiki check | Covered | +| CN-007 | [MIL-001], [MIL-007] | - | Repository API check | Covered | +| CN-008 | [MIL-002] | [ADR-0002] | pyproject check | Covered | +| CN-009 | [MIL-001] | - | Review of .gitignore and history | Covered | + +## Open Inconsistencies + +| # | Views | Inconsistency | Found in (RC-ID) | Status | Resolved in | +| --- | --- | --- | --- | --- | --- | +| - | - | None found | - | Closed | - | + +## Coverage Notes + +- No test plan (`TP`), architecture description (`AD`) or design artifacts exist: ADR-0001 skips stories and use cases, and the code is small. Quality checks in Concern Coverage name the check used instead. +- `-` in Upstream means foundational, in Downstream that nothing is built on it yet, and in Last Reviewed that no `RC-*` exists. +- Every concern is covered once MIL-007 is merged; the quality check column names the check that was run. + +--- + +[BC-001]: ../business-case.md +[SA-001]: ../stakeholder-analysis.md +[PP-001]: ../project-plan.md +[DICT-001]: ../dictionary.md +[ADR-0001]: ../adr/adr-0001-skip-stories-use-cases.md +[ADR-0002]: ../adr/adr-0002-light-clean-architecture.md +[ADR-0003]: ../adr/adr-0003-docker-headless-browser.md +[MIL-001]: ../milestones/mil-001-baseline.md +[MIL-002]: ../milestones/mil-002-scaffold.md +[MIL-003]: ../milestones/mil-003-click-bot.md +[MIL-004]: ../milestones/mil-004-buy-upgrades.md +[MIL-005]: ../milestones/mil-005-timed-run.md +[MIL-006]: ../milestones/mil-006-strategy.md +[MIL-007]: ../milestones/mil-007-documentation.md +[MIL-008]: ../milestones/mil-008-smarter-rule.md +[cadc5ef]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/commit/cadc5eff21d9afc09799d4bc2e3f6ac17ab2612a +[9a6796f]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/commit/9a6796f775680effe37a54f2609c3915140c8c0e diff --git a/docs/wiki/Step-5-Improve-the-Strategy-da.md b/docs/wiki/Step-5-Improve-the-Strategy-da.md index bbfa58b..56f92f3 100644 --- a/docs/wiki/Step-5-Improve-the-Strategy-da.md +++ b/docs/wiki/Step-5-Improve-the-Strategy-da.md @@ -36,9 +36,29 @@ Det bedste resultat er intervallet på 10 sekunder med køb én. Hvorfor: - At tjekke oftere er ikke bedre. Hvert tjek læser siden og tager tid fra klikningen, og det dyreste produkt, der er råd til, er som regel kun lidt bedre end det billigere, man kunne have købt et øjeblik før. - Køb alle gør ingen forskel tidligt i spillet: der er kun råd til Cursor og Grandma, og det køber botten alligevel. Det ville få betydning senere, når antallet af cookies rummer flere produkter på én gang. +## En smartere regel: bedste værdi + +`--best-value` ser ikke kun på prisen. Spillet fortæller, hvor mange cookies pr. sekund én enhed af hvert produkt giver, og botten køber det produkt, der er råd til, med den laveste pris pr. cookie pr. sekund (`choose_best_value`). Cursor koster 15 og giver 0,1 (150 cookies for hver cookie pr. sekund), Grandma koster 100 og giver 1 (100 for hver), så Grandma er det bedste køb, selv om den koster mere. + +```bash +cookie-clicker-bot --best-value +``` + +Hver række er en fuld kørsel på 300 sekunder alene i en Docker-container, den ene efter den anden, tjek hvert 5. sekund: + +| Kørsel | Strategi | cookies/sekund | Købt | +| --- | --- | --- | --- | +| 1 | dyreste, der er råd til | 18 | 30 Cursor, 15 Grandma | +| 2 | bedste værdi | 17.9 | 29 Cursor, 15 Grandma | +| 3 | dyreste, der er råd til | 18.8 | 28 Cursor, 16 Grandma | +| 4 | bedste værdi | 18.8 | 28 Cursor, 16 Grandma | + +Den smartere regel er **ikke bedre**: de to regler adskiller sig mindre end to kørsler af den samme regel (18 og 18,8). Årsagen er den samme som for køb alle. På 5 sekunder klikker botten omkring 125 cookies, så der er kun råd til Cursor og Grandma, og begge regler køber næsten det samme. En regel, der ser på værdi, ville få betydning, når der er råd til flere produkter på én gang, for eksempel med et længere tjekinterval. + ## Hvad vi fik ud af dette trin - **Små, rene regler er nemme at sammenligne.** Fordi valget af opgradering ikke rører Selenium, er en ny strategi en ny funktion plus en test. - **Mål, gæt ikke.** Det resultat, vi forventede (flere tjek, flere cookies), var det modsatte af, hvad kørslerne viste. - **En anden rigtig fejl.** Det første hold styrtede ned i én container: spillet omskriver tælleren i hvert billede, og en computer under belastning fik vores ene genforsøg til at fejle. Læsningen forsøger nu fem gange med en kort pause. En test med en falsk driver, der bliver forældet fire gange, viser det. - **Retfærdige sammenligninger kræver de samme forhold.** Vi kørte rækkerne samtidig og rapporterer to hold, fordi en enkelt kørsel kan være heldig. +- **En bedre idé skal stadig måles.** Reglen om bedste værdi er rigtig på papiret og gjorde ingen forskel i kørslerne, fordi botten aldrig har cookies nok til at vælge mellem mere end to produkter. diff --git a/docs/wiki/Step-5-Improve-the-Strategy-ms.md b/docs/wiki/Step-5-Improve-the-Strategy-ms.md index bbd1d54..d46be8f 100644 --- a/docs/wiki/Step-5-Improve-the-Strategy-ms.md +++ b/docs/wiki/Step-5-Improve-the-Strategy-ms.md @@ -36,9 +36,29 @@ Keputusan terbaik ialah selang 10 saat dengan beli satu. Sebabnya: - Menyemak lebih kerap tidak lebih baik. Setiap semakan membaca halaman dan mengambil masa daripada mengklik, dan produk termahal yang mampu dibeli biasanya hanya sedikit lebih baik daripada yang lebih murah yang boleh anda beli seketika sebelumnya. - Beli semua tidak membuat perbezaan pada awal permainan: hanya Cursor dan Grandma yang mampu dibeli, dan bot membelinya juga. Ia akan penting kemudian, apabila bilangan kuki cukup untuk beberapa produk sekali gus. +## Peraturan yang lebih bijak: nilai terbaik + +`--best-value` tidak melihat harga sahaja. Permainan memberitahu berapa kuki sesaat yang dihasilkan oleh satu unit setiap produk, dan bot membeli produk yang mampu dibeli dengan harga terendah bagi setiap kuki sesaat (`choose_best_value`). Cursor berharga 15 dan menghasilkan 0.1 (150 kuki bagi setiap kuki sesaat), Grandma berharga 100 dan menghasilkan 1 (100 bagi setiap satu), jadi Grandma ialah belian yang lebih baik walaupun lebih mahal. + +```bash +cookie-clicker-bot --best-value +``` + +Setiap baris ialah larian penuh 300 saat bersendirian dalam bekas Docker, satu demi satu, semak setiap 5 saat: + +| Larian | Strategi | kuki/saat | Dibeli | +| --- | --- | --- | --- | +| 1 | termahal yang mampu dibeli | 18 | 30 Cursor, 15 Grandma | +| 2 | nilai terbaik | 17.9 | 29 Cursor, 15 Grandma | +| 3 | termahal yang mampu dibeli | 18.8 | 28 Cursor, 16 Grandma | +| 4 | nilai terbaik | 18.8 | 28 Cursor, 16 Grandma | + +Peraturan yang lebih bijak ini **tidak lebih baik**: perbezaan antara kedua-dua peraturan lebih kecil daripada perbezaan antara dua larian peraturan yang sama (18 dan 18.8). Sebabnya sama seperti beli semua. Dalam 5 saat bot mengklik kira-kira 125 kuki, jadi hanya Cursor dan Grandma yang mampu dibeli, dan kedua-dua peraturan membeli hampir perkara yang sama. Peraturan yang melihat nilai akan penting apabila beberapa produk mampu dibeli sekali gus, misalnya dengan selang semakan yang lebih panjang. + ## Apa yang kita peroleh dalam langkah ini - **Peraturan kecil yang tulen mudah dibandingkan.** Oleh kerana pemilihan naik taraf tidak menyentuh Selenium, strategi baharu hanyalah fungsi baharu serta satu ujian. - **Ukur, jangan meneka.** Keputusan yang kami jangkakan (lebih banyak semakan, lebih banyak kuki) bertentangan dengan apa yang ditunjukkan oleh larian. - **Pepijat sebenar kedua.** Kelompok pertama ranap dalam satu bekas: permainan menulis semula pembilang pada setiap bingkai, dan komputer yang terbeban menyebabkan satu percubaan semula kami gagal. Bacaan kini mencuba semula lima kali dengan jeda singkat. Ujian dengan pemacu palsu yang menjadi basi empat kali menunjukkannya. - **Perbandingan yang adil memerlukan keadaan yang sama.** Kami menjalankan baris-baris itu serentak dan melaporkan dua kelompok, kerana satu larian boleh bernasib baik. +- **Idea yang lebih baik masih perlu diukur.** Peraturan nilai terbaik betul di atas kertas dan tidak membuat perbezaan dalam larian, kerana bot tidak pernah mempunyai kuki yang cukup untuk memilih antara lebih daripada dua produk. diff --git a/docs/wiki/Step-5-Improve-the-Strategy.md b/docs/wiki/Step-5-Improve-the-Strategy.md index a545926..7e51462 100644 --- a/docs/wiki/Step-5-Improve-the-Strategy.md +++ b/docs/wiki/Step-5-Improve-the-Strategy.md @@ -36,9 +36,29 @@ 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. +## A smarter rule: best value + +`--best-value` does not look at the price alone. The game tells how many cookies per second one unit of each product makes, and the bot buys the affordable product with the lowest price per cookie per second (`choose_best_value`). Cursor costs 15 and makes 0.1 (150 cookies for each cookie per second), Grandma costs 100 and makes 1 (100 for each), so Grandma is the better deal even though it costs more. + +```bash +cookie-clicker-bot --best-value +``` + +Each row is a full 300 second run alone in a Docker container, one after the other, check every 5 seconds: + +| Run | Strategy | cookies/second | Bought | +| --- | --- | --- | --- | +| 1 | most expensive affordable | 18 | 30 Cursor, 15 Grandma | +| 2 | best value | 17.9 | 29 Cursor, 15 Grandma | +| 3 | most expensive affordable | 18.8 | 28 Cursor, 16 Grandma | +| 4 | best value | 18.8 | 28 Cursor, 16 Grandma | + +The smarter rule is **not better**: the two rules differ by less than the difference between two runs of the same rule (18 and 18.8). The reason is the same as for buy all. In 5 seconds the bot clicks about 125 cookies, so only Cursor and Grandma are ever affordable, and both rules buy nearly the same. A rule that looks at value would matter once several products are affordable at once, for example with a longer check interval or after the first minutes of luck. + ## 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. +- **A better idea still has to be measured.** The best value rule is correct on paper and made no difference in the runs, because the bot never has enough cookies to choose between more than two products. diff --git a/src/cookie_clicker_bot/application/game_loop.py b/src/cookie_clicker_bot/application/game_loop.py index e314019..78f1a75 100644 --- a/src/cookie_clicker_bot/application/game_loop.py +++ b/src/cookie_clicker_bot/application/game_loop.py @@ -1,116 +1,116 @@ -"""@file game_loop.py -@brief The game loop of the bot, written against the game port. -""" - -import time -from collections.abc import Callable -from dataclasses import dataclass - -from cookie_clicker_bot.application.ports import GamePort -from cookie_clicker_bot.domain.buy_strategy import BuyStrategy -from cookie_clicker_bot.domain.upgrades import ( - Upgrade, - choose_affordable_upgrades, - choose_best_value, - choose_most_expensive_affordable, -) - - -@dataclass(frozen=True, slots=True) -class PlayResult: - """@brief What a run did.""" - - ## Number of clicks on the big cookie. - clicks: int - ## Names of the upgrades bought, in the order they were bought. - purchases: tuple[str, ...] - ## Seconds the run lasted, measured with the clock given to `click_and_buy`. - elapsed_seconds: float - - -def click_cookie(game: GamePort) -> None: - """@brief Click the big cookie once (lecture step 2). - - @param game The game to play. - """ - game.click_cookie() - - -def buy_most_expensive_upgrade(game: GamePort) -> Upgrade | None: - """@brief Buy the most expensive upgrade the player can afford (lecture step 3). - - @param game The game to play. - @return The upgrade that was bought, or None when nothing was affordable or - the click did not reach the product. - """ - chosen = choose_most_expensive_affordable(game.upgrades(), game.cookie_count()) - if chosen is not None and game.buy(chosen): - return chosen - return None - - -def buy_best_value_upgrade(game: GamePort) -> Upgrade | None: - """@brief Buy the affordable upgrade with the lowest price per cookie per second. - - @param game The game to play. - @return The upgrade that was bought, or None when nothing was affordable or - the click did not reach the product. - """ - chosen = choose_best_value(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, BEST_VALUE buys the one with the lowest - price per cookie per second. - @return The upgrades really bought, in order; a click that missed is left out. - """ - if strategy is not BuyStrategy.BUY_ALL: - buy_one = ( - buy_best_value_upgrade - if strategy is BuyStrategy.BEST_VALUE - else buy_most_expensive_upgrade - ) - bought = buy_one(game) - return [] if bought is None else [bought] - plan = choose_affordable_upgrades(game.upgrades(), game.cookie_count()) - return [upgrade for upgrade in plan if game.buy(upgrade)] - - -def click_and_buy( - game: GamePort, - duration_seconds: float, - check_interval_seconds: float, - clock: Callable[[], float] = time.monotonic, - strategy: BuyStrategy = BuyStrategy.BUY_ONE, -) -> PlayResult: - """@brief Click the cookie nonstop and check the upgrades at every interval. - - @param game The game to play. - @param duration_seconds How long to play. - @param check_interval_seconds Time between two upgrade checks; the first check - comes one interval after the start. - @param clock Monotonic clock in seconds; a test passes a fake one. - @param strategy What to buy at each check. - @return The clicks, the names of the upgrades bought in order, and the time spent. - """ - start = clock() - deadline = start + duration_seconds - next_check = start + check_interval_seconds - clicks = 0 - purchases: list[str] = [] - while (now := clock()) < deadline: - click_cookie(game) - clicks += 1 - if now >= next_check: - next_check = now + check_interval_seconds - purchases.extend(upgrade.name for upgrade in buy_upgrades(game, strategy)) - return PlayResult( - clicks=clicks, purchases=tuple(purchases), elapsed_seconds=clock() - start - ) +"""@file game_loop.py +@brief The game loop of the bot, written against the game port. +""" + +import time +from collections.abc import Callable +from dataclasses import dataclass + +from cookie_clicker_bot.application.ports import GamePort +from cookie_clicker_bot.domain.buy_strategy import BuyStrategy +from cookie_clicker_bot.domain.upgrades import ( + Upgrade, + choose_affordable_upgrades, + choose_best_value, + choose_most_expensive_affordable, +) + + +@dataclass(frozen=True, slots=True) +class PlayResult: + """@brief What a run did.""" + + ## Number of clicks on the big cookie. + clicks: int + ## Names of the upgrades bought, in the order they were bought. + purchases: tuple[str, ...] + ## Seconds the run lasted, measured with the clock given to `click_and_buy`. + elapsed_seconds: float + + +def click_cookie(game: GamePort) -> None: + """@brief Click the big cookie once (lecture step 2). + + @param game The game to play. + """ + game.click_cookie() + + +def buy_most_expensive_upgrade(game: GamePort) -> Upgrade | None: + """@brief Buy the most expensive upgrade the player can afford (lecture step 3). + + @param game The game to play. + @return The upgrade that was bought, or None when nothing was affordable or + the click did not reach the product. + """ + chosen = choose_most_expensive_affordable(game.upgrades(), game.cookie_count()) + if chosen is not None and game.buy(chosen): + return chosen + return None + + +def buy_best_value_upgrade(game: GamePort) -> Upgrade | None: + """@brief Buy the affordable upgrade with the lowest price per cookie per second. + + @param game The game to play. + @return The upgrade that was bought, or None when nothing was affordable or + the click did not reach the product. + """ + chosen = choose_best_value(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, BEST_VALUE buys the one with the lowest + price per cookie per second. + @return The upgrades really bought, in order; a click that missed is left out. + """ + if strategy is not BuyStrategy.BUY_ALL: + buy_one = ( + buy_best_value_upgrade + if strategy is BuyStrategy.BEST_VALUE + else buy_most_expensive_upgrade + ) + bought = buy_one(game) + return [] if bought is None else [bought] + plan = choose_affordable_upgrades(game.upgrades(), game.cookie_count()) + return [upgrade for upgrade in plan if game.buy(upgrade)] + + +def click_and_buy( + game: GamePort, + duration_seconds: float, + check_interval_seconds: float, + clock: Callable[[], float] = time.monotonic, + strategy: BuyStrategy = BuyStrategy.BUY_ONE, +) -> PlayResult: + """@brief Click the cookie nonstop and check the upgrades at every interval. + + @param game The game to play. + @param duration_seconds How long to play. + @param check_interval_seconds Time between two upgrade checks; the first check + comes one interval after the start. + @param clock Monotonic clock in seconds; a test passes a fake one. + @param strategy What to buy at each check. + @return The clicks, the names of the upgrades bought in order, and the time spent. + """ + start = clock() + deadline = start + duration_seconds + next_check = start + check_interval_seconds + clicks = 0 + purchases: list[str] = [] + while (now := clock()) < deadline: + click_cookie(game) + clicks += 1 + if now >= next_check: + next_check = now + check_interval_seconds + purchases.extend(upgrade.name for upgrade in buy_upgrades(game, strategy)) + return PlayResult( + clicks=clicks, purchases=tuple(purchases), elapsed_seconds=clock() - start + ) diff --git a/src/cookie_clicker_bot/cli.py b/src/cookie_clicker_bot/cli.py index a7437e6..0bdd9ca 100644 --- a/src/cookie_clicker_bot/cli.py +++ b/src/cookie_clicker_bot/cli.py @@ -1,161 +1,161 @@ -"""@file cli.py -@brief Command-line entry point (the composition root of the bot). -""" - -import argparse -import logging -from collections.abc import Callable, Sequence -from pathlib import Path - -from cookie_clicker_bot import constants -from cookie_clicker_bot.application.game_loop import click_and_buy -from cookie_clicker_bot.application.ports import GamePort -from cookie_clicker_bot.application.report import format_result -from cookie_clicker_bot.domain.buy_strategy import BuyStrategy -from cookie_clicker_bot.infrastructure.browser import create_driver -from cookie_clicker_bot.infrastructure.selenium_game import SeleniumGame - -logger = logging.getLogger(__name__) - - -def create_game(headless: bool) -> GamePort: - """@brief Build the Selenium game. - - @param headless True to run Chromium without a window. - @return The game, ready to open. - """ - return SeleniumGame(create_driver(headless)) - - -def positive_float(text: str) -> float: - """@brief Read a number greater than zero from the command line. - - @param text The argument text. - @return The number. - @throws argparse.ArgumentTypeError When the text is not a number above zero. - """ - message = constants.POSITIVE_NUMBER_ERROR_TEMPLATE.format(value=text) - try: - value = float(text) - except ValueError: - raise argparse.ArgumentTypeError(message) from None - if not value > 0: - raise argparse.ArgumentTypeError(message) - return value - - -def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: - """@brief Read the command line. - - @param argv Arguments without the program name; None reads `sys.argv`. - @return The parsed options. - """ - parser = argparse.ArgumentParser( - prog="cookie-clicker-bot", - description=( - "Plays Cookie Clicker: clicks the big cookie as fast as it can " - "and buys upgrades." - ), - ) - parser.add_argument( - "--duration", - type=float, - default=constants.RUN_DURATION_SECONDS, - help="seconds to play (default: %(default)s)", - ) - parser.add_argument( - "--interval", - type=positive_float, - default=constants.UPGRADE_CHECK_INTERVAL_SECONDS, - help="seconds between upgrade checks, greater than 0 (default: %(default)s)", - ) - choice = parser.add_mutually_exclusive_group() - choice.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)" - ), - ) - choice.add_argument( - "--best-value", - action="store_true", - help=( - "at each check buy the affordable upgrade with the lowest price per " - "cookie per second" - ), - ) - parser.add_argument( - "--headless", - action="store_true", - help="run Chromium without a window (the Docker image does this)", - ) - parser.add_argument( - "--screenshot-start", - type=Path, - help="write a screenshot of the game before the first click", - ) - parser.add_argument( - "--screenshot", - type=Path, - help="write a screenshot of the game when the run ends", - ) - return parser.parse_args(argv) - - -def choose_strategy(args: argparse.Namespace) -> BuyStrategy: - """@brief Turn the purchase options into a strategy. - - @param args The parsed options. - @return BUY_ALL, BEST_VALUE or, by default, BUY_ONE. - """ - if args.buy_all: - return BuyStrategy.BUY_ALL - if args.best_value: - return BuyStrategy.BEST_VALUE - return BuyStrategy.BUY_ONE - - -def main( - argv: Sequence[str] | None = None, - game_factory: Callable[[bool], GamePort] = create_game, -) -> int: - """@brief Start the bot. - - @param argv Arguments without the program name; None reads `sys.argv`. - @param game_factory Builds the game; a test passes a fake. - @return Process exit code, 0 when the run finished. - """ - logging.basicConfig(level=logging.INFO, format="%(message)s") - args = parse_args(argv) - game = game_factory(args.headless) - try: - game.open_game() - game.dismiss_language() - if args.screenshot_start is not None: - game.save_screenshot(args.screenshot_start) - strategy = choose_strategy(args) - logger.info( - "checking upgrades every %s seconds, strategy %s", - args.interval, - strategy.value, - ) - result = click_and_buy(game, args.duration, args.interval, strategy=strategy) - for name in result.purchases: - logger.info("bought %s", name) - logger.info( - "clicked the cookie %d times and bought %d upgrades in %.1f seconds", - result.clicks, - len(result.purchases), - result.elapsed_seconds, - ) - cookies_per_second = game.cookies_per_second() - logger.info("%s", format_result(cookies_per_second)) - if args.screenshot is not None: - game.save_screenshot(args.screenshot) - finally: - game.close() - # The program's required result: the only line written to stdout (logs use stderr). - print(format_result(cookies_per_second)) - return 0 +"""@file cli.py +@brief Command-line entry point (the composition root of the bot). +""" + +import argparse +import logging +from collections.abc import Callable, Sequence +from pathlib import Path + +from cookie_clicker_bot import constants +from cookie_clicker_bot.application.game_loop import click_and_buy +from cookie_clicker_bot.application.ports import GamePort +from cookie_clicker_bot.application.report import format_result +from cookie_clicker_bot.domain.buy_strategy import BuyStrategy +from cookie_clicker_bot.infrastructure.browser import create_driver +from cookie_clicker_bot.infrastructure.selenium_game import SeleniumGame + +logger = logging.getLogger(__name__) + + +def create_game(headless: bool) -> GamePort: + """@brief Build the Selenium game. + + @param headless True to run Chromium without a window. + @return The game, ready to open. + """ + return SeleniumGame(create_driver(headless)) + + +def positive_float(text: str) -> float: + """@brief Read a number greater than zero from the command line. + + @param text The argument text. + @return The number. + @throws argparse.ArgumentTypeError When the text is not a number above zero. + """ + message = constants.POSITIVE_NUMBER_ERROR_TEMPLATE.format(value=text) + try: + value = float(text) + except ValueError: + raise argparse.ArgumentTypeError(message) from None + if not value > 0: + raise argparse.ArgumentTypeError(message) + return value + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + """@brief Read the command line. + + @param argv Arguments without the program name; None reads `sys.argv`. + @return The parsed options. + """ + parser = argparse.ArgumentParser( + prog="cookie-clicker-bot", + description=( + "Plays Cookie Clicker: clicks the big cookie as fast as it can " + "and buys upgrades." + ), + ) + parser.add_argument( + "--duration", + type=float, + default=constants.RUN_DURATION_SECONDS, + help="seconds to play (default: %(default)s)", + ) + parser.add_argument( + "--interval", + type=positive_float, + default=constants.UPGRADE_CHECK_INTERVAL_SECONDS, + help="seconds between upgrade checks, greater than 0 (default: %(default)s)", + ) + choice = parser.add_mutually_exclusive_group() + choice.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)" + ), + ) + choice.add_argument( + "--best-value", + action="store_true", + help=( + "at each check buy the affordable upgrade with the lowest price per " + "cookie per second" + ), + ) + parser.add_argument( + "--headless", + action="store_true", + help="run Chromium without a window (the Docker image does this)", + ) + parser.add_argument( + "--screenshot-start", + type=Path, + help="write a screenshot of the game before the first click", + ) + parser.add_argument( + "--screenshot", + type=Path, + help="write a screenshot of the game when the run ends", + ) + return parser.parse_args(argv) + + +def choose_strategy(args: argparse.Namespace) -> BuyStrategy: + """@brief Turn the purchase options into a strategy. + + @param args The parsed options. + @return BUY_ALL, BEST_VALUE or, by default, BUY_ONE. + """ + if args.buy_all: + return BuyStrategy.BUY_ALL + if args.best_value: + return BuyStrategy.BEST_VALUE + return BuyStrategy.BUY_ONE + + +def main( + argv: Sequence[str] | None = None, + game_factory: Callable[[bool], GamePort] = create_game, +) -> int: + """@brief Start the bot. + + @param argv Arguments without the program name; None reads `sys.argv`. + @param game_factory Builds the game; a test passes a fake. + @return Process exit code, 0 when the run finished. + """ + logging.basicConfig(level=logging.INFO, format="%(message)s") + args = parse_args(argv) + game = game_factory(args.headless) + try: + game.open_game() + game.dismiss_language() + if args.screenshot_start is not None: + game.save_screenshot(args.screenshot_start) + strategy = choose_strategy(args) + logger.info( + "checking upgrades every %s seconds, strategy %s", + args.interval, + strategy.value, + ) + result = click_and_buy(game, args.duration, args.interval, strategy=strategy) + for name in result.purchases: + logger.info("bought %s", name) + logger.info( + "clicked the cookie %d times and bought %d upgrades in %.1f seconds", + result.clicks, + len(result.purchases), + result.elapsed_seconds, + ) + cookies_per_second = game.cookies_per_second() + logger.info("%s", format_result(cookies_per_second)) + if args.screenshot is not None: + game.save_screenshot(args.screenshot) + finally: + game.close() + # The program's required result: the only line written to stdout (logs use stderr). + print(format_result(cookies_per_second)) + return 0 diff --git a/src/cookie_clicker_bot/domain/buy_strategy.py b/src/cookie_clicker_bot/domain/buy_strategy.py index 8ad8d2d..9295ed8 100644 --- a/src/cookie_clicker_bot/domain/buy_strategy.py +++ b/src/cookie_clicker_bot/domain/buy_strategy.py @@ -1,16 +1,16 @@ -"""@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" - ## Buy the affordable upgrade with the lowest price per cookie per second. - BEST_VALUE = "best-value" +"""@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" + ## Buy the affordable upgrade with the lowest price per cookie per second. + BEST_VALUE = "best-value" diff --git a/src/cookie_clicker_bot/domain/upgrades.py b/src/cookie_clicker_bot/domain/upgrades.py index a0e5421..4efd362 100644 --- a/src/cookie_clicker_bot/domain/upgrades.py +++ b/src/cookie_clicker_bot/domain/upgrades.py @@ -1,81 +1,81 @@ -"""@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 - ## Cookies per second one unit makes; None when the page did not tell. - cookies_per_second: float | None = None - - -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_best_value(upgrades: Sequence[Upgrade], cookies: int) -> Upgrade | None: - """@brief Pick the affordable upgrade that pays for itself fastest. - - The value of an upgrade is its price divided by the cookies per second it makes, - so the lowest ratio wins. Upgrades with unknown or zero production are skipped; - when none of the affordable ones has a known production the rule falls back to - the most expensive affordable upgrade. - - @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. - """ - producing = [ - upgrade - for upgrade in upgrades - if upgrade.price <= cookies - and upgrade.cookies_per_second is not None - and upgrade.cookies_per_second > 0 - ] - if not producing: - return choose_most_expensive_affordable(upgrades, cookies) - return min( - producing, - key=lambda upgrade: upgrade.price / (upgrade.cookies_per_second or 1.0), - ) - - -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 +"""@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 + ## Cookies per second one unit makes; None when the page did not tell. + cookies_per_second: float | None = None + + +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_best_value(upgrades: Sequence[Upgrade], cookies: int) -> Upgrade | None: + """@brief Pick the affordable upgrade that pays for itself fastest. + + The value of an upgrade is its price divided by the cookies per second it makes, + so the lowest ratio wins. Upgrades with unknown or zero production are skipped; + when none of the affordable ones has a known production the rule falls back to + the most expensive affordable upgrade. + + @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. + """ + producing = [ + upgrade + for upgrade in upgrades + if upgrade.price <= cookies + and upgrade.cookies_per_second is not None + and upgrade.cookies_per_second > 0 + ] + if not producing: + return choose_most_expensive_affordable(upgrades, cookies) + return min( + producing, + key=lambda upgrade: upgrade.price / (upgrade.cookies_per_second or 1.0), + ) + + +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 diff --git a/src/cookie_clicker_bot/infrastructure/selenium_game.py b/src/cookie_clicker_bot/infrastructure/selenium_game.py index b13f9e8..48a6a0e 100644 --- a/src/cookie_clicker_bot/infrastructure/selenium_game.py +++ b/src/cookie_clicker_bot/infrastructure/selenium_game.py @@ -1,256 +1,256 @@ -"""@file selenium_game.py -@brief Selenium adapter that implements the game port. -""" - -import time -from collections.abc import Callable, Sequence -from pathlib import Path -from typing import Any, Protocol, TypeVar - -from selenium.common.exceptions import ( - ElementClickInterceptedException, - JavascriptException, - NoSuchElementException, - StaleElementReferenceException, -) -from selenium.webdriver.common.by import By - -from cookie_clicker_bot import constants -from cookie_clicker_bot.domain.parsing import ( - parse_amount, - parse_cookie_count, - parse_cookies_per_second, -) -from cookie_clicker_bot.domain.upgrades import Upgrade - -T = TypeVar("T") - - -class ElementLike(Protocol): - """@brief The part of a Selenium element that the adapter uses.""" - - @property - def text(self) -> str: - """@brief Visible text of the element.""" - ... - - def click(self) -> None: - """@brief Click the element.""" - - def is_displayed(self) -> bool: - """@brief True when the element is visible.""" - ... - - def get_attribute(self, name: str) -> str | None: - """@brief Read an attribute of the element.""" - ... - - def find_element(self, by: str, value: str) -> "ElementLike": - """@brief Find one element inside this element.""" - ... - - -class DriverLike(Protocol): - """@brief The part of a Selenium driver that the adapter uses.""" - - def get(self, url: str) -> None: - """@brief Load a page.""" - - def find_element(self, by: str, value: str) -> ElementLike: - """@brief Find one element.""" - ... - - def find_elements(self, by: str, value: str) -> Sequence[ElementLike]: - """@brief Find every element that matches.""" - ... - - def save_screenshot(self, filename: str) -> bool: - """@brief Write a PNG screenshot.""" - ... - - def execute_script(self, script: str, *args: Any) -> Any: # noqa: ANN401 - """@brief Run JavaScript in the page; the arguments are `arguments[n]`.""" - ... - - def quit(self) -> None: - """@brief Close the browser.""" - - -class SeleniumGame: - """@brief Plays the game page through a Selenium driver.""" - - def __init__( - self, - driver: DriverLike, - timeout_seconds: float = constants.WAIT_TIMEOUT_SECONDS, - clock: Callable[[], float] = time.monotonic, - sleep: Callable[[float], None] = time.sleep, - ) -> None: - """@brief Wrap a driver. - - @param driver The Selenium driver (or a fake in tests). - @param timeout_seconds Longest wait for an element. - @param clock Monotonic clock in seconds. - @param sleep Pause function used between polls. - """ - self._driver = driver - self._timeout = timeout_seconds - self._clock = clock - self._sleep = sleep - - def open_game(self) -> None: - """@brief Load the game page.""" - self._driver.get(constants.GAME_URL) - - def dismiss_language(self) -> None: - """@brief Pick English, then wait until the big cookie can be clicked.""" - english = self._wait_for(lambda: self._visible(constants.LANGUAGE_ENGLISH_ID)) - english.click() - self._wait_for(lambda: self._visible(constants.BIG_COOKIE_ID)) - - def click_cookie(self) -> None: - """@brief Click the big cookie once.""" - try: - self._driver.find_element(By.ID, constants.BIG_COOKIE_ID).click() - except (ElementClickInterceptedException, StaleElementReferenceException): - # A banner or a re-render covered the cookie for a moment; try again. - return - - def cookie_count(self) -> int: - """@brief Read how many cookies the player has. - - @return The cookie count. - """ - return self._read_again_if_stale( - lambda: parse_cookie_count( - self._driver.find_element(By.ID, constants.COOKIE_COUNT_ID).text - ) - ) - - def cookies_per_second(self) -> float: - """@brief Read how many cookies the player makes each second. - - @return The cookies per second. - """ - return self._read_again_if_stale( - lambda: parse_cookies_per_second( - self._driver.find_element(By.ID, constants.COOKIES_PER_SECOND_ID).text - ) - ) - - def upgrades(self) -> list[Upgrade]: - """@brief Read the upgrades the player can see in the right-hand pane. - - @return The visible upgrades, affordable or not, in store order. - """ - return self._read_again_if_stale(self._read_upgrades) - - def _read_upgrades(self) -> list[Upgrade]: - """@brief Find the visible products and read each one.""" - products = self._driver.find_elements( - By.CSS_SELECTOR, constants.VISIBLE_PRODUCT_SELECTOR - ) - production = self._read_production() - return [self._read_upgrade(product, production) for product in products] - - def _read_production(self) -> list[float]: - """@brief Read the cookies per second of one unit of every product. - - @return The numbers by product index; empty when the page does not tell. - """ - try: - production = self._driver.execute_script(constants.JS_PRODUCTION_SCRIPT) - except JavascriptException: - return [] - return production if isinstance(production, list) else [] - - def _read_again_if_stale(self, read: Callable[[], T]) -> T: - """@brief Run a read; repeat it while the page re-renders under it. - - @throws StaleElementReferenceException When every attempt went stale. - """ - for _ in range(constants.STALE_READ_ATTEMPTS - 1): - try: - return read() - except StaleElementReferenceException: - self._sleep(constants.POLL_INTERVAL_SECONDS) - return read() - - def buy(self, upgrade: Upgrade) -> bool: - """@brief Buy an upgrade by clicking its product. - - @param upgrade The upgrade to buy. - @return True only when a click really reached the product. - """ - try: - element = self._driver.find_element(By.ID, upgrade.key) - try: - element.click() - except ElementClickInterceptedException: - # The cookie-consent banner covers the lower products. Click from - # inside the page instead; the banner itself is never clicked. - self._driver.execute_script(constants.JS_CLICK_SCRIPT, element) - except StaleElementReferenceException: - # The pane re-rendered under us; the next check retries. - return False - return True - - def save_screenshot(self, path: Path) -> None: - """@brief Write a screenshot of the game to a PNG file. - - @param path Target file; its folder is created when missing. - """ - path.parent.mkdir(parents=True, exist_ok=True) - self._driver.save_screenshot(str(path)) - - def close(self) -> None: - """@brief Close the browser.""" - self._driver.quit() - - @staticmethod - def _read_upgrade(product: ElementLike, production: Sequence[float]) -> Upgrade: - """@brief Read the id, name, price and production 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), - cookies_per_second=SeleniumGame._production_of(key, production), - ) - - @staticmethod - def _production_of(key: str, production: Sequence[float]) -> float | None: - """@brief Look up the production of the product with this element id.""" - index = key.removeprefix(constants.PRODUCT_ID_PREFIX) - if not index.isdigit() or int(index) >= len(production): - return None - value = production[int(index)] - return float(value) if isinstance(value, int | float) else None - - def _visible(self, element_id: str) -> ElementLike | None: - """@brief Return the element with this id when it exists and is displayed.""" - try: - element = self._driver.find_element(By.ID, element_id) - return element if element.is_displayed() else None - except (NoSuchElementException, StaleElementReferenceException): - # Not there yet, or the page re-rendered under us: poll again. - return None - - def _wait_for(self, locate: Callable[[], T | None]) -> T: - """@brief Poll until `locate` returns a value. - - @param locate Function that returns the value, or None while not ready. - @return The value. - @throws TimeoutError When the value is not there within the timeout. - """ - deadline = self._clock() + self._timeout - while True: - found = locate() - if found is not None: - return found - if self._clock() >= deadline: - message = f"the game page was not ready within {self._timeout} s" - raise TimeoutError(message) - self._sleep(constants.POLL_INTERVAL_SECONDS) +"""@file selenium_game.py +@brief Selenium adapter that implements the game port. +""" + +import time +from collections.abc import Callable, Sequence +from pathlib import Path +from typing import Any, Protocol, TypeVar + +from selenium.common.exceptions import ( + ElementClickInterceptedException, + JavascriptException, + NoSuchElementException, + StaleElementReferenceException, +) +from selenium.webdriver.common.by import By + +from cookie_clicker_bot import constants +from cookie_clicker_bot.domain.parsing import ( + parse_amount, + parse_cookie_count, + parse_cookies_per_second, +) +from cookie_clicker_bot.domain.upgrades import Upgrade + +T = TypeVar("T") + + +class ElementLike(Protocol): + """@brief The part of a Selenium element that the adapter uses.""" + + @property + def text(self) -> str: + """@brief Visible text of the element.""" + ... + + def click(self) -> None: + """@brief Click the element.""" + + def is_displayed(self) -> bool: + """@brief True when the element is visible.""" + ... + + def get_attribute(self, name: str) -> str | None: + """@brief Read an attribute of the element.""" + ... + + def find_element(self, by: str, value: str) -> "ElementLike": + """@brief Find one element inside this element.""" + ... + + +class DriverLike(Protocol): + """@brief The part of a Selenium driver that the adapter uses.""" + + def get(self, url: str) -> None: + """@brief Load a page.""" + + def find_element(self, by: str, value: str) -> ElementLike: + """@brief Find one element.""" + ... + + def find_elements(self, by: str, value: str) -> Sequence[ElementLike]: + """@brief Find every element that matches.""" + ... + + def save_screenshot(self, filename: str) -> bool: + """@brief Write a PNG screenshot.""" + ... + + def execute_script(self, script: str, *args: Any) -> Any: # noqa: ANN401 + """@brief Run JavaScript in the page; the arguments are `arguments[n]`.""" + ... + + def quit(self) -> None: + """@brief Close the browser.""" + + +class SeleniumGame: + """@brief Plays the game page through a Selenium driver.""" + + def __init__( + self, + driver: DriverLike, + timeout_seconds: float = constants.WAIT_TIMEOUT_SECONDS, + clock: Callable[[], float] = time.monotonic, + sleep: Callable[[float], None] = time.sleep, + ) -> None: + """@brief Wrap a driver. + + @param driver The Selenium driver (or a fake in tests). + @param timeout_seconds Longest wait for an element. + @param clock Monotonic clock in seconds. + @param sleep Pause function used between polls. + """ + self._driver = driver + self._timeout = timeout_seconds + self._clock = clock + self._sleep = sleep + + def open_game(self) -> None: + """@brief Load the game page.""" + self._driver.get(constants.GAME_URL) + + def dismiss_language(self) -> None: + """@brief Pick English, then wait until the big cookie can be clicked.""" + english = self._wait_for(lambda: self._visible(constants.LANGUAGE_ENGLISH_ID)) + english.click() + self._wait_for(lambda: self._visible(constants.BIG_COOKIE_ID)) + + def click_cookie(self) -> None: + """@brief Click the big cookie once.""" + try: + self._driver.find_element(By.ID, constants.BIG_COOKIE_ID).click() + except (ElementClickInterceptedException, StaleElementReferenceException): + # A banner or a re-render covered the cookie for a moment; try again. + return + + def cookie_count(self) -> int: + """@brief Read how many cookies the player has. + + @return The cookie count. + """ + return self._read_again_if_stale( + lambda: parse_cookie_count( + self._driver.find_element(By.ID, constants.COOKIE_COUNT_ID).text + ) + ) + + def cookies_per_second(self) -> float: + """@brief Read how many cookies the player makes each second. + + @return The cookies per second. + """ + return self._read_again_if_stale( + lambda: parse_cookies_per_second( + self._driver.find_element(By.ID, constants.COOKIES_PER_SECOND_ID).text + ) + ) + + def upgrades(self) -> list[Upgrade]: + """@brief Read the upgrades the player can see in the right-hand pane. + + @return The visible upgrades, affordable or not, in store order. + """ + return self._read_again_if_stale(self._read_upgrades) + + def _read_upgrades(self) -> list[Upgrade]: + """@brief Find the visible products and read each one.""" + products = self._driver.find_elements( + By.CSS_SELECTOR, constants.VISIBLE_PRODUCT_SELECTOR + ) + production = self._read_production() + return [self._read_upgrade(product, production) for product in products] + + def _read_production(self) -> list[float]: + """@brief Read the cookies per second of one unit of every product. + + @return The numbers by product index; empty when the page does not tell. + """ + try: + production = self._driver.execute_script(constants.JS_PRODUCTION_SCRIPT) + except JavascriptException: + return [] + return production if isinstance(production, list) else [] + + def _read_again_if_stale(self, read: Callable[[], T]) -> T: + """@brief Run a read; repeat it while the page re-renders under it. + + @throws StaleElementReferenceException When every attempt went stale. + """ + for _ in range(constants.STALE_READ_ATTEMPTS - 1): + try: + return read() + except StaleElementReferenceException: + self._sleep(constants.POLL_INTERVAL_SECONDS) + return read() + + def buy(self, upgrade: Upgrade) -> bool: + """@brief Buy an upgrade by clicking its product. + + @param upgrade The upgrade to buy. + @return True only when a click really reached the product. + """ + try: + element = self._driver.find_element(By.ID, upgrade.key) + try: + element.click() + except ElementClickInterceptedException: + # The cookie-consent banner covers the lower products. Click from + # inside the page instead; the banner itself is never clicked. + self._driver.execute_script(constants.JS_CLICK_SCRIPT, element) + except StaleElementReferenceException: + # The pane re-rendered under us; the next check retries. + return False + return True + + def save_screenshot(self, path: Path) -> None: + """@brief Write a screenshot of the game to a PNG file. + + @param path Target file; its folder is created when missing. + """ + path.parent.mkdir(parents=True, exist_ok=True) + self._driver.save_screenshot(str(path)) + + def close(self) -> None: + """@brief Close the browser.""" + self._driver.quit() + + @staticmethod + def _read_upgrade(product: ElementLike, production: Sequence[float]) -> Upgrade: + """@brief Read the id, name, price and production 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), + cookies_per_second=SeleniumGame._production_of(key, production), + ) + + @staticmethod + def _production_of(key: str, production: Sequence[float]) -> float | None: + """@brief Look up the production of the product with this element id.""" + index = key.removeprefix(constants.PRODUCT_ID_PREFIX) + if not index.isdigit() or int(index) >= len(production): + return None + value = production[int(index)] + return float(value) if isinstance(value, int | float) else None + + def _visible(self, element_id: str) -> ElementLike | None: + """@brief Return the element with this id when it exists and is displayed.""" + try: + element = self._driver.find_element(By.ID, element_id) + return element if element.is_displayed() else None + except (NoSuchElementException, StaleElementReferenceException): + # Not there yet, or the page re-rendered under us: poll again. + return None + + def _wait_for(self, locate: Callable[[], T | None]) -> T: + """@brief Poll until `locate` returns a value. + + @param locate Function that returns the value, or None while not ready. + @return The value. + @throws TimeoutError When the value is not there within the timeout. + """ + deadline = self._clock() + self._timeout + while True: + found = locate() + if found is not None: + return found + if self._clock() >= deadline: + message = f"the game page was not ready within {self._timeout} s" + raise TimeoutError(message) + self._sleep(constants.POLL_INTERVAL_SECONDS) diff --git a/tests/integration/test_selenium_game.py b/tests/integration/test_selenium_game.py index fa978a9..9c7e834 100644 --- a/tests/integration/test_selenium_game.py +++ b/tests/integration/test_selenium_game.py @@ -1,490 +1,490 @@ -"""@file test_selenium_game.py -@brief Tests of the Selenium adapter against a fake driver (no browser). -""" - -from collections.abc import Callable -from pathlib import Path -from typing import Any - -import pytest -from selenium.common.exceptions import ( - ElementClickInterceptedException, - JavascriptException, - NoSuchElementException, - StaleElementReferenceException, -) - -from cookie_clicker_bot import constants -from cookie_clicker_bot.domain.upgrades import Upgrade -from cookie_clicker_bot.infrastructure.selenium_game import SeleniumGame - - -class FakeElement: - """An element that records clicks and may raise on click.""" - - def __init__( - self, - displayed: Callable[[], bool] = lambda: True, - click_error: Exception | None = None, - text: str = "", - attributes: dict[str, str] | None = None, - children: dict[str, "FakeElement"] | None = None, - ) -> None: - self.text = text - self.clicks = 0 - self._displayed = displayed - self._click_error = click_error - self._attributes = attributes or {} - self._children = children or {} - - def get_attribute(self, name: str) -> str | None: - return self._attributes.get(name) - - def find_element(self, by: str, value: str) -> "FakeElement": - if by != "css selector" or value not in self._children: - raise NoSuchElementException(value) - return self._children[value] - - def click(self) -> None: - if self._click_error is not None: - raise self._click_error - self.clicks += 1 - - def is_displayed(self) -> bool: - return self._displayed() - - -class FakeDriver: - """A driver with elements by id.""" - - def __init__(self, elements: dict[str, FakeElement] | None = None) -> None: - self.elements = elements or {} - self.groups: dict[str, list[FakeElement]] = {} - self.loaded: list[str] = [] - self.screenshots: list[str] = [] - self.quit_called = False - self.scripts: list[tuple[str, FakeElement]] = [] - self.production: Any = [] - - def execute_script(self, script: str, *args: Any) -> Any: # noqa: ANN401 - if script == constants.JS_PRODUCTION_SCRIPT: - if isinstance(self.production, Exception): - raise self.production - return self.production - self.scripts.append((script, args[0])) - - def get(self, url: str) -> None: - self.loaded.append(url) - - def find_element(self, by: str, value: str) -> FakeElement: - if by != "id" or value not in self.elements: - raise NoSuchElementException(value) - return self.elements[value] - - def find_elements(self, by: str, value: str) -> list[FakeElement]: - if by != "css selector": - return [] - return self.groups.get(value, []) - - def save_screenshot(self, filename: str) -> bool: - self.screenshots.append(filename) - return True - - def quit(self) -> None: - self.quit_called = True - - -class FakeTime: - """A clock that only moves when the fake sleep is called.""" - - def __init__(self) -> None: - self.now = 0.0 - self.sleeps: list[float] = [] - - def clock(self) -> float: - return self.now - - def sleep(self, seconds: float) -> None: - self.sleeps.append(seconds) - self.now += seconds - - -def make_game(driver: FakeDriver, fake_time: FakeTime | None = None) -> SeleniumGame: - fake_time = fake_time or FakeTime() - return SeleniumGame( - driver, timeout_seconds=1.0, clock=fake_time.clock, sleep=fake_time.sleep - ) - - -def ready_driver() -> FakeDriver: - return FakeDriver( - { - constants.LANGUAGE_ENGLISH_ID: FakeElement(), - constants.BIG_COOKIE_ID: FakeElement(), - } - ) - - -def test_open_game_loads_the_game_address() -> None: - driver = FakeDriver() - - make_game(driver).open_game() - - assert driver.loaded == [constants.GAME_URL] - - -def test_dismiss_language_clicks_english_and_waits_for_the_cookie() -> None: - driver = ready_driver() - - make_game(driver).dismiss_language() - - assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1 - - -def test_dismiss_language_polls_until_the_button_is_visible() -> None: - fake_time = FakeTime() - driver = ready_driver() - driver.elements[constants.LANGUAGE_ENGLISH_ID] = FakeElement( - displayed=lambda: fake_time.now >= 0.4 - ) - - make_game(driver, fake_time).dismiss_language() - - assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1 - assert fake_time.sleeps == [constants.POLL_INTERVAL_SECONDS] * 2 - - -def stale_once() -> Callable[[], bool]: - """Return an `is_displayed` that goes stale on the first call only.""" - calls: list[int] = [] - - def displayed() -> bool: - calls.append(1) - if len(calls) == 1: - raise StaleElementReferenceException - return True - - return displayed - - -def test_dismiss_language_survives_a_cookie_that_goes_stale_once() -> None: - driver = ready_driver() - driver.elements[constants.BIG_COOKIE_ID] = FakeElement(displayed=stale_once()) - - make_game(driver).dismiss_language() - - assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1 - - -def test_dismiss_language_survives_a_button_that_goes_stale_once() -> None: - driver = ready_driver() - driver.elements[constants.LANGUAGE_ENGLISH_ID] = FakeElement(displayed=stale_once()) - - make_game(driver).dismiss_language() - - assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1 - - -def test_dismiss_language_survives_a_lookup_that_goes_stale_once() -> None: - driver = ready_driver() - real_find = driver.find_element - calls: list[int] = [] - - def find_element(by: str, value: str) -> FakeElement: - calls.append(1) - if len(calls) == 1: - raise StaleElementReferenceException - return real_find(by, value) - - driver.find_element = find_element # type: ignore[method-assign] - - make_game(driver).dismiss_language() - - assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1 - - -def test_dismiss_language_times_out_when_the_button_never_shows() -> None: - fake_time = FakeTime() - - with pytest.raises(TimeoutError): - make_game(FakeDriver(), fake_time).dismiss_language() - - assert fake_time.now >= 1.0 - - -def test_dismiss_language_times_out_when_the_cookie_never_shows() -> None: - driver = FakeDriver({constants.LANGUAGE_ENGLISH_ID: FakeElement()}) - - with pytest.raises(TimeoutError): - make_game(driver).dismiss_language() - - -def test_click_cookie_clicks_the_big_cookie() -> None: - driver = ready_driver() - - make_game(driver).click_cookie() - - assert driver.elements[constants.BIG_COOKIE_ID].clicks == 1 - - -@pytest.mark.parametrize( - "error", - [ElementClickInterceptedException(), StaleElementReferenceException()], -) -def test_click_cookie_ignores_a_covered_or_stale_cookie(error: Exception) -> None: - driver = FakeDriver({constants.BIG_COOKIE_ID: FakeElement(click_error=error)}) - - make_game(driver).click_cookie() - - -def make_product(key: str, name: str, price: str) -> FakeElement: - return FakeElement( - attributes={"id": key}, - children={ - constants.PRODUCT_NAME_SELECTOR: FakeElement(text=name), - constants.PRODUCT_PRICE_SELECTOR: FakeElement(text=price), - }, - ) - - -def test_cookie_count_reads_the_cookie_counter() -> None: - driver = FakeDriver( - { - constants.COOKIE_COUNT_ID: FakeElement( - text="1.235\nmillion cookies\nper second: 0" - ) - } - ) - - assert make_game(driver).cookie_count() == 1_235_000 - - -def test_cookies_per_second_reads_its_element() -> None: - driver = FakeDriver( - {constants.COOKIES_PER_SECOND_ID: FakeElement(text="per second: 1,234.5")} - ) - - assert make_game(driver).cookies_per_second() == 1234.5 - - -def test_upgrades_reads_the_visible_products() -> None: - driver = FakeDriver() - driver.groups[constants.VISIBLE_PRODUCT_SELECTOR] = [ - make_product("product0", "Cursor", "15"), - make_product("product2", "Farm", "1,100"), - ] - - assert make_game(driver).upgrades() == [ - Upgrade("product0", "Cursor", 15), - Upgrade("product2", "Farm", 1100), - ] - - -class StaleOnceText(FakeElement): - """An element whose text goes stale on the first read.""" - - def __init__(self, text: str) -> None: - super().__init__() - self._text = text - self._reads = 0 - - @property - def text(self) -> str: - self._reads += 1 - if self._reads == 1: - raise StaleElementReferenceException - return self._text - - @text.setter - def text(self, value: str) -> None: - self._text = value - - -def test_cookie_count_retries_once_when_the_counter_goes_stale() -> None: - driver = FakeDriver({constants.COOKIE_COUNT_ID: StaleOnceText("42 cookies")}) - - assert make_game(driver).cookie_count() == 42 - - -def test_cookies_per_second_retries_once_when_the_element_goes_stale() -> None: - driver = FakeDriver( - {constants.COOKIES_PER_SECOND_ID: StaleOnceText("per second: 3.5")} - ) - - assert make_game(driver).cookies_per_second() == 3.5 - - -def test_upgrades_retries_once_when_a_product_goes_stale() -> None: - product = make_product("product0", "Cursor", "15") - product.get_attribute = _stale_once_then(product.get_attribute) # type: ignore[assignment] - driver = FakeDriver() - driver.groups[constants.VISIBLE_PRODUCT_SELECTOR] = [product] - - assert make_game(driver).upgrades() == [Upgrade("product0", "Cursor", 15)] - - -def test_cookies_per_second_succeeds_after_four_stale_attempts() -> None: - driver = FakeDriver() - attempts = _stale_for(4, FakeElement(text="per second: 3.5")) - driver.find_element = attempts.find_element # type: ignore[method-assign] - fake_time = FakeTime() - - assert make_game(driver, fake_time).cookies_per_second() == 3.5 - assert attempts.calls == 5 - assert fake_time.sleeps == [constants.POLL_INTERVAL_SECONDS] * 4 - - -def test_cookie_count_gives_up_when_the_counter_stays_stale() -> None: - driver = FakeDriver() - attempts = _stale_for(100, FakeElement(text="42 cookies")) - driver.find_element = attempts.find_element # type: ignore[method-assign] - fake_time = FakeTime() - - with pytest.raises(StaleElementReferenceException): - make_game(driver, fake_time).cookie_count() - - assert attempts.calls == constants.STALE_READ_ATTEMPTS - assert fake_time.sleeps == [constants.POLL_INTERVAL_SECONDS] * ( - constants.STALE_READ_ATTEMPTS - 1 - ) - - -class _StaleFor: - """A `find_element` that goes stale for the first `stale` calls.""" - - def __init__(self, stale: int, element: FakeElement) -> None: - self._stale = stale - self._element = element - self.calls = 0 - - def find_element(self, by: str, value: str) -> FakeElement: - self.calls += 1 - if self.calls <= self._stale: - raise StaleElementReferenceException - return self._element - - -def _stale_for(stale: int, element: FakeElement) -> _StaleFor: - return _StaleFor(stale, element) - - -def _stale_once_then( - original: Callable[[str], str | None], -) -> Callable[[str], str | None]: - calls: list[int] = [] - - def get_attribute(name: str) -> str | None: - calls.append(1) - if len(calls) == 1: - raise StaleElementReferenceException - return original(name) - - return get_attribute - - -def test_upgrades_is_empty_when_no_product_is_visible() -> None: - assert make_game(FakeDriver()).upgrades() == [] - - -def test_buy_clicks_the_product_with_the_upgrade_key() -> None: - driver = FakeDriver({"product1": FakeElement()}) - - bought = make_game(driver).buy(Upgrade("product1", "Grandma", 100)) - - assert bought is True - assert driver.elements["product1"].clicks == 1 - assert driver.scripts == [] - - -def test_buy_clicks_through_javascript_when_a_banner_covers_the_product() -> None: - element = FakeElement(click_error=ElementClickInterceptedException()) - driver = FakeDriver({"product1": element}) - - bought = make_game(driver).buy(Upgrade("product1", "Grandma", 100)) - - assert bought is True - assert driver.scripts == [(constants.JS_CLICK_SCRIPT, element)] - - -def test_buy_returns_false_for_a_stale_product() -> None: - driver = FakeDriver( - {"product1": FakeElement(click_error=StaleElementReferenceException())} - ) - - assert make_game(driver).buy(Upgrade("product1", "Grandma", 100)) is False - assert driver.scripts == [] - - -def test_save_screenshot_creates_the_folder_and_calls_the_driver( - tmp_path: Path, -) -> None: - driver = FakeDriver() - target = tmp_path / "images" / "shot.png" - - make_game(driver).save_screenshot(target) - - assert target.parent.is_dir() - assert driver.screenshots == [str(target)] - - -def test_close_quits_the_driver() -> None: - driver = FakeDriver() - - make_game(driver).close() - - assert driver.quit_called - - -def _driver_with_two_products() -> FakeDriver: - driver = FakeDriver() - driver.groups[constants.VISIBLE_PRODUCT_SELECTOR] = [ - make_product("product0", "Cursor", "15"), - make_product("product1", "Grandma", "100"), - ] - return driver - - -def test_upgrades_reads_the_production_of_each_product() -> None: - driver = _driver_with_two_products() - driver.production = [0.1, 1] - - assert make_game(driver).upgrades() == [ - Upgrade("product0", "Cursor", 15, 0.1), - Upgrade("product1", "Grandma", 100, 1.0), - ] - - -def test_upgrades_leaves_the_production_unknown_when_the_script_fails() -> None: - driver = _driver_with_two_products() - driver.production = JavascriptException("Game is not defined") - - upgrades = make_game(driver).upgrades() - - assert [upgrade.cookies_per_second for upgrade in upgrades] == [None, None] - - -def test_upgrades_leaves_the_production_unknown_for_a_product_the_list_lacks() -> None: - driver = _driver_with_two_products() - driver.production = [0.1] - - upgrades = make_game(driver).upgrades() - - assert [upgrade.cookies_per_second for upgrade in upgrades] == [0.1, None] - - -def test_upgrades_leaves_a_production_that_is_not_a_number_unknown() -> None: - driver = _driver_with_two_products() - driver.production = [{}, 1] - - upgrades = make_game(driver).upgrades() - - assert [upgrade.cookies_per_second for upgrade in upgrades] == [None, 1.0] - - -def test_upgrades_leaves_the_production_unknown_for_an_unexpected_answer() -> None: - driver = _driver_with_two_products() - driver.production = None - - upgrades = make_game(driver).upgrades() - - assert [upgrade.cookies_per_second for upgrade in upgrades] == [None, None] +"""@file test_selenium_game.py +@brief Tests of the Selenium adapter against a fake driver (no browser). +""" + +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import pytest +from selenium.common.exceptions import ( + ElementClickInterceptedException, + JavascriptException, + NoSuchElementException, + StaleElementReferenceException, +) + +from cookie_clicker_bot import constants +from cookie_clicker_bot.domain.upgrades import Upgrade +from cookie_clicker_bot.infrastructure.selenium_game import SeleniumGame + + +class FakeElement: + """An element that records clicks and may raise on click.""" + + def __init__( + self, + displayed: Callable[[], bool] = lambda: True, + click_error: Exception | None = None, + text: str = "", + attributes: dict[str, str] | None = None, + children: dict[str, "FakeElement"] | None = None, + ) -> None: + self.text = text + self.clicks = 0 + self._displayed = displayed + self._click_error = click_error + self._attributes = attributes or {} + self._children = children or {} + + def get_attribute(self, name: str) -> str | None: + return self._attributes.get(name) + + def find_element(self, by: str, value: str) -> "FakeElement": + if by != "css selector" or value not in self._children: + raise NoSuchElementException(value) + return self._children[value] + + def click(self) -> None: + if self._click_error is not None: + raise self._click_error + self.clicks += 1 + + def is_displayed(self) -> bool: + return self._displayed() + + +class FakeDriver: + """A driver with elements by id.""" + + def __init__(self, elements: dict[str, FakeElement] | None = None) -> None: + self.elements = elements or {} + self.groups: dict[str, list[FakeElement]] = {} + self.loaded: list[str] = [] + self.screenshots: list[str] = [] + self.quit_called = False + self.scripts: list[tuple[str, FakeElement]] = [] + self.production: Any = [] + + def execute_script(self, script: str, *args: Any) -> Any: # noqa: ANN401 + if script == constants.JS_PRODUCTION_SCRIPT: + if isinstance(self.production, Exception): + raise self.production + return self.production + self.scripts.append((script, args[0])) + + def get(self, url: str) -> None: + self.loaded.append(url) + + def find_element(self, by: str, value: str) -> FakeElement: + if by != "id" or value not in self.elements: + raise NoSuchElementException(value) + return self.elements[value] + + def find_elements(self, by: str, value: str) -> list[FakeElement]: + if by != "css selector": + return [] + return self.groups.get(value, []) + + def save_screenshot(self, filename: str) -> bool: + self.screenshots.append(filename) + return True + + def quit(self) -> None: + self.quit_called = True + + +class FakeTime: + """A clock that only moves when the fake sleep is called.""" + + def __init__(self) -> None: + self.now = 0.0 + self.sleeps: list[float] = [] + + def clock(self) -> float: + return self.now + + def sleep(self, seconds: float) -> None: + self.sleeps.append(seconds) + self.now += seconds + + +def make_game(driver: FakeDriver, fake_time: FakeTime | None = None) -> SeleniumGame: + fake_time = fake_time or FakeTime() + return SeleniumGame( + driver, timeout_seconds=1.0, clock=fake_time.clock, sleep=fake_time.sleep + ) + + +def ready_driver() -> FakeDriver: + return FakeDriver( + { + constants.LANGUAGE_ENGLISH_ID: FakeElement(), + constants.BIG_COOKIE_ID: FakeElement(), + } + ) + + +def test_open_game_loads_the_game_address() -> None: + driver = FakeDriver() + + make_game(driver).open_game() + + assert driver.loaded == [constants.GAME_URL] + + +def test_dismiss_language_clicks_english_and_waits_for_the_cookie() -> None: + driver = ready_driver() + + make_game(driver).dismiss_language() + + assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1 + + +def test_dismiss_language_polls_until_the_button_is_visible() -> None: + fake_time = FakeTime() + driver = ready_driver() + driver.elements[constants.LANGUAGE_ENGLISH_ID] = FakeElement( + displayed=lambda: fake_time.now >= 0.4 + ) + + make_game(driver, fake_time).dismiss_language() + + assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1 + assert fake_time.sleeps == [constants.POLL_INTERVAL_SECONDS] * 2 + + +def stale_once() -> Callable[[], bool]: + """Return an `is_displayed` that goes stale on the first call only.""" + calls: list[int] = [] + + def displayed() -> bool: + calls.append(1) + if len(calls) == 1: + raise StaleElementReferenceException + return True + + return displayed + + +def test_dismiss_language_survives_a_cookie_that_goes_stale_once() -> None: + driver = ready_driver() + driver.elements[constants.BIG_COOKIE_ID] = FakeElement(displayed=stale_once()) + + make_game(driver).dismiss_language() + + assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1 + + +def test_dismiss_language_survives_a_button_that_goes_stale_once() -> None: + driver = ready_driver() + driver.elements[constants.LANGUAGE_ENGLISH_ID] = FakeElement(displayed=stale_once()) + + make_game(driver).dismiss_language() + + assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1 + + +def test_dismiss_language_survives_a_lookup_that_goes_stale_once() -> None: + driver = ready_driver() + real_find = driver.find_element + calls: list[int] = [] + + def find_element(by: str, value: str) -> FakeElement: + calls.append(1) + if len(calls) == 1: + raise StaleElementReferenceException + return real_find(by, value) + + driver.find_element = find_element # type: ignore[method-assign] + + make_game(driver).dismiss_language() + + assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1 + + +def test_dismiss_language_times_out_when_the_button_never_shows() -> None: + fake_time = FakeTime() + + with pytest.raises(TimeoutError): + make_game(FakeDriver(), fake_time).dismiss_language() + + assert fake_time.now >= 1.0 + + +def test_dismiss_language_times_out_when_the_cookie_never_shows() -> None: + driver = FakeDriver({constants.LANGUAGE_ENGLISH_ID: FakeElement()}) + + with pytest.raises(TimeoutError): + make_game(driver).dismiss_language() + + +def test_click_cookie_clicks_the_big_cookie() -> None: + driver = ready_driver() + + make_game(driver).click_cookie() + + assert driver.elements[constants.BIG_COOKIE_ID].clicks == 1 + + +@pytest.mark.parametrize( + "error", + [ElementClickInterceptedException(), StaleElementReferenceException()], +) +def test_click_cookie_ignores_a_covered_or_stale_cookie(error: Exception) -> None: + driver = FakeDriver({constants.BIG_COOKIE_ID: FakeElement(click_error=error)}) + + make_game(driver).click_cookie() + + +def make_product(key: str, name: str, price: str) -> FakeElement: + return FakeElement( + attributes={"id": key}, + children={ + constants.PRODUCT_NAME_SELECTOR: FakeElement(text=name), + constants.PRODUCT_PRICE_SELECTOR: FakeElement(text=price), + }, + ) + + +def test_cookie_count_reads_the_cookie_counter() -> None: + driver = FakeDriver( + { + constants.COOKIE_COUNT_ID: FakeElement( + text="1.235\nmillion cookies\nper second: 0" + ) + } + ) + + assert make_game(driver).cookie_count() == 1_235_000 + + +def test_cookies_per_second_reads_its_element() -> None: + driver = FakeDriver( + {constants.COOKIES_PER_SECOND_ID: FakeElement(text="per second: 1,234.5")} + ) + + assert make_game(driver).cookies_per_second() == 1234.5 + + +def test_upgrades_reads_the_visible_products() -> None: + driver = FakeDriver() + driver.groups[constants.VISIBLE_PRODUCT_SELECTOR] = [ + make_product("product0", "Cursor", "15"), + make_product("product2", "Farm", "1,100"), + ] + + assert make_game(driver).upgrades() == [ + Upgrade("product0", "Cursor", 15), + Upgrade("product2", "Farm", 1100), + ] + + +class StaleOnceText(FakeElement): + """An element whose text goes stale on the first read.""" + + def __init__(self, text: str) -> None: + super().__init__() + self._text = text + self._reads = 0 + + @property + def text(self) -> str: + self._reads += 1 + if self._reads == 1: + raise StaleElementReferenceException + return self._text + + @text.setter + def text(self, value: str) -> None: + self._text = value + + +def test_cookie_count_retries_once_when_the_counter_goes_stale() -> None: + driver = FakeDriver({constants.COOKIE_COUNT_ID: StaleOnceText("42 cookies")}) + + assert make_game(driver).cookie_count() == 42 + + +def test_cookies_per_second_retries_once_when_the_element_goes_stale() -> None: + driver = FakeDriver( + {constants.COOKIES_PER_SECOND_ID: StaleOnceText("per second: 3.5")} + ) + + assert make_game(driver).cookies_per_second() == 3.5 + + +def test_upgrades_retries_once_when_a_product_goes_stale() -> None: + product = make_product("product0", "Cursor", "15") + product.get_attribute = _stale_once_then(product.get_attribute) # type: ignore[assignment] + driver = FakeDriver() + driver.groups[constants.VISIBLE_PRODUCT_SELECTOR] = [product] + + assert make_game(driver).upgrades() == [Upgrade("product0", "Cursor", 15)] + + +def test_cookies_per_second_succeeds_after_four_stale_attempts() -> None: + driver = FakeDriver() + attempts = _stale_for(4, FakeElement(text="per second: 3.5")) + driver.find_element = attempts.find_element # type: ignore[method-assign] + fake_time = FakeTime() + + assert make_game(driver, fake_time).cookies_per_second() == 3.5 + assert attempts.calls == 5 + assert fake_time.sleeps == [constants.POLL_INTERVAL_SECONDS] * 4 + + +def test_cookie_count_gives_up_when_the_counter_stays_stale() -> None: + driver = FakeDriver() + attempts = _stale_for(100, FakeElement(text="42 cookies")) + driver.find_element = attempts.find_element # type: ignore[method-assign] + fake_time = FakeTime() + + with pytest.raises(StaleElementReferenceException): + make_game(driver, fake_time).cookie_count() + + assert attempts.calls == constants.STALE_READ_ATTEMPTS + assert fake_time.sleeps == [constants.POLL_INTERVAL_SECONDS] * ( + constants.STALE_READ_ATTEMPTS - 1 + ) + + +class _StaleFor: + """A `find_element` that goes stale for the first `stale` calls.""" + + def __init__(self, stale: int, element: FakeElement) -> None: + self._stale = stale + self._element = element + self.calls = 0 + + def find_element(self, by: str, value: str) -> FakeElement: + self.calls += 1 + if self.calls <= self._stale: + raise StaleElementReferenceException + return self._element + + +def _stale_for(stale: int, element: FakeElement) -> _StaleFor: + return _StaleFor(stale, element) + + +def _stale_once_then( + original: Callable[[str], str | None], +) -> Callable[[str], str | None]: + calls: list[int] = [] + + def get_attribute(name: str) -> str | None: + calls.append(1) + if len(calls) == 1: + raise StaleElementReferenceException + return original(name) + + return get_attribute + + +def test_upgrades_is_empty_when_no_product_is_visible() -> None: + assert make_game(FakeDriver()).upgrades() == [] + + +def test_buy_clicks_the_product_with_the_upgrade_key() -> None: + driver = FakeDriver({"product1": FakeElement()}) + + bought = make_game(driver).buy(Upgrade("product1", "Grandma", 100)) + + assert bought is True + assert driver.elements["product1"].clicks == 1 + assert driver.scripts == [] + + +def test_buy_clicks_through_javascript_when_a_banner_covers_the_product() -> None: + element = FakeElement(click_error=ElementClickInterceptedException()) + driver = FakeDriver({"product1": element}) + + bought = make_game(driver).buy(Upgrade("product1", "Grandma", 100)) + + assert bought is True + assert driver.scripts == [(constants.JS_CLICK_SCRIPT, element)] + + +def test_buy_returns_false_for_a_stale_product() -> None: + driver = FakeDriver( + {"product1": FakeElement(click_error=StaleElementReferenceException())} + ) + + assert make_game(driver).buy(Upgrade("product1", "Grandma", 100)) is False + assert driver.scripts == [] + + +def test_save_screenshot_creates_the_folder_and_calls_the_driver( + tmp_path: Path, +) -> None: + driver = FakeDriver() + target = tmp_path / "images" / "shot.png" + + make_game(driver).save_screenshot(target) + + assert target.parent.is_dir() + assert driver.screenshots == [str(target)] + + +def test_close_quits_the_driver() -> None: + driver = FakeDriver() + + make_game(driver).close() + + assert driver.quit_called + + +def _driver_with_two_products() -> FakeDriver: + driver = FakeDriver() + driver.groups[constants.VISIBLE_PRODUCT_SELECTOR] = [ + make_product("product0", "Cursor", "15"), + make_product("product1", "Grandma", "100"), + ] + return driver + + +def test_upgrades_reads_the_production_of_each_product() -> None: + driver = _driver_with_two_products() + driver.production = [0.1, 1] + + assert make_game(driver).upgrades() == [ + Upgrade("product0", "Cursor", 15, 0.1), + Upgrade("product1", "Grandma", 100, 1.0), + ] + + +def test_upgrades_leaves_the_production_unknown_when_the_script_fails() -> None: + driver = _driver_with_two_products() + driver.production = JavascriptException("Game is not defined") + + upgrades = make_game(driver).upgrades() + + assert [upgrade.cookies_per_second for upgrade in upgrades] == [None, None] + + +def test_upgrades_leaves_the_production_unknown_for_a_product_the_list_lacks() -> None: + driver = _driver_with_two_products() + driver.production = [0.1] + + upgrades = make_game(driver).upgrades() + + assert [upgrade.cookies_per_second for upgrade in upgrades] == [0.1, None] + + +def test_upgrades_leaves_a_production_that_is_not_a_number_unknown() -> None: + driver = _driver_with_two_products() + driver.production = [{}, 1] + + upgrades = make_game(driver).upgrades() + + assert [upgrade.cookies_per_second for upgrade in upgrades] == [None, 1.0] + + +def test_upgrades_leaves_the_production_unknown_for_an_unexpected_answer() -> None: + driver = _driver_with_two_products() + driver.production = None + + upgrades = make_game(driver).upgrades() + + assert [upgrade.cookies_per_second for upgrade in upgrades] == [None, None] diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index dd946d1..56308f2 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -1,241 +1,241 @@ -"""@file test_cli.py -@brief Tests of the command line and the run sequence with a fake game. -""" - -from pathlib import Path - -import pytest - -from cookie_clicker_bot import constants -from cookie_clicker_bot.application.ports import GamePort -from cookie_clicker_bot.cli import main, parse_args -from cookie_clicker_bot.domain.upgrades import Upgrade - - -class RecordingGame: - """A game that records every call in order.""" - - def __init__(self, fail_on_open: bool = False, buy_succeeds: bool = True) -> None: - self._buy_succeeds = buy_succeeds - self.calls: list[str] = [] - self.screenshots: list[Path] = [] - self._fail_on_open = fail_on_open - - def open_game(self) -> None: - self.calls.append("open_game") - if self._fail_on_open: - message = "boom" - raise RuntimeError(message) - - def dismiss_language(self) -> None: - self.calls.append("dismiss_language") - - def click_cookie(self) -> None: - self.calls.append("click_cookie") - - def cookie_count(self) -> int: - return 1000 - - def cookies_per_second(self) -> float: - return 8.5 - - def upgrades(self) -> list[Upgrade]: - return [Upgrade("product0", "Cursor", 15)] - - def buy(self, upgrade: Upgrade) -> bool: - self.calls.append("buy") - return self._buy_succeeds - - def save_screenshot(self, path: Path) -> None: - self.calls.append("save_screenshot") - self.screenshots.append(path) - - def close(self) -> None: - self.calls.append("close") - - -def test_parse_args_uses_defaults() -> None: - args = parse_args([]) - - assert args.duration == constants.RUN_DURATION_SECONDS == 300 - assert args.interval == constants.UPGRADE_CHECK_INTERVAL_SECONDS == 5 - assert args.headless is False - assert args.screenshot_start is None - assert args.screenshot is None - - -def test_parse_args_reads_every_option() -> None: - args = parse_args( - [ - "--headless", - "--duration", - "10", - "--interval", - "2.5", - "--screenshot-start", - "a.png", - "--screenshot", - "b.png", - ] - ) - - assert args.headless is True - assert args.duration == 10 - assert args.interval == 2.5 - assert args.screenshot_start == Path("a.png") - assert args.screenshot == Path("b.png") - - -def test_main_opens_dismisses_and_closes() -> None: - game = RecordingGame() - - code = main(["--duration", "0"], lambda _headless: game) - - assert code == 0 - assert game.calls == ["open_game", "dismiss_language", "close"] - - -def test_main_passes_headless_to_the_factory() -> None: - seen: list[bool] = [] - - def factory(headless: bool) -> GamePort: - seen.append(headless) - return RecordingGame() - - main(["--headless", "--duration", "0"], factory) - - assert seen == [True] - - -def test_main_takes_both_screenshots_when_asked() -> None: - game = RecordingGame() - - main( - ["--duration", "0", "--screenshot-start", "s.png", "--screenshot", "e.png"], - lambda _headless: game, - ) - - assert game.screenshots == [Path("s.png"), Path("e.png")] - assert game.calls[-1] == "close" - - -def test_main_takes_no_screenshot_by_default() -> None: - game = RecordingGame() - - main(["--duration", "0"], lambda _headless: game) - - assert game.screenshots == [] - - -def test_main_closes_the_game_when_an_error_is_raised() -> None: - game = RecordingGame(fail_on_open=True) - - with pytest.raises(RuntimeError, match="boom"): - main(["--duration", "0"], lambda _headless: game) - - assert game.calls == ["open_game", "close"] - - -def test_main_prints_exactly_one_result_line_on_stdout( - capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture -) -> None: - caplog.set_level("INFO") - - main(["--duration", "0"], lambda _headless: RecordingGame()) - - out = capsys.readouterr().out - assert out == "cookies/second: 8.5" + chr(10) - assert "clicked the cookie" not in out - assert "clicked the cookie" in caplog.text - - -def test_main_logs_bought_only_for_real_purchases( - caplog: pytest.LogCaptureFixture, -) -> None: - caplog.set_level("INFO") - - main(["--duration", "0.05", "--interval", "0.001"], lambda _h: RecordingGame()) - assert "bought Cursor" in caplog.text - - caplog.clear() - game = RecordingGame(buy_succeeds=False) - main(["--duration", "0.05", "--interval", "0.001"], lambda _h: game) - assert "buy" in game.calls - assert "bought Cursor" not in caplog.text - assert "bought 0 upgrades" in caplog.text - - -def test_main_prints_nothing_on_stdout_when_the_run_fails( - capsys: pytest.CaptureFixture[str], -) -> None: - with pytest.raises(RuntimeError, match="boom"): - main(["--duration", "0"], lambda _headless: RecordingGame(fail_on_open=True)) - - assert capsys.readouterr().out == "" - - -def test_parse_args_buys_one_upgrade_unless_buy_all_is_given() -> None: - assert parse_args([]).buy_all is False - assert parse_args(["--buy-all"]).buy_all is True - - -def test_parse_args_best_value_is_off_by_default() -> None: - assert parse_args([]).best_value is False - assert parse_args(["--best-value"]).best_value is True - - -def test_parse_args_rejects_buy_all_together_with_best_value( - capsys: pytest.CaptureFixture[str], -) -> None: - with pytest.raises(SystemExit) as exit_info: - parse_args(["--buy-all", "--best-value"]) - - assert exit_info.value.code == 2 - assert "not allowed with" in capsys.readouterr().err - - -def test_main_logs_the_best_value_strategy( - caplog: pytest.LogCaptureFixture, -) -> None: - caplog.set_level("INFO") - - main(["--duration", "0", "--best-value"], lambda _h: RecordingGame()) - - assert "strategy best-value" in caplog.text - - -@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 +"""@file test_cli.py +@brief Tests of the command line and the run sequence with a fake game. +""" + +from pathlib import Path + +import pytest + +from cookie_clicker_bot import constants +from cookie_clicker_bot.application.ports import GamePort +from cookie_clicker_bot.cli import main, parse_args +from cookie_clicker_bot.domain.upgrades import Upgrade + + +class RecordingGame: + """A game that records every call in order.""" + + def __init__(self, fail_on_open: bool = False, buy_succeeds: bool = True) -> None: + self._buy_succeeds = buy_succeeds + self.calls: list[str] = [] + self.screenshots: list[Path] = [] + self._fail_on_open = fail_on_open + + def open_game(self) -> None: + self.calls.append("open_game") + if self._fail_on_open: + message = "boom" + raise RuntimeError(message) + + def dismiss_language(self) -> None: + self.calls.append("dismiss_language") + + def click_cookie(self) -> None: + self.calls.append("click_cookie") + + def cookie_count(self) -> int: + return 1000 + + def cookies_per_second(self) -> float: + return 8.5 + + def upgrades(self) -> list[Upgrade]: + return [Upgrade("product0", "Cursor", 15)] + + def buy(self, upgrade: Upgrade) -> bool: + self.calls.append("buy") + return self._buy_succeeds + + def save_screenshot(self, path: Path) -> None: + self.calls.append("save_screenshot") + self.screenshots.append(path) + + def close(self) -> None: + self.calls.append("close") + + +def test_parse_args_uses_defaults() -> None: + args = parse_args([]) + + assert args.duration == constants.RUN_DURATION_SECONDS == 300 + assert args.interval == constants.UPGRADE_CHECK_INTERVAL_SECONDS == 5 + assert args.headless is False + assert args.screenshot_start is None + assert args.screenshot is None + + +def test_parse_args_reads_every_option() -> None: + args = parse_args( + [ + "--headless", + "--duration", + "10", + "--interval", + "2.5", + "--screenshot-start", + "a.png", + "--screenshot", + "b.png", + ] + ) + + assert args.headless is True + assert args.duration == 10 + assert args.interval == 2.5 + assert args.screenshot_start == Path("a.png") + assert args.screenshot == Path("b.png") + + +def test_main_opens_dismisses_and_closes() -> None: + game = RecordingGame() + + code = main(["--duration", "0"], lambda _headless: game) + + assert code == 0 + assert game.calls == ["open_game", "dismiss_language", "close"] + + +def test_main_passes_headless_to_the_factory() -> None: + seen: list[bool] = [] + + def factory(headless: bool) -> GamePort: + seen.append(headless) + return RecordingGame() + + main(["--headless", "--duration", "0"], factory) + + assert seen == [True] + + +def test_main_takes_both_screenshots_when_asked() -> None: + game = RecordingGame() + + main( + ["--duration", "0", "--screenshot-start", "s.png", "--screenshot", "e.png"], + lambda _headless: game, + ) + + assert game.screenshots == [Path("s.png"), Path("e.png")] + assert game.calls[-1] == "close" + + +def test_main_takes_no_screenshot_by_default() -> None: + game = RecordingGame() + + main(["--duration", "0"], lambda _headless: game) + + assert game.screenshots == [] + + +def test_main_closes_the_game_when_an_error_is_raised() -> None: + game = RecordingGame(fail_on_open=True) + + with pytest.raises(RuntimeError, match="boom"): + main(["--duration", "0"], lambda _headless: game) + + assert game.calls == ["open_game", "close"] + + +def test_main_prints_exactly_one_result_line_on_stdout( + capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level("INFO") + + main(["--duration", "0"], lambda _headless: RecordingGame()) + + out = capsys.readouterr().out + assert out == "cookies/second: 8.5" + chr(10) + assert "clicked the cookie" not in out + assert "clicked the cookie" in caplog.text + + +def test_main_logs_bought_only_for_real_purchases( + caplog: pytest.LogCaptureFixture, +) -> None: + caplog.set_level("INFO") + + main(["--duration", "0.05", "--interval", "0.001"], lambda _h: RecordingGame()) + assert "bought Cursor" in caplog.text + + caplog.clear() + game = RecordingGame(buy_succeeds=False) + main(["--duration", "0.05", "--interval", "0.001"], lambda _h: game) + assert "buy" in game.calls + assert "bought Cursor" not in caplog.text + assert "bought 0 upgrades" in caplog.text + + +def test_main_prints_nothing_on_stdout_when_the_run_fails( + capsys: pytest.CaptureFixture[str], +) -> None: + with pytest.raises(RuntimeError, match="boom"): + main(["--duration", "0"], lambda _headless: RecordingGame(fail_on_open=True)) + + assert capsys.readouterr().out == "" + + +def test_parse_args_buys_one_upgrade_unless_buy_all_is_given() -> None: + assert parse_args([]).buy_all is False + assert parse_args(["--buy-all"]).buy_all is True + + +def test_parse_args_best_value_is_off_by_default() -> None: + assert parse_args([]).best_value is False + assert parse_args(["--best-value"]).best_value is True + + +def test_parse_args_rejects_buy_all_together_with_best_value( + capsys: pytest.CaptureFixture[str], +) -> None: + with pytest.raises(SystemExit) as exit_info: + parse_args(["--buy-all", "--best-value"]) + + assert exit_info.value.code == 2 + assert "not allowed with" in capsys.readouterr().err + + +def test_main_logs_the_best_value_strategy( + caplog: pytest.LogCaptureFixture, +) -> None: + caplog.set_level("INFO") + + main(["--duration", "0", "--best-value"], lambda _h: RecordingGame()) + + assert "strategy best-value" in caplog.text + + +@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 diff --git a/tests/unit/test_game_loop.py b/tests/unit/test_game_loop.py index d53b3e8..752f653 100644 --- a/tests/unit/test_game_loop.py +++ b/tests/unit/test_game_loop.py @@ -1,282 +1,282 @@ -"""@file test_game_loop.py -@brief Tests of the game loop with a fake game and a fake clock. -""" - -from pathlib import Path - -from cookie_clicker_bot.application.game_loop import ( - PlayResult, - buy_best_value_upgrade, - 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 - -VALUE_CURSOR = Upgrade("product0", "Cursor", 15, 0.1) -VALUE_GRANDMA = Upgrade("product1", "Grandma", 100, 1.0) - -CURSOR = Upgrade("product0", "Cursor", 15) -GRANDMA = Upgrade("product1", "Grandma", 100) - - -class FakeClock: - """A clock that only moves when the fake game is clicked.""" - - def __init__(self) -> None: - self.now = 0.0 - - def __call__(self) -> float: - return self.now - - -class FakeGame: - """A game with a cookie count and upgrades; each click takes one second.""" - - def __init__( - self, - cookies: int = 0, - upgrades: list[Upgrade] | None = None, - clock: FakeClock | None = None, - buy_succeeds: bool = True, - ) -> None: - self.buy_succeeds = buy_succeeds - self.clicks = 0 - self.cookies = cookies - self.available = upgrades or [] - self.bought: list[Upgrade] = [] - self.buy_times: list[float] = [] - self._clock = clock - - def open_game(self) -> None: - pass - - def dismiss_language(self) -> None: - pass - - def click_cookie(self) -> None: - self.clicks += 1 - if self._clock is not None: - self._clock.now += 1.0 - - def cookie_count(self) -> int: - return self.cookies - - def cookies_per_second(self) -> float: - return 0.0 - - def upgrades(self) -> list[Upgrade]: - return self.available - - def buy(self, upgrade: Upgrade) -> bool: - self.bought.append(upgrade) - if self._clock is not None: - self.buy_times.append(self._clock.now) - return self.buy_succeeds - - def save_screenshot(self, path: Path) -> None: - pass - - def close(self) -> None: - pass - - -def test_click_cookie_delegates_to_the_game() -> None: - game = FakeGame() - - click_cookie(game) - - assert game.clicks == 1 - - -def test_buy_most_expensive_upgrade_buys_the_best_affordable() -> None: - game = FakeGame(cookies=105, upgrades=[CURSOR, GRANDMA]) - - bought = buy_most_expensive_upgrade(game) - - assert bought == GRANDMA - assert game.bought == [GRANDMA] - - -def test_buy_most_expensive_upgrade_buys_nothing_when_too_poor() -> None: - game = FakeGame(cookies=10, upgrades=[CURSOR, GRANDMA]) - - assert buy_most_expensive_upgrade(game) is None - assert game.bought == [] - - -def test_click_and_buy_checks_the_upgrades_every_interval() -> None: - clock = FakeClock() - game = FakeGame(cookies=105, upgrades=[CURSOR, GRANDMA], clock=clock) - - result = click_and_buy(game, 12, 5, clock) - - assert result == PlayResult( - clicks=12, purchases=("Grandma", "Grandma"), elapsed_seconds=12.0 - ) - # The checks fall in the iterations that start at 5 s and 10 s; each click then - # moves the fake clock on by one second. - assert game.buy_times == [6.0, 11.0] - - -def test_click_and_buy_does_not_check_before_the_first_interval() -> None: - clock = FakeClock() - game = FakeGame(cookies=105, upgrades=[GRANDMA], clock=clock) - - result = click_and_buy(game, 4, 5, clock) - - assert result == PlayResult(clicks=4, purchases=(), elapsed_seconds=4.0) - assert game.bought == [] - - -def test_click_and_buy_records_only_real_purchases() -> None: - clock = FakeClock() - game = FakeGame(cookies=10, upgrades=[CURSOR, GRANDMA], clock=clock) - - result = click_and_buy(game, 11, 5, clock) - - assert result.clicks == 11 - assert result.purchases == () - - -def test_buy_most_expensive_upgrade_returns_none_when_the_click_fails() -> None: - game = FakeGame(cookies=105, upgrades=[CURSOR, GRANDMA], buy_succeeds=False) - - assert buy_most_expensive_upgrade(game) is None - assert game.bought == [GRANDMA] - - -def test_click_and_buy_does_not_record_a_purchase_when_the_click_fails() -> None: - clock = FakeClock() - game = FakeGame(cookies=105, upgrades=[GRANDMA], clock=clock, buy_succeeds=False) - - result = click_and_buy(game, 12, 5, clock) - - assert game.buy_times == [6.0, 11.0] - assert result.purchases == () - - -def test_click_and_buy_does_not_click_when_duration_is_zero() -> None: - clock = FakeClock() - game = FakeGame(clock=clock) - - assert click_and_buy(game, 0, 5, clock) == PlayResult( - clicks=0, purchases=(), elapsed_seconds=0.0 - ) - - -def test_click_and_buy_stops_at_the_deadline_and_reports_elapsed_time() -> None: - clock = FakeClock() - game = FakeGame(clock=clock) - - result = click_and_buy(game, 300, 5, clock) - - assert result.clicks == 300 - assert result.elapsed_seconds == 300.0 - - -def test_buy_upgrades_buy_one_buys_only_the_best_affordable() -> None: - game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA]) - - assert buy_upgrades(game, BuyStrategy.BUY_ONE) == [GRANDMA] - assert game.bought == [GRANDMA] - - -def test_buy_upgrades_buy_all_buys_every_affordable_upgrade() -> None: - game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA]) - - assert buy_upgrades(game, BuyStrategy.BUY_ALL) == [GRANDMA, CURSOR] - assert game.bought == [GRANDMA, CURSOR] - - -def test_buy_upgrades_buy_all_buys_nothing_when_too_poor() -> None: - game = FakeGame(cookies=10, upgrades=[CURSOR, GRANDMA]) - - assert buy_upgrades(game, BuyStrategy.BUY_ALL) == [] - assert game.bought == [] - - -def test_buy_upgrades_buy_all_leaves_out_failed_buys() -> None: - game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA], buy_succeeds=False) - - assert buy_upgrades(game, BuyStrategy.BUY_ALL) == [] - assert game.bought == [GRANDMA, CURSOR] - - -def test_click_and_buy_defaults_to_buying_one_upgrade() -> None: - clock = FakeClock() - game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA], clock=clock) - - result = click_and_buy(game, 6, 5, clock) - - assert result.purchases == ("Grandma",) - - -def test_click_and_buy_buy_all_records_every_purchase_in_order() -> None: - clock = FakeClock() - game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA], clock=clock) - - result = click_and_buy(game, 6, 5, clock, BuyStrategy.BUY_ALL) - - assert result.purchases == ("Grandma", "Cursor") - - -def test_click_and_buy_buy_all_does_not_record_failed_buys() -> None: - clock = FakeClock() - game = FakeGame( - cookies=120, upgrades=[CURSOR, GRANDMA], clock=clock, buy_succeeds=False - ) - - result = click_and_buy(game, 6, 5, clock, BuyStrategy.BUY_ALL) - - assert game.bought == [GRANDMA, CURSOR] - assert result.purchases == () - - -def test_click_and_buy_checks_every_second_with_a_one_second_interval() -> None: - clock = FakeClock() - game = FakeGame(cookies=105, upgrades=[GRANDMA], clock=clock) - - click_and_buy(game, 4, 1, clock) - - assert game.buy_times == [2.0, 3.0, 4.0] - - -def test_click_and_buy_checks_every_ten_seconds_with_a_ten_second_interval() -> None: - clock = FakeClock() - game = FakeGame(cookies=105, upgrades=[GRANDMA], clock=clock) - - result = click_and_buy(game, 25, 10, clock) - - assert game.buy_times == [11.0, 21.0] - assert result.purchases == ("Grandma", "Grandma") - - -def test_buy_best_value_upgrade_buys_the_cheapest_per_cookie_per_second() -> None: - game = FakeGame(cookies=500, upgrades=[VALUE_CURSOR, VALUE_GRANDMA]) - - bought = buy_best_value_upgrade(game) - - assert bought == VALUE_GRANDMA - assert game.bought == [VALUE_GRANDMA] - - -def test_buy_best_value_upgrade_buys_nothing_when_too_poor() -> None: - game = FakeGame(cookies=10, upgrades=[VALUE_CURSOR, VALUE_GRANDMA]) - - assert buy_best_value_upgrade(game) is None - assert game.bought == [] - - -def test_buy_best_value_upgrade_leaves_out_a_click_that_missed() -> None: - game = FakeGame(cookies=500, upgrades=[VALUE_GRANDMA], buy_succeeds=False) - - assert buy_best_value_upgrade(game) is None - - -def test_buy_upgrades_with_best_value_buys_one_upgrade() -> None: - game = FakeGame(cookies=500, upgrades=[VALUE_CURSOR, VALUE_GRANDMA]) - - assert buy_upgrades(game, BuyStrategy.BEST_VALUE) == [VALUE_GRANDMA] +"""@file test_game_loop.py +@brief Tests of the game loop with a fake game and a fake clock. +""" + +from pathlib import Path + +from cookie_clicker_bot.application.game_loop import ( + PlayResult, + buy_best_value_upgrade, + 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 + +VALUE_CURSOR = Upgrade("product0", "Cursor", 15, 0.1) +VALUE_GRANDMA = Upgrade("product1", "Grandma", 100, 1.0) + +CURSOR = Upgrade("product0", "Cursor", 15) +GRANDMA = Upgrade("product1", "Grandma", 100) + + +class FakeClock: + """A clock that only moves when the fake game is clicked.""" + + def __init__(self) -> None: + self.now = 0.0 + + def __call__(self) -> float: + return self.now + + +class FakeGame: + """A game with a cookie count and upgrades; each click takes one second.""" + + def __init__( + self, + cookies: int = 0, + upgrades: list[Upgrade] | None = None, + clock: FakeClock | None = None, + buy_succeeds: bool = True, + ) -> None: + self.buy_succeeds = buy_succeeds + self.clicks = 0 + self.cookies = cookies + self.available = upgrades or [] + self.bought: list[Upgrade] = [] + self.buy_times: list[float] = [] + self._clock = clock + + def open_game(self) -> None: + pass + + def dismiss_language(self) -> None: + pass + + def click_cookie(self) -> None: + self.clicks += 1 + if self._clock is not None: + self._clock.now += 1.0 + + def cookie_count(self) -> int: + return self.cookies + + def cookies_per_second(self) -> float: + return 0.0 + + def upgrades(self) -> list[Upgrade]: + return self.available + + def buy(self, upgrade: Upgrade) -> bool: + self.bought.append(upgrade) + if self._clock is not None: + self.buy_times.append(self._clock.now) + return self.buy_succeeds + + def save_screenshot(self, path: Path) -> None: + pass + + def close(self) -> None: + pass + + +def test_click_cookie_delegates_to_the_game() -> None: + game = FakeGame() + + click_cookie(game) + + assert game.clicks == 1 + + +def test_buy_most_expensive_upgrade_buys_the_best_affordable() -> None: + game = FakeGame(cookies=105, upgrades=[CURSOR, GRANDMA]) + + bought = buy_most_expensive_upgrade(game) + + assert bought == GRANDMA + assert game.bought == [GRANDMA] + + +def test_buy_most_expensive_upgrade_buys_nothing_when_too_poor() -> None: + game = FakeGame(cookies=10, upgrades=[CURSOR, GRANDMA]) + + assert buy_most_expensive_upgrade(game) is None + assert game.bought == [] + + +def test_click_and_buy_checks_the_upgrades_every_interval() -> None: + clock = FakeClock() + game = FakeGame(cookies=105, upgrades=[CURSOR, GRANDMA], clock=clock) + + result = click_and_buy(game, 12, 5, clock) + + assert result == PlayResult( + clicks=12, purchases=("Grandma", "Grandma"), elapsed_seconds=12.0 + ) + # The checks fall in the iterations that start at 5 s and 10 s; each click then + # moves the fake clock on by one second. + assert game.buy_times == [6.0, 11.0] + + +def test_click_and_buy_does_not_check_before_the_first_interval() -> None: + clock = FakeClock() + game = FakeGame(cookies=105, upgrades=[GRANDMA], clock=clock) + + result = click_and_buy(game, 4, 5, clock) + + assert result == PlayResult(clicks=4, purchases=(), elapsed_seconds=4.0) + assert game.bought == [] + + +def test_click_and_buy_records_only_real_purchases() -> None: + clock = FakeClock() + game = FakeGame(cookies=10, upgrades=[CURSOR, GRANDMA], clock=clock) + + result = click_and_buy(game, 11, 5, clock) + + assert result.clicks == 11 + assert result.purchases == () + + +def test_buy_most_expensive_upgrade_returns_none_when_the_click_fails() -> None: + game = FakeGame(cookies=105, upgrades=[CURSOR, GRANDMA], buy_succeeds=False) + + assert buy_most_expensive_upgrade(game) is None + assert game.bought == [GRANDMA] + + +def test_click_and_buy_does_not_record_a_purchase_when_the_click_fails() -> None: + clock = FakeClock() + game = FakeGame(cookies=105, upgrades=[GRANDMA], clock=clock, buy_succeeds=False) + + result = click_and_buy(game, 12, 5, clock) + + assert game.buy_times == [6.0, 11.0] + assert result.purchases == () + + +def test_click_and_buy_does_not_click_when_duration_is_zero() -> None: + clock = FakeClock() + game = FakeGame(clock=clock) + + assert click_and_buy(game, 0, 5, clock) == PlayResult( + clicks=0, purchases=(), elapsed_seconds=0.0 + ) + + +def test_click_and_buy_stops_at_the_deadline_and_reports_elapsed_time() -> None: + clock = FakeClock() + game = FakeGame(clock=clock) + + result = click_and_buy(game, 300, 5, clock) + + assert result.clicks == 300 + assert result.elapsed_seconds == 300.0 + + +def test_buy_upgrades_buy_one_buys_only_the_best_affordable() -> None: + game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA]) + + assert buy_upgrades(game, BuyStrategy.BUY_ONE) == [GRANDMA] + assert game.bought == [GRANDMA] + + +def test_buy_upgrades_buy_all_buys_every_affordable_upgrade() -> None: + game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA]) + + assert buy_upgrades(game, BuyStrategy.BUY_ALL) == [GRANDMA, CURSOR] + assert game.bought == [GRANDMA, CURSOR] + + +def test_buy_upgrades_buy_all_buys_nothing_when_too_poor() -> None: + game = FakeGame(cookies=10, upgrades=[CURSOR, GRANDMA]) + + assert buy_upgrades(game, BuyStrategy.BUY_ALL) == [] + assert game.bought == [] + + +def test_buy_upgrades_buy_all_leaves_out_failed_buys() -> None: + game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA], buy_succeeds=False) + + assert buy_upgrades(game, BuyStrategy.BUY_ALL) == [] + assert game.bought == [GRANDMA, CURSOR] + + +def test_click_and_buy_defaults_to_buying_one_upgrade() -> None: + clock = FakeClock() + game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA], clock=clock) + + result = click_and_buy(game, 6, 5, clock) + + assert result.purchases == ("Grandma",) + + +def test_click_and_buy_buy_all_records_every_purchase_in_order() -> None: + clock = FakeClock() + game = FakeGame(cookies=120, upgrades=[CURSOR, GRANDMA], clock=clock) + + result = click_and_buy(game, 6, 5, clock, BuyStrategy.BUY_ALL) + + assert result.purchases == ("Grandma", "Cursor") + + +def test_click_and_buy_buy_all_does_not_record_failed_buys() -> None: + clock = FakeClock() + game = FakeGame( + cookies=120, upgrades=[CURSOR, GRANDMA], clock=clock, buy_succeeds=False + ) + + result = click_and_buy(game, 6, 5, clock, BuyStrategy.BUY_ALL) + + assert game.bought == [GRANDMA, CURSOR] + assert result.purchases == () + + +def test_click_and_buy_checks_every_second_with_a_one_second_interval() -> None: + clock = FakeClock() + game = FakeGame(cookies=105, upgrades=[GRANDMA], clock=clock) + + click_and_buy(game, 4, 1, clock) + + assert game.buy_times == [2.0, 3.0, 4.0] + + +def test_click_and_buy_checks_every_ten_seconds_with_a_ten_second_interval() -> None: + clock = FakeClock() + game = FakeGame(cookies=105, upgrades=[GRANDMA], clock=clock) + + result = click_and_buy(game, 25, 10, clock) + + assert game.buy_times == [11.0, 21.0] + assert result.purchases == ("Grandma", "Grandma") + + +def test_buy_best_value_upgrade_buys_the_cheapest_per_cookie_per_second() -> None: + game = FakeGame(cookies=500, upgrades=[VALUE_CURSOR, VALUE_GRANDMA]) + + bought = buy_best_value_upgrade(game) + + assert bought == VALUE_GRANDMA + assert game.bought == [VALUE_GRANDMA] + + +def test_buy_best_value_upgrade_buys_nothing_when_too_poor() -> None: + game = FakeGame(cookies=10, upgrades=[VALUE_CURSOR, VALUE_GRANDMA]) + + assert buy_best_value_upgrade(game) is None + assert game.bought == [] + + +def test_buy_best_value_upgrade_leaves_out_a_click_that_missed() -> None: + game = FakeGame(cookies=500, upgrades=[VALUE_GRANDMA], buy_succeeds=False) + + assert buy_best_value_upgrade(game) is None + + +def test_buy_upgrades_with_best_value_buys_one_upgrade() -> None: + game = FakeGame(cookies=500, upgrades=[VALUE_CURSOR, VALUE_GRANDMA]) + + assert buy_upgrades(game, BuyStrategy.BEST_VALUE) == [VALUE_GRANDMA] diff --git a/tests/unit/test_upgrades.py b/tests/unit/test_upgrades.py index c26f39a..2f4ddc8 100644 --- a/tests/unit/test_upgrades.py +++ b/tests/unit/test_upgrades.py @@ -1,114 +1,114 @@ -"""@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_best_value, - 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] - - -VALUE_CURSOR = Upgrade("product0", "Cursor", 15, 0.1) -VALUE_GRANDMA = Upgrade("product1", "Grandma", 100, 1.0) -VALUE_FARM = Upgrade("product2", "Farm", 1100, 8.0) - - -def test_best_value_picks_the_lowest_price_per_cookie_per_second() -> None: - upgrades = [VALUE_CURSOR, VALUE_GRANDMA, VALUE_FARM] - - assert choose_best_value(upgrades, 2000) == VALUE_GRANDMA - - -def test_best_value_ignores_what_the_cookies_cannot_pay() -> None: - upgrades = [VALUE_CURSOR, VALUE_GRANDMA, VALUE_FARM] - - assert choose_best_value(upgrades, 50) == VALUE_CURSOR - - -def test_best_value_returns_none_when_nothing_is_affordable() -> None: - assert choose_best_value([VALUE_CURSOR, VALUE_GRANDMA], 10) is None - - -def test_best_value_returns_none_without_upgrades() -> None: - assert choose_best_value([], 1000) is None - - -def test_best_value_takes_the_first_on_a_tie() -> None: - twin = Upgrade("product9", "Twin", 100, 1.0) - - assert choose_best_value([VALUE_GRANDMA, twin], 500) == VALUE_GRANDMA - - -def test_best_value_skips_an_upgrade_with_zero_production() -> None: - idle = Upgrade("product9", "Idle", 10, 0.0) - - assert choose_best_value([idle, VALUE_GRANDMA], 500) == VALUE_GRANDMA - - -def test_best_value_falls_back_to_the_most_expensive_when_production_is_unknown() -> ( - None -): - assert choose_best_value([CURSOR, GRANDMA, FARM], 1200) == FARM +"""@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_best_value, + 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] + + +VALUE_CURSOR = Upgrade("product0", "Cursor", 15, 0.1) +VALUE_GRANDMA = Upgrade("product1", "Grandma", 100, 1.0) +VALUE_FARM = Upgrade("product2", "Farm", 1100, 8.0) + + +def test_best_value_picks_the_lowest_price_per_cookie_per_second() -> None: + upgrades = [VALUE_CURSOR, VALUE_GRANDMA, VALUE_FARM] + + assert choose_best_value(upgrades, 2000) == VALUE_GRANDMA + + +def test_best_value_ignores_what_the_cookies_cannot_pay() -> None: + upgrades = [VALUE_CURSOR, VALUE_GRANDMA, VALUE_FARM] + + assert choose_best_value(upgrades, 50) == VALUE_CURSOR + + +def test_best_value_returns_none_when_nothing_is_affordable() -> None: + assert choose_best_value([VALUE_CURSOR, VALUE_GRANDMA], 10) is None + + +def test_best_value_returns_none_without_upgrades() -> None: + assert choose_best_value([], 1000) is None + + +def test_best_value_takes_the_first_on_a_tie() -> None: + twin = Upgrade("product9", "Twin", 100, 1.0) + + assert choose_best_value([VALUE_GRANDMA, twin], 500) == VALUE_GRANDMA + + +def test_best_value_skips_an_upgrade_with_zero_production() -> None: + idle = Upgrade("product9", "Idle", 10, 0.0) + + assert choose_best_value([idle, VALUE_GRANDMA], 500) == VALUE_GRANDMA + + +def test_best_value_falls_back_to_the_most_expensive_when_production_is_unknown() -> ( + None +): + assert choose_best_value([CURSOR, GRANDMA, FARM], 1200) == FARM -- 2.54.0 From 086b9f15820815506d2b053dc3fa046895098a76 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 28 Oct 2026 23:43:04 +0800 Subject: [PATCH 5/5] Resolve pending commit links for MIL-008 --- docs/sqa/reviews/rc-020-code-mil-008.md | 3 ++- docs/sqa/traceability-matrix.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/sqa/reviews/rc-020-code-mil-008.md b/docs/sqa/reviews/rc-020-code-mil-008.md index c73ceb6..24b27b1 100644 --- a/docs/sqa/reviews/rc-020-code-mil-008.md +++ b/docs/sqa/reviews/rc-020-code-mil-008.md @@ -9,7 +9,7 @@ ## Version History | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | -| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Code review record | pending | +| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Code review record | [2f1c7ff] | --- @@ -59,3 +59,4 @@ Go — the code of MIL-008 meets every Mandatory criterion; `ruff`, `mypy --stri [MIL-008]: ../../milestones/mil-008-smarter-rule.md [QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md +[2f1c7ff]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/commit/2f1c7ff00a80bdcfa4ed4ff087227a3e4d4d591c diff --git a/docs/sqa/traceability-matrix.md b/docs/sqa/traceability-matrix.md index 37dfee1..2744007 100644 --- a/docs/sqa/traceability-matrix.md +++ b/docs/sqa/traceability-matrix.md @@ -10,7 +10,7 @@ | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | | 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Add MIL-008 and RC-019 | [9a6796f] | -| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Add RC-020, the code review of MIL-008 | pending | +| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Add RC-020, the code review of MIL-008 | [2f1c7ff] | --- @@ -95,3 +95,4 @@ Tracks backward and forward links between artifact instances so that the Busines [MIL-008]: ../milestones/mil-008-smarter-rule.md [cadc5ef]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/commit/cadc5eff21d9afc09799d4bc2e3f6ac17ab2612a [9a6796f]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/commit/9a6796f775680effe37a54f2609c3915140c8c0e +[2f1c7ff]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/commit/2f1c7ff00a80bdcfa4ed4ff087227a3e4d4d591c -- 2.54.0