diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index a112fe8..8f01b13 100644 --- a/docs/artifact-registry.md +++ b/docs/artifact-registry.md @@ -17,7 +17,7 @@ document of a type. `Primary File` may contain a glob (e.g. | MIL | Milestone / Gateway | docs/milestones/mil-*.md | 006 | | ADR | Architecture Decision Record | docs/adr/adr-*.md | 0002 | | TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 | -| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 011 | +| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 012 | ## Languages diff --git a/docs/sqa/reviews/rc-011-mil-002-source.md b/docs/sqa/reviews/rc-011-mil-002-source.md new file mode 100644 index 0000000..c41e5db --- /dev/null +++ b/docs/sqa/reviews/rc-011-mil-002-source.md @@ -0,0 +1,62 @@ +# SQA Review Record + +## Metadata +| Key | Value | +| --- | --- | +| ID | RC-011 | +| CrossReference | [MIL-002], [QC-PY-001] | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [68c4391] | + +--- + +## Artifact Under Review + +- Instance reviewed: source of milestone MIL-002 (open_game, select_language, click_cookie, get_cookie_count, parser, Selenium adapter) ([MIL-002]) +- Checklist used: [QC-PY-001] +- Scope: full review +- Language and domain: n/a (technical type) +- Language reviewer: none +- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S01`) + +## Checklist Results + +| # | Criterion | Status | Evidence/Notes | +| --- | --- | --- | --- | +| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | snake_case modules and functions, PascalCase classes, UPPER_SNAKE constants. | +| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose: parse_game_number, wait_for_element, ELEMENT_TIMEOUT_SECONDS. | +| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff check` and `ruff format --check` clean; no suppressions. | +| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | All signatures annotated, including -> None; mypy strict passes. | +| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No bare except; ValueError and UnreadableNumberError are raised with a message. | +| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults; no builtin shadowed. | +| 7 | Files, locks and connections are managed with context managers | N-A | The browser is released by SeleniumGame.close(); the composition root in MIL-003 calls it in a finally block. | +| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Public modules, classes and functions have Doxygen docstrings (@brief, @param, @return). | +| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | N-A | No logging yet; added with the run loop in MIL-003. | +| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | Plain technical tasks; no Design Class Diagram exists or is required. | +| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 33 tests (unit and integration) run offline with a fake game and a fake driver; positive, negative, boundary and edge cases covered. | +| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | mypy --strict: no issues in 22 files; no Any. | +| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | selenium is declared and is the only runtime dependency. | +| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | URL, ids and number words are defined once in constants.py. | +| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | domain imports only constants; application imports domain and its own port; only infrastructure/selenium_game.py imports selenium; no cycles. | +| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | GamePort is a narrow Protocol (4 methods); SeleniumGame receives its driver (dependency inversion); each module has one purpose. | +| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | domain, application (ports, use_cases), infrastructure and presentation under src/; unit, integration and e2e under tests/. | +| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | All names English, snake_case, no spaces. | + +## Overall Verdict + +Go - All Mandatory criteria pass or are not applicable; the live-game behaviour is verified in MIL-004. Stand-in review, not independent. + +## Action Items + +| Action | Owner | Due | +| --- | --- | --- | +| None | S01 | - | + +--- + +[MIL-002]: ../../milestones/mil-002-bot-core-open-and-click.md +[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md +[68c4391]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/commit/68c439137f13299e20cf6271e392365d9f5371c4 diff --git a/docs/sqa/traceability-matrix.md b/docs/sqa/traceability-matrix.md index a92b6c5..5f0235f 100644 --- a/docs/sqa/traceability-matrix.md +++ b/docs/sqa/traceability-matrix.md @@ -1,77 +1,78 @@ -# Traceability Matrix - -## Metadata -| Key | Value | -| --- | --- | -| ID | TM-001 | -| CrossReference | [BC-001], [SA-001], [PP-001] | - -## Version History -| Date | Status | Author | Reviewer | Change | Commit | -| --- | --- | --- | --- | --- | --- | -| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | | Initial version | [96a9a78] | -| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Added ADR-0001, RC-009 and RC-010 | [ad3334f] | - ---- - -## 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. - -## Traceability Table - -| Artifact Instance | Type | Language | Domain | Upstream (Backward Link) | Downstream (Forward Link) | Last Reviewed (RC-ID) | -| --- | --- | --- | --- | --- | --- | --- | -| [BC-001] | BC | en | it | - | [SA-001], [PP-001] | [RC-001] | -| [SA-001] | SA | en | it | [BC-001] | [PP-001] | [RC-002] | -| [PP-001] | PP | en | it | [BC-001], [SA-001] | [MIL-001], [MIL-002], [MIL-003], [MIL-004], [MIL-005] | [RC-003] | -| [MIL-001] | MIL | en | it | [PP-001] | [MIL-002], [ADR-0001] | [RC-004], [RC-010] | -| [MIL-002] | MIL | en | it | [MIL-001] | [MIL-003] | [RC-005] | -| [MIL-003] | MIL | en | it | [MIL-002] | [MIL-004] | [RC-006] | -| [MIL-004] | MIL | en | it | [MIL-003] | [MIL-005] | [RC-007] | -| [MIL-005] | MIL | en | it | [MIL-004] | - | [RC-008] | -| [ADR-0001] | ADR | - | - | [BC-001], [SA-001], [MIL-001] | - | [RC-009] | - -## Concern Coverage - -| Concern (CN-ID) | Views that answer it | Decisions (ADR) | Quality check (TP) | Status | -| --- | --- | --- | --- | --- | -| CN-001 | [MIL-003] | - | - | Gap | -| CN-002 | [BC-001] | - | - | Covered | -| CN-003 | [MIL-002] | - | - | Covered | -| CN-004 | [MIL-004] | - | - | Covered | -| CN-005 | [MIL-001] | - | - | Covered | -| CN-006 | [MIL-001] | [ADR-0001] | - | Covered | -| CN-007 | [MIL-005] | - | - | Covered | - -## Open Inconsistencies - -| # | Views | Inconsistency | Found in (RC-ID) | Status | Resolved in | -| --- | --- | --- | --- | --- | --- | -| - | - | None found | - | Closed | - | - ---- - -[BC-001]: ../business-case.md -[ADR-0001]: ../adr/adr-0001-selenium-runtime-dependency.md -[SA-001]: ../stakeholder-analysis.md -[PP-001]: ../project-plan.md -[MIL-001]: ../milestones/mil-001-project-setup.md -[MIL-002]: ../milestones/mil-002-bot-core-open-and-click.md -[MIL-003]: ../milestones/mil-003-upgrades-and-five-minute-run.md -[MIL-004]: ../milestones/mil-004-screenshots-and-readme.md -[MIL-005]: ../milestones/mil-005-wiki-and-release.md -[RC-001]: ./reviews/rc-001-business-case.md -[RC-002]: ./reviews/rc-002-stakeholder-analysis.md -[RC-003]: ./reviews/rc-003-project-plan.md -[RC-004]: ./reviews/rc-004-mil-001-project-setup.md -[RC-005]: ./reviews/rc-005-mil-002-bot-core-open-and-click.md -[RC-006]: ./reviews/rc-006-mil-003-upgrades-and-five-minute-run.md -[RC-007]: ./reviews/rc-007-mil-004-screenshots-and-readme.md -[RC-008]: ./reviews/rc-008-mil-005-wiki-and-release.md -[96a9a78]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/commit/96a9a78f010ce8ae3fce8d84602317d8b5808a13 -[RC-009]: ./reviews/rc-009-adr-0001-selenium-runtime-dependency.md -[RC-010]: ./reviews/rc-010-mil-001-source.md -[ad3334f]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/commit/ad3334fa13c4261c3d034fbffcc7b43fe95fbb31 +# Traceability Matrix + +## Metadata +| Key | Value | +| --- | --- | +| ID | TM-001 | +| CrossReference | [BC-001], [SA-001], [PP-001] | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Added ADR-0001, RC-009 and RC-010 | [ad3334f] | +| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Added RC-011 (MIL-002 source review) | [68c4391] | + +--- + +## 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. + +## Traceability Table + +| Artifact Instance | Type | Language | Domain | Upstream (Backward Link) | Downstream (Forward Link) | Last Reviewed (RC-ID) | +| --- | --- | --- | --- | --- | --- | --- | +| [BC-001] | BC | en | it | - | [SA-001], [PP-001] | [RC-001] | +| [SA-001] | SA | en | it | [BC-001] | [PP-001] | [RC-002] | +| [PP-001] | PP | en | it | [BC-001], [SA-001] | [MIL-001], [MIL-002], [MIL-003], [MIL-004], [MIL-005] | [RC-003] | +| [MIL-001] | MIL | en | it | [PP-001] | [MIL-002], [ADR-0001] | [RC-004], [RC-010] | +| [MIL-002] | MIL | en | it | [MIL-001] | [MIL-003] | [RC-005], [RC-011] | +| [MIL-003] | MIL | en | it | [MIL-002] | [MIL-004] | [RC-006] | +| [MIL-004] | MIL | en | it | [MIL-003] | [MIL-005] | [RC-007] | +| [MIL-005] | MIL | en | it | [MIL-004] | - | [RC-008] | +| [ADR-0001] | ADR | - | - | [BC-001], [SA-001], [MIL-001] | - | [RC-009] | + +## Concern Coverage + +| Concern (CN-ID) | Views that answer it | Decisions (ADR) | Quality check (TP) | Status | +| --- | --- | --- | --- | --- | +| CN-001 | [MIL-003] | - | - | Gap | +| CN-002 | [BC-001] | - | - | Covered | +| CN-003 | [MIL-002] | - | - | Covered | +| CN-004 | [MIL-004] | - | - | Covered | +| CN-005 | [MIL-001] | - | - | Covered | +| CN-006 | [MIL-001] | [ADR-0001] | - | Covered | +| CN-007 | [MIL-005] | - | - | Covered | + +## Open Inconsistencies + +| # | Views | Inconsistency | Found in (RC-ID) | Status | Resolved in | +| --- | --- | --- | --- | --- | --- | +| - | - | None found | - | Closed | - | + +--- + +[BC-001]: ../business-case.md +[ADR-0001]: ../adr/adr-0001-selenium-runtime-dependency.md +[SA-001]: ../stakeholder-analysis.md +[PP-001]: ../project-plan.md +[MIL-001]: ../milestones/mil-001-project-setup.md +[MIL-002]: ../milestones/mil-002-bot-core-open-and-click.md +[MIL-003]: ../milestones/mil-003-upgrades-and-five-minute-run.md +[MIL-004]: ../milestones/mil-004-screenshots-and-readme.md +[MIL-005]: ../milestones/mil-005-wiki-and-release.md +[RC-001]: ./reviews/rc-001-business-case.md +[RC-002]: ./reviews/rc-002-stakeholder-analysis.md +[RC-003]: ./reviews/rc-003-project-plan.md +[RC-004]: ./reviews/rc-004-mil-001-project-setup.md +[RC-005]: ./reviews/rc-005-mil-002-bot-core-open-and-click.md +[RC-006]: ./reviews/rc-006-mil-003-upgrades-and-five-minute-run.md +[RC-007]: ./reviews/rc-007-mil-004-screenshots-and-readme.md +[RC-008]: ./reviews/rc-008-mil-005-wiki-and-release.md +[RC-009]: ./reviews/rc-009-adr-0001-selenium-runtime-dependency.md +[RC-010]: ./reviews/rc-010-mil-001-source.md +[ad3334f]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/commit/ad3334fa13c4261c3d034fbffcc7b43fe95fbb31 +[RC-011]: ./reviews/rc-011-mil-002-source.md +[68c4391]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/commit/68c439137f13299e20cf6271e392365d9f5371c4 diff --git a/src/cookie_clicker_bot/application/ports/__init__.py b/src/cookie_clicker_bot/application/ports/__init__.py new file mode 100644 index 0000000..e0e62c2 --- /dev/null +++ b/src/cookie_clicker_bot/application/ports/__init__.py @@ -0,0 +1,3 @@ +"""@file +@brief Ports: what the application needs from the outside world. +""" diff --git a/src/cookie_clicker_bot/application/ports/game_port.py b/src/cookie_clicker_bot/application/ports/game_port.py new file mode 100644 index 0000000..0a3c1f0 --- /dev/null +++ b/src/cookie_clicker_bot/application/ports/game_port.py @@ -0,0 +1,34 @@ +"""@file +@brief The port through which the bot sees and operates the game. +""" + +from typing import Protocol + + +class GamePort(Protocol): + """@brief A running game page. The Selenium adapter implements it.""" + + def open_page(self, url: str) -> None: + """@brief Load the page at the address. + @param url The address of the game. + """ + ... + + def wait_for_element(self, element_id: str) -> None: + """@brief Block until the element is on the page. + @param element_id The HTML id of the element. + """ + ... + + def click_element(self, element_id: str) -> None: + """@brief Click the element the way a user does. + @param element_id The HTML id of the element. + """ + ... + + def read_text(self, element_id: str) -> str: + """@brief Return the visible text of the element. + @param element_id The HTML id of the element. + @return The text as shown on the page. + """ + ... diff --git a/src/cookie_clicker_bot/application/use_cases/__init__.py b/src/cookie_clicker_bot/application/use_cases/__init__.py new file mode 100644 index 0000000..d5fba15 --- /dev/null +++ b/src/cookie_clicker_bot/application/use_cases/__init__.py @@ -0,0 +1,3 @@ +"""@file +@brief Use cases of the bot, one function per lecture step. +""" diff --git a/src/cookie_clicker_bot/application/use_cases/bot_core.py b/src/cookie_clicker_bot/application/use_cases/bot_core.py new file mode 100644 index 0000000..72f6886 --- /dev/null +++ b/src/cookie_clicker_bot/application/use_cases/bot_core.py @@ -0,0 +1,51 @@ +"""@file +@brief Lecture steps 1 to 3: open the game, click the cookie, read the count. +""" + +from cookie_clicker_bot.application.ports.game_port import GamePort +from cookie_clicker_bot.constants import ( + BIG_COOKIE_ID, + COOKIE_COUNTER_ID, + GAME_URL, + LANGUAGE_BUTTON_PREFIX, + LANGUAGE_CODE, +) +from cookie_clicker_bot.domain.number_parsing import parse_game_number + + +def open_game(game: GamePort) -> None: + """@brief Load the game and wait until it asks for a language. + @param game The game page to operate. + """ + game.open_page(GAME_URL) + game.wait_for_element(LANGUAGE_BUTTON_PREFIX + LANGUAGE_CODE) + + +def select_language(game: GamePort) -> None: + """@brief Choose the language and wait until the big cookie is playable. + @param game The game page to operate. + """ + game.click_element(LANGUAGE_BUTTON_PREFIX + LANGUAGE_CODE) + game.wait_for_element(BIG_COOKIE_ID) + + +def click_cookie(game: GamePort, times: int = 1) -> None: + """@brief Click the big cookie. + @param game The game page to operate. + @param times How many clicks to make, zero or more. + @throws ValueError When times is negative. + """ + if times < 0: + raise ValueError(f"times must not be negative, got {times}") + for _ in range(times): + game.click_element(BIG_COOKIE_ID) + + +def get_cookie_count(game: GamePort) -> int: + """@brief Read how many cookies the bakery has now. + @param game The game page to read. + @return The number of cookies, rounded to a whole number. + @throws UnreadableNumberError When the counter shows no number. + """ + first_line = game.read_text(COOKIE_COUNTER_ID).strip().splitlines()[0] + return parse_game_number(first_line) diff --git a/src/cookie_clicker_bot/constants.py b/src/cookie_clicker_bot/constants.py index 799889c..764cbb7 100644 --- a/src/cookie_clicker_bot/constants.py +++ b/src/cookie_clicker_bot/constants.py @@ -1,7 +1,5 @@ """@file @brief Constants shared by every layer of the bot. - -Game specific values (URL, element ids) are added with the code that uses them. """ ## Seconds the bot plays before it reads the cookies per second (lecture step 5). @@ -12,3 +10,36 @@ STORE_CHECK_INTERVAL_SECONDS: int = 5 ## Seconds to wait for an element before the bot gives up. ELEMENT_TIMEOUT_SECONDS: int = 20 + +## Address of the game the bot plays. +GAME_URL: str = "https://ozh.github.io/cookieclicker/" + +## Element id prefix of the language buttons shown on the first launch. +LANGUAGE_BUTTON_PREFIX: str = "langSelect-" + +## Language the bot chooses (the game's own code for English). +LANGUAGE_CODE: str = "EN" + +## Element id of the big cookie that is clicked to earn cookies. +BIG_COOKIE_ID: str = "bigCookie" + +## Element id of the counter that shows the cookies and the cookies per second. +COOKIE_COUNTER_ID: str = "cookies" + +## Element id of the line that shows the cookies per second. +CPS_ID: str = "cookiesPerSecond" + +## Number words the game prints for large amounts, with their value. +NUMBER_SCALES: dict[str, int] = { + "thousand": 10**3, + "million": 10**6, + "billion": 10**9, + "trillion": 10**12, + "quadrillion": 10**15, + "quintillion": 10**18, + "sextillion": 10**21, + "septillion": 10**24, + "octillion": 10**27, + "nonillion": 10**30, + "decillion": 10**33, +} diff --git a/src/cookie_clicker_bot/domain/errors.py b/src/cookie_clicker_bot/domain/errors.py new file mode 100644 index 0000000..8fbdd9f --- /dev/null +++ b/src/cookie_clicker_bot/domain/errors.py @@ -0,0 +1,7 @@ +"""@file +@brief Errors raised by the game rules. +""" + + +class UnreadableNumberError(ValueError): + """@brief Raised when text from the game does not contain a readable number.""" diff --git a/src/cookie_clicker_bot/domain/number_parsing.py b/src/cookie_clicker_bot/domain/number_parsing.py new file mode 100644 index 0000000..15c2c49 --- /dev/null +++ b/src/cookie_clicker_bot/domain/number_parsing.py @@ -0,0 +1,30 @@ +"""@file +@brief Turns the amounts the game prints ("1.234 million") into numbers. +""" + +import re +from decimal import ROUND_HALF_UP, Decimal + +from cookie_clicker_bot.constants import NUMBER_SCALES +from cookie_clicker_bot.domain.errors import UnreadableNumberError + +_AMOUNT = re.compile(r"^\s*(\d[\d,]*(?:\.\d+)?)(?:\s+([a-z]+))?", re.IGNORECASE) + + +def parse_game_number(text: str) -> int: + """@brief Read the first amount in a text printed by the game. + + The game groups thousands with commas ("12,345") and writes large amounts + with a number word ("1.234 million"). Any other word after the number, such + as "cookies", is ignored. + + @param text The text, for example "1.234 million" or "12,345 cookies". + @return The amount, rounded to a whole number. + @throws UnreadableNumberError When the text does not start with a number. + """ + match = _AMOUNT.match(text) + if match is None: + raise UnreadableNumberError(f"No number at the start of {text!r}") + value = Decimal(match.group(1).replace(",", "")) + scale = NUMBER_SCALES.get((match.group(2) or "").lower(), 1) + return int((value * scale).to_integral_value(rounding=ROUND_HALF_UP)) diff --git a/src/cookie_clicker_bot/infrastructure/selenium_game.py b/src/cookie_clicker_bot/infrastructure/selenium_game.py new file mode 100644 index 0000000..26d4374 --- /dev/null +++ b/src/cookie_clicker_bot/infrastructure/selenium_game.py @@ -0,0 +1,81 @@ +"""@file +@brief Selenium adapter for the GamePort, and the factory for the Chrome driver. +""" + +from selenium import webdriver +from selenium.webdriver.common.by import By +from selenium.webdriver.remote.webdriver import WebDriver +from selenium.webdriver.support import expected_conditions +from selenium.webdriver.support.ui import WebDriverWait + +from cookie_clicker_bot.constants import ELEMENT_TIMEOUT_SECONDS + +_HEADLESS_ARGUMENTS: tuple[str, ...] = ( + "--headless=new", + "--no-sandbox", + "--disable-dev-shm-usage", + "--window-size=1280,1024", +) + + +class SeleniumGame: + """@brief Operates the game page through a Selenium web driver.""" + + def __init__(self, driver: WebDriver) -> None: + """@brief Create the adapter. + @param driver The web driver to operate; the caller owns its creation. + """ + self._driver = driver + + def open_page(self, url: str) -> None: + """@brief Load the page at the address. + @param url The address of the game. + """ + self._driver.get(url) + + def wait_for_element(self, element_id: str) -> None: + """@brief Block until the element is on the page. + @param element_id The HTML id of the element. + @throws selenium.common.exceptions.TimeoutException When it never appears. + """ + WebDriverWait(self._driver, ELEMENT_TIMEOUT_SECONDS).until( + expected_conditions.presence_of_element_located((By.ID, element_id)) + ) + + def click_element(self, element_id: str) -> None: + """@brief Click the element with a real click, never a script click. + + The game rate-limits synthetic clicks, so the click must come from the + browser the way a user's does. + + @param element_id The HTML id of the element. + @throws selenium.common.exceptions.TimeoutException When it never + becomes clickable. + """ + element = WebDriverWait(self._driver, ELEMENT_TIMEOUT_SECONDS).until( + expected_conditions.element_to_be_clickable((By.ID, element_id)) + ) + element.click() + + def read_text(self, element_id: str) -> str: + """@brief Return the visible text of the element. + @param element_id The HTML id of the element. + @return The text as shown on the page. + """ + return self._driver.find_element(By.ID, element_id).text + + def close(self) -> None: + """@brief Quit the browser and end the driver session.""" + self._driver.quit() + + +def create_chrome_driver(headless: bool = False) -> WebDriver: + """@brief Build a Chrome web driver. + @param headless True to run without a window, for a container or CI. + @return The started Chrome driver. + """ + options = webdriver.ChromeOptions() + if headless: + for argument in _HEADLESS_ARGUMENTS: + options.add_argument(argument) + return webdriver.Chrome(options=options) diff --git a/tests/integration/test_selenium_game.py b/tests/integration/test_selenium_game.py new file mode 100644 index 0000000..43690c9 --- /dev/null +++ b/tests/integration/test_selenium_game.py @@ -0,0 +1,105 @@ +"""@file +@brief Tests of the Selenium adapter against a hand-written fake driver. + +No browser and no network: the fake driver records what the adapter asks for. +""" + +from typing import cast + +import pytest +from selenium.common.exceptions import NoSuchElementException +from selenium.webdriver.common.by import By +from selenium.webdriver.remote.webdriver import WebDriver + +from cookie_clicker_bot.infrastructure.selenium_game import SeleniumGame + + +class FakeElement: + """@brief An element that counts its real clicks.""" + + def __init__(self, text: str = "") -> None: + self.text = text + self.clicks = 0 + + def is_displayed(self) -> bool: + return True + + def is_enabled(self) -> bool: + return True + + def click(self) -> None: + self.clicks += 1 + + +class FakeDriver: + """@brief A driver with elements by id; it records its calls.""" + + def __init__(self, elements: dict[str, FakeElement]) -> None: + self.elements = elements + self.visited: list[str] = [] + self.lookups: list[tuple[str, str]] = [] + self.quit_calls = 0 + + def get(self, url: str) -> None: + self.visited.append(url) + + def find_element(self, by: str, value: str) -> FakeElement: + self.lookups.append((by, value)) + if value not in self.elements: + raise NoSuchElementException(value) + return self.elements[value] + + def quit(self) -> None: + self.quit_calls += 1 + + +def make_game(driver: FakeDriver) -> SeleniumGame: + return SeleniumGame(cast(WebDriver, driver)) + + +def test_open_page_loads_the_url() -> None: + driver = FakeDriver({}) + + make_game(driver).open_page("https://example.test/") + + assert driver.visited == ["https://example.test/"] + + +def test_wait_for_element_looks_up_by_id() -> None: + driver = FakeDriver({"bigCookie": FakeElement()}) + + make_game(driver).wait_for_element("bigCookie") + + assert driver.lookups == [(By.ID, "bigCookie")] + + +def test_read_text_returns_the_element_text() -> None: + driver = FakeDriver({"cookies": FakeElement("12 cookies")}) + + assert make_game(driver).read_text("cookies") == "12 cookies" + assert driver.lookups == [(By.ID, "cookies")] + + +def test_click_element_makes_one_real_click() -> None: + element = FakeElement() + driver = FakeDriver({"bigCookie": element}) + + make_game(driver).click_element("bigCookie") + + assert element.clicks == 1 + assert all(by == By.ID for by, _ in driver.lookups) + + +def test_read_text_raises_when_element_is_missing() -> None: + game = make_game(FakeDriver({})) + + with pytest.raises(NoSuchElementException): + game.read_text("missing") + + +def test_close_quits_the_driver() -> None: + driver = FakeDriver({}) + + make_game(driver).close() + + assert driver.quit_calls == 1 diff --git a/tests/unit/fakes.py b/tests/unit/fakes.py new file mode 100644 index 0000000..d3fa92a --- /dev/null +++ b/tests/unit/fakes.py @@ -0,0 +1,25 @@ +"""@file +@brief A fake game page for tests that must not start a browser. +""" + + +class FakeGame: + """@brief Records what the bot does and answers reads from a dictionary.""" + + def __init__(self, texts: dict[str, str] | None = None) -> None: + self.texts: dict[str, str] = texts or {} + self.opened: list[str] = [] + self.waited: list[str] = [] + self.clicked: list[str] = [] + + def open_page(self, url: str) -> None: + self.opened.append(url) + + def wait_for_element(self, element_id: str) -> None: + self.waited.append(element_id) + + def click_element(self, element_id: str) -> None: + self.clicked.append(element_id) + + def read_text(self, element_id: str) -> str: + return self.texts[element_id] diff --git a/tests/unit/test_bot_core.py b/tests/unit/test_bot_core.py new file mode 100644 index 0000000..8cf953f --- /dev/null +++ b/tests/unit/test_bot_core.py @@ -0,0 +1,66 @@ +"""@file +@brief Tests of lecture steps 1 to 3 against a fake game. +""" + +import pytest + +from cookie_clicker_bot.application.use_cases.bot_core import ( + click_cookie, + get_cookie_count, + open_game, + select_language, +) +from cookie_clicker_bot.constants import ( + BIG_COOKIE_ID, + COOKIE_COUNTER_ID, + GAME_URL, + LANGUAGE_BUTTON_PREFIX, + LANGUAGE_CODE, +) +from tests.unit.fakes import FakeGame + +LANGUAGE_BUTTON = LANGUAGE_BUTTON_PREFIX + LANGUAGE_CODE + + +def test_open_game_loads_the_url_and_waits_for_the_language_prompt() -> None: + game = FakeGame() + open_game(game) + assert game.opened == [GAME_URL] + assert game.waited == [LANGUAGE_BUTTON] + + +def test_select_language_clicks_the_button_and_waits_for_the_cookie() -> None: + game = FakeGame() + select_language(game) + assert game.clicked == [LANGUAGE_BUTTON] + assert game.waited == [BIG_COOKIE_ID] + + +@pytest.mark.parametrize("times", [0, 1, 2, 50]) +def test_click_cookie_clicks_the_requested_number_of_times(times: int) -> None: + game = FakeGame() + click_cookie(game, times) + assert game.clicked == [BIG_COOKIE_ID] * times + + +def test_click_cookie_defaults_to_one_click() -> None: + game = FakeGame() + click_cookie(game) + assert game.clicked == [BIG_COOKIE_ID] + + +def test_click_cookie_refuses_a_negative_count() -> None: + game = FakeGame() + with pytest.raises(ValueError, match="negative"): + click_cookie(game, -1) + assert game.clicked == [] + + +def test_get_cookie_count_reads_the_first_line_of_the_counter() -> None: + game = FakeGame({COOKIE_COUNTER_ID: "12,345 cookies\nper second: 3.5"}) + assert get_cookie_count(game) == 12345 + + +def test_get_cookie_count_reads_large_amounts_split_over_lines() -> None: + game = FakeGame({COOKIE_COUNTER_ID: "1.234 million\ncookies\nper second: 900"}) + assert get_cookie_count(game) == 1_234_000 diff --git a/tests/unit/test_number_parsing.py b/tests/unit/test_number_parsing.py new file mode 100644 index 0000000..1ca8004 --- /dev/null +++ b/tests/unit/test_number_parsing.py @@ -0,0 +1,49 @@ +"""@file +@brief Tests of parse_game_number. +""" + +import pytest + +from cookie_clicker_bot.domain.errors import UnreadableNumberError +from cookie_clicker_bot.domain.number_parsing import parse_game_number + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("0", 0), + ("1 cookie", 1), + ("15 cookies", 15), + ("12,345 cookies", 12345), + ("1,234,567", 1234567), + ], +) +def test_parse_plain_amounts(text: str, expected: int) -> None: + assert parse_game_number(text) == expected + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("1.234 million", 1_234_000), + ("2 billion", 2_000_000_000), + ("1.500 Thousand", 1500), + ("3 decillion", 3 * 10**33), + ], +) +def test_parse_amounts_with_number_word(text: str, expected: int) -> None: + assert parse_game_number(text) == expected + + +def test_parse_ignores_unknown_word_after_the_number() -> None: + assert parse_game_number("42 bakers") == 42 + + +def test_parse_rounds_decimals() -> None: + assert parse_game_number("7.6 cookies") == 8 + + +@pytest.mark.parametrize("text", ["", "cookies", "-5", "per second: 3"]) +def test_parse_rejects_text_without_a_leading_number(text: str) -> None: + with pytest.raises(UnreadableNumberError): + parse_game_number(text)